Files
tty7/.github/scripts/windows-installer.iss
T
l0ng-ai 0c9f4baa3a fix(cli): make the PATH install reversible, honest, and safe to migrate
Follow-up on the review of #277. Seven fixes, no change to what the feature
is for.

An AppImage copy is now claimed with a marker file instead of being inferred
from "am I an AppImage right now". Keying off the runtime meant that a user
who moved from the AppImage to the tarball hit their own copy, read it as
somebody else's binary, and never got another install for as long as that file
sat there.

The Windows uninstaller takes {app} back out of HKCU\Environment. Nothing did
before: the entry is written by the app at runtime, so Inno never knew it
existed and every uninstall grew the user's PATH by one dead entry. Unix has no
equivalent hook and still leaves its symlink behind; that is now stated in the
module docs rather than left to be discovered.

An occupied candidate directory no longer ends the scan, and every platform now
reports whether the install actually wins the lookup. `Occupied` on
/opt/homebrew/bin used to mean giving up while ~/.local/bin sat free, and
Windows — which appends to PATH and so never collides — reported `Installed`
even when an existing tty7 earlier on PATH kept beating it. A new
`InstalledShadowed` names the winner.

`cargo run --release` no longer repoints the developer's real tty7 at a build
tree. `cfg!(debug_assertions)` only covered the debug half of that.

The Windows registry PATH is read, matched, and written as UTF-16 throughout.
It went through `to_string_lossy` before, so a value the registry holds but
Rust cannot represent as a String would have been written back with U+FFFD in
place of its characters — the exact PATH corruption the surrounding code is
careful to avoid.

Two tests mutated $HOME and $PATH while the rest of the binary's tests ran
beside them, and src/ui/home.rs mutates $HOME too. `candidate_dirs` takes home
as a parameter, `place` takes its mode, and the PATH-joining and registry-
joining rules are pure functions — so no test in this module touches the
environment any more. 5 tests become 11, and the Windows joining logic is
covered on every platform.

Also: the config flag reaches Settings → About and both features docs instead
of being config.json-only, startup reads config.json once instead of twice, and
the CLI's strip failure warns like its sibling instead of being swallowed.
2026-07-31 16:49:22 +08:00

184 lines
8.5 KiB
Plaintext

; tty7 Windows installer (Inno Setup 6 — preinstalled on GitHub's
; windows-latest runners). Compiled by bundle-windows.ps1, which stages the
; payload and passes every path in via /D defines:
;
; /DAppVersion=<semver> version parsed from Cargo.toml
; /DStageDir=<abs path> staged payload (tty7-app.exe, completions\, LICENSE.txt, README.md)
; /DOutputDir=<abs path> where the setup exe is written
; /DOutputName=<basename> setup exe filename, without ".exe"
;
; Defaults to a per-user install ({localappdata}\Programs\tty7 — no UAC
; prompt), with an "install for all users" escape hatch in the dialog. The
; build is unsigned, so SmartScreen warns on first launch either way — same as
; the portable zip.
#ifndef AppVersion
#error Missing /DAppVersion — this script is meant to be compiled via bundle-windows.ps1
#endif
[Setup]
; Never change AppId: it is how Windows ties upgrades + the uninstall entry
; to previous installs of tty7.
AppId={{9A3F6C1E-4B7D-4E2A-8C5F-D01B92E64A37}
AppName=tty7
AppVersion={#AppVersion}
AppPublisher=tty7 contributors
AppPublisherURL=https://github.com/l0ng-ai/tty7
AppSupportURL=https://github.com/l0ng-ai/tty7/issues
AppUpdatesURL=https://github.com/l0ng-ai/tty7/releases
DefaultDirName={autopf}\tty7
DisableProgramGroupPage=yes
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
MinVersion=10.0
LicenseFile={#StageDir}\LICENSE.txt
SetupIconFile=..\..\assets\favicon.ico
UninstallDisplayIcon={app}\tty7-app.exe
OutputDir={#OutputDir}
OutputBaseFilename={#OutputName}
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
; The persistent daemon (tty7-app.exe --daemon) is a detached background process
; that outlives the GUI and holds the running image of tty7-app.exe, so Windows
; locks the file and an upgrade can't replace it. We stop it explicitly in
; PrepareToInstall below; keep the Restart Manager as a backstop but don't let
; it relaunch anything (the GUI respawns the daemon itself on next start).
CloseApplications=yes
RestartApplications=no
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
; Builds before the tty7/tty7-app split installed the GUI as tty7.exe. Upgrading
; only *adds* tty7-app.exe, so the old binary would stay on disk — and a taskbar
; pin (which Inno cannot rewrite, unlike the [Icons] shortcuts) still points at
; it. The user would keep launching the previous version from their pinned icon,
; against the same daemon endpoint as the new one. Delete it on upgrade; a fresh
; install simply has nothing to remove.
;
; That name is now reused by the CLI below, which lands at the very same path.
; The order saves us: [InstallDelete] runs before [Files], so the stale GUI is
; gone before the CLI is written, and what survives is never a mix of the two.
; The taskbar pin is the loose end — post-upgrade it points at a CLI, so
; clicking it flashes a console instead of opening a window. That is louder
; than the bug it replaces (silently running last release's GUI), and the pin
; is not ours to rewrite; the Start Menu entry in [Icons] is correct either way.
[InstallDelete]
Type: files; Name: "{app}\tty7.exe"
[Files]
Source: "{#StageDir}\tty7-app.exe"; DestDir: "{app}"; Flags: ignoreversion
; The CLI. `core::cli_install` adds {app} to the user's PATH at first launch,
; so this is not registered as an [Env] change here — the portable zip has no
; installer to do it, and one code path serving both is one behaviour to debug.
; The uninstaller takes that entry back out; see RemoveAppDirFromUserPath below.
Source: "{#StageDir}\tty7.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs
Source: "{#StageDir}\LICENSE.txt"; DestDir: "{app}"; Flags: ignoreversion
Source: "{#StageDir}\README.md"; DestDir: "{app}"; Flags: ignoreversion
; The Linux musl tty7-server, for serving WSL distros without a download.
; `skipifsourcedoesntexist` because a build whose server-musl leg was skipped
; still has to produce an installer. See bundle-windows.ps1.
Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion recursesubdirs skipifsourcedoesntexist
[Icons]
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"
Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon
[Run]
Filename: "{app}\tty7-app.exe"; Description: "{cm:LaunchProgram,tty7}"; Flags: nowait postinstall skipifsilent
[UninstallRun]
; Stop the daemon before the uninstaller deletes tty7-app.exe — the running daemon
; is the locked image of that file, so removing it fails otherwise. This runs at
; the start of uninstallation, before any files are removed. The installed binary
; is this version, which understands the flag; runhidden suppresses any flash and
; the call returns without opening a window. RunOnceId keys the entry so a repeated
; uninstall doesn't run it twice.
Filename: "{app}\tty7-app.exe"; Parameters: "--stop-daemon"; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon"
[Code]
(* Gracefully stop the persistent daemon before we overwrite tty7-app.exe. We can't
run the *installed* binary here — on an upgrade from an older build it may not
understand --stop-daemon and would launch the GUI instead — so we extract the
*new* tty7-app.exe to {tmp} and run that. It connects to the running daemon, hangs
up every shell, waits for it to exit (releasing the file lock), then returns
without opening a window. Best effort: any failure falls through to the Restart
Manager backstop, and a fresh install simply has no daemon to stop. *)
function PrepareToInstall(var NeedsRestart: Boolean): String;
var
ResultCode: Integer;
begin
ExtractTemporaryFile('tty7-app.exe');
Exec(ExpandConstant('{tmp}\tty7-app.exe'), '--stop-daemon', '',
SW_HIDE, ewWaitUntilTerminated, ResultCode);
Result := '';
end;
(* Take {app} back out of the user's PATH.
`core::cli_install` puts it there at first launch rather than the installer
doing it, because the portable zip has no installer — but that leaves nobody
to undo it, and an uninstall that permanently grows the user's PATH by one
dead entry is not an uninstall. So the removal lives here, on the one install
shape that has an uninstaller at all. (Unix has no equivalent hook: deleting
the .app or the tarball leaves the symlink behind for the user to remove.)
HKCU even for an all-users install: cli_install only ever writes the user
hive, so that is the only place an entry can be. On a machine where several
users ran tty7, this clears the one uninstalling — the others keep a dead
entry, which is the price of not needing elevation to install in the first
place.
Entries are compared case-insensitively and ignoring a trailing backslash,
and every other entry is written back verbatim: this is somebody's PATH, and
we are here to remove one thing from it, not to tidy it. No
WM_SETTINGCHANGE broadcast — the entry now names a deleted directory, so
nothing is waiting on the news, and it is gone from new shells at next
sign-in regardless. *)
procedure RemoveAppDirFromUserPath();
var
Existing, Rebuilt, Raw, Entry, Target: String;
P: Integer;
begin
if not RegQueryStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', Existing) then
exit;
Target := RemoveBackslashUnlessRoot(ExpandConstant('{app}'));
Rebuilt := '';
(* The trailing ';' makes the last entry look like every other one. *)
Existing := Existing + ';';
repeat
P := Pos(';', Existing);
Raw := Copy(Existing, 1, P - 1);
Existing := Copy(Existing, P + 1, Length(Existing));
Entry := Trim(Raw);
if (Entry <> '') and
(CompareText(RemoveBackslashUnlessRoot(Entry), Target) <> 0) then
begin
if Rebuilt <> '' then
Rebuilt := Rebuilt + ';';
Rebuilt := Rebuilt + Raw;
end;
until Existing = '';
if Rebuilt = '' then
RegDeleteValue(HKEY_CURRENT_USER, 'Environment', 'Path')
(* Inno cannot read a value's type back, so infer it: a PATH holding a '%' is
one that has to stay expandable, and rewriting it as REG_SZ would freeze
every other entry's variable at today's value. *)
else if Pos('%', Rebuilt) > 0 then
RegWriteExpandStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', Rebuilt)
else
RegWriteStringValue(HKEY_CURRENT_USER, 'Environment', 'Path', Rebuilt);
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
RemoveAppDirFromUserPath();
end;