diff --git a/README.md b/README.md
index d7ed9d9..00001db 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,31 @@ zig build run
Everything is pinned by `flake.nix`; nothing needs to be installed on the host.
+## Installing
+
+```sh
+mise run install # builds release, installs into ~/.local
+mise run uninstall
+```
+
+That puts the binary in `~/.local/libexec/vtabs`, a launcher on `~/.local/bin`,
+a desktop entry in `~/.local/share/applications`, and the icon in the hicolor
+theme, then refreshes the desktop and icon caches. `mise tasks` lists the rest
+(`build`, `run`, `fmt`, `screenshot`).
+
+Two things the installer has to handle that a normal `install` wouldn't:
+
+- **A launcher, not a symlink.** The binary links against GTK in the Nix store
+ and has an RPATH, so it runs anywhere — but it still needs to be pointed at
+ GTK's GSettings schemas and icon themes, or it aborts on a missing schema.
+ A desktop launcher starts the app with a minimal environment, so those paths
+ are baked into the launcher script. They are read out of the dev shell at
+ install time rather than hardcoded, so they can't drift from the flake.
+- **Nix GC roots.** The installed app depends on store paths that
+ `nix-collect-garbage` would otherwise be free to delete, which would break it
+ later with no obvious connection to the command that did it. Install pins
+ every store path in the binary's RPATH; uninstall releases them.
+
## Which "libghostty" this uses, and why
Ghostty ships two different C APIs, and only one of them is usable here:
@@ -116,8 +141,8 @@ This is a proof of concept, and the following are deliberately absent:
`./b.sh` wraps `nix develop --command zig build` and strips the enormous
command line Zig prints on failure.
-`./shot.sh out.png "text to type"` runs the app inside a throwaway **headless
-Sway** and screenshots it. This keeps UI checks entirely out of your real
+`mise run screenshot out.png "text to type"` (or `./shot.sh` directly) runs the
+app inside a throwaway **headless Sway** and screenshots it. This keeps UI checks entirely out of your real
Wayland session — nothing appears on screen, and it works while the session is
locked.
diff --git a/dist/dev.greyson.vtabs.desktop.in b/dist/dev.greyson.vtabs.desktop.in
new file mode 100644
index 0000000..5b43c4a
--- /dev/null
+++ b/dist/dev.greyson.vtabs.desktop.in
@@ -0,0 +1,16 @@
+[Desktop Entry]
+Type=Application
+Name=vtabs
+GenericName=Terminal
+Comment=Terminal emulator with vertical tabs
+# Substituted with an absolute path by `mise run install`, because desktop
+# launchers do not reliably have ~/.local/bin on PATH.
+Exec=@EXEC@
+Icon=dev.greyson.vtabs
+Terminal=false
+Categories=System;TerminalEmulator;
+Keywords=shell;prompt;command;commandline;cmd;terminal;tabs;
+StartupNotify=true
+# Lets the compositor match the running window to this entry, which is what
+# gives the window its icon. Must equal the GApplication id.
+StartupWMClass=dev.greyson.vtabs
diff --git a/dist/dev.greyson.vtabs.svg b/dist/dev.greyson.vtabs.svg
new file mode 100644
index 0000000..0c3e30b
--- /dev/null
+++ b/dist/dev.greyson.vtabs.svg
@@ -0,0 +1,44 @@
+
+
+
diff --git a/flake.nix b/flake.nix
index fc8b531..9f40d1d 100644
--- a/flake.nix
+++ b/flake.nix
@@ -84,10 +84,20 @@
];
shellHook = ''
- # GTK needs to find icon themes and schemas at runtime when the app
- # is launched straight out of the dev shell rather than installed.
- export XDG_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share:$XDG_DATA_DIRS"
- export GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas"
+ # The runtime data GTK needs to find at startup: its own GSettings
+ # schemas and the icon themes our widgets pull symbolic icons from.
+ #
+ # These are exported under their own names, rather than only folded
+ # into XDG_DATA_DIRS, because `mise run install` reads them back out
+ # to bake into the installed launcher. The built binary already has
+ # an RPATH into the Nix store and runs fine outside this shell, but
+ # it still has to be pointed at this data or GTK aborts on a missing
+ # schema and falls back to broken-image icons.
+ export VTABS_GSETTINGS_SCHEMA_DIR="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}/glib-2.0/schemas"
+ export VTABS_RUNTIME_DATA_DIRS="${pkgs.gtk4}/share/gsettings-schemas/${pkgs.gtk4.name}:${pkgs.adwaita-icon-theme}/share:${pkgs.hicolor-icon-theme}/share"
+
+ export GSETTINGS_SCHEMA_DIR="$VTABS_GSETTINGS_SCHEMA_DIR"
+ export XDG_DATA_DIRS="$VTABS_RUNTIME_DATA_DIRS:$XDG_DATA_DIRS"
'';
};
});
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..79f8b04
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,117 @@
+# Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is
+# nothing for mise to install; these tasks just drive it.
+
+[tasks.build]
+description = "Build a release binary into zig-out/bin"
+run = "nix develop --command zig build -Doptimize=ReleaseFast"
+
+[tasks.build-debug]
+description = "Build a debug binary (leak checking, safety checks)"
+run = "nix develop --command zig build"
+
+[tasks.run]
+description = "Build and run vtabs"
+run = "nix develop --command zig build run"
+
+[tasks.fmt]
+description = "Format all Zig source"
+run = "nix develop --command zig fmt build.zig src"
+
+[tasks.screenshot]
+description = "Screenshot vtabs in a throwaway headless compositor"
+depends = ["build-debug"]
+run = "nix develop --command ./shot.sh"
+
+[tasks.install]
+description = "Install vtabs into ~/.local with a desktop entry and icon"
+depends = ["build"]
+run = '''
+#!/usr/bin/env bash
+set -euo pipefail
+
+data="${XDG_DATA_HOME:-$HOME/.local/share}"
+bindir="$HOME/.local/bin"
+libexec="$HOME/.local/libexec/vtabs"
+apps="$data/applications"
+icons="$data/icons/hicolor/scalable/apps"
+gcroots="$data/vtabs/gcroots"
+
+# The binary carries an RPATH into the Nix store, so it runs anywhere, but it
+# still has to be told where GTK's GSettings schemas and icon themes live or
+# it aborts on a missing schema. Read those paths back out of the dev shell
+# that built it rather than hardcoding store hashes.
+schemas="$(nix develop --command printenv VTABS_GSETTINGS_SCHEMA_DIR)"
+datadirs="$(nix develop --command printenv VTABS_RUNTIME_DATA_DIRS)"
+
+mkdir -p "$bindir" "$libexec" "$apps" "$icons" "$gcroots"
+
+install -m755 zig-out/bin/vtabs "$libexec/vtabs"
+
+# A launcher script rather than a symlink: a desktop launcher starts the app
+# with a minimal environment, so the GTK runtime paths must be set here.
+# Existing values are preserved so this composes with the rest of the session.
+cat > "$bindir/vtabs" < "$apps/dev.greyson.vtabs.desktop"
+install -m644 dist/dev.greyson.vtabs.svg "$icons/dev.greyson.vtabs.svg"
+
+# Pin the store paths the installed binary links against. Without this a
+# later `nix-collect-garbage` deletes GTK and the installed app stops
+# starting, with no obvious connection to the command that broke it.
+# Nix roots are transitive, so rooting each RPATH entry covers its closure.
+rm -f "$gcroots"/*
+if command -v readelf >/dev/null; then
+ readelf -d "$libexec/vtabs" \
+ | sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' \
+ | tr ':' '\n' \
+ | sed -n 's|^\(/nix/store/[^/]*\).*|\1|p' \
+ | sort -u \
+ | while read -r path; do
+ nix-store --realise --add-root "$gcroots/$(basename "$path")" \
+ --indirect "$path" >/dev/null 2>&1 || true
+ done
+ echo "pinned $(find "$gcroots" -maxdepth 1 -type l | wc -l) store paths against garbage collection"
+else
+ echo "warning: readelf not found; store paths left unpinned (nix-collect-garbage may break the install)" >&2
+fi
+
+# Let the desktop notice the new entry and icon right away.
+command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true
+command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true
+
+echo "installed $bindir/vtabs"
+case ":$PATH:" in
+ *":$bindir:"*) ;;
+ *) echo "note: $bindir is not on your PATH; the desktop entry still works" >&2 ;;
+esac
+'''
+
+[tasks.uninstall]
+description = "Remove everything `install` put into ~/.local"
+run = '''
+#!/usr/bin/env bash
+set -euo pipefail
+
+data="${XDG_DATA_HOME:-$HOME/.local/share}"
+apps="$data/applications"
+
+rm -f "$HOME/.local/bin/vtabs"
+rm -rf "$HOME/.local/libexec/vtabs"
+rm -f "$apps/dev.greyson.vtabs.desktop"
+rm -f "$data/icons/hicolor/scalable/apps/dev.greyson.vtabs.svg"
+# Dropping the roots lets Nix reclaim the runtime closure on its next GC.
+rm -rf "$data/vtabs"
+
+command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true
+command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true
+
+echo "uninstalled vtabs"
+'''