mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
* fix(windows): make the install directory actually replaceable before updating
The updater stopped the daemon and started the Inno installer the moment
the daemon's endpoint disappeared — but the endpoint going away is not
the same event as the images being released. The ConPTY hosts
(OpenConsole.exe) are the daemon's children, not the shells', so the
per-pane kill never reached them, and the daemon's exit(0) skipped every
destructor that would have closed them; they kept the installed
OpenConsole.exe open for seconds after --stop-daemon returned. Silent
Setup then hit the lock, took the suppressed dialog's default (Abort),
and the updater's recovery relaunched the old build — "updated,
restarted, still the old version". A daemon that died without cleaning
up made it permanent: its orphaned hosts survive indefinitely, which is
the DeleteFile-code-5 users hit even after "closing everything".
Reproduced both shapes in isolation before fixing: with a pane open,
--stop-daemon returned ~1s in while OpenConsole.exe stayed locked for
another ~1.4s; after taskkill on the daemon, the orphaned host held the
lock forever.
The shutdown now finishes what it starts, at every layer that can be
the last one standing:
* The daemon reaps its remaining descendants and waits for them
before exiting, while the endpoint — the signal stop() watches —
is still up.
* stop() reads the pidfile before asking, and waits for that process
to actually exit after the endpoint goes, not just stop listening.
* The recorded-daemon reap waits for the images to be released
instead of returning on the async TerminateProcess.
* stop_for_update(dir) — reached via --stop-daemon
--update-install-dir, which PrepareToInstall and the portable
updater now pass — also terminates anything still running from the
installation directory (the orphan case no pidfile can name) and
only returns once the .exe/.dll images there open for writing,
naming the holdouts in the error if they never do.
* The updater runs that clearing itself before invoking Setup, so a
directory that cannot be cleared fails with a cause in update.log
and relaunches the previous build, instead of Inno's bare
"DeleteFile failed; code 5".
The update dialog on Windows also told a macOS truth — "the background
service keeps running, so whatever is open in your panes survives".
Windows cannot replace a running daemon's image, so its install path
stops the service; the dialog now says so.
* fix(windows): tighten the install-dir clearing per review
- An image that fails to canonicalize stays in the lock check instead of
being silently skipped; only a positive match against the caller's own
running image is excluded.
- reap_recorded_daemon shares one deadline across the whole tree via a
new winproc::terminate_and_wait_all, which stop_for_update and
reap_descendants_of now use too — one implementation of "terminate,
then wait, bounded overall" instead of three.
- [UninstallRun] passes --update-install-dir "{app}" like
PrepareToInstall, so uninstalling after a daemon crash gets the same
orphaned-ConPTY-host cleanup as upgrading.
* fix(update): close three gaps the update audit found
- macOS updater: wait for the parent by watching getppid() reparent to
launchd instead of polling kill(pid, 0), which a recycled pid could
satisfy forever. The kill loop remains only for a hand-run updater.
- Windows: a new update guard (config-dir update.lock, held by the
updater from daemon stop to relaunch) makes ensure_running refuse to
spawn a daemon mid-install, so a tty7 CLI call or manual launch can no
longer relock the images the installer is replacing. Stale guards —
dead writer or past the TTL — are shed on sight.
- Windows portable: the update backup now carries an incomplete marker
from before the first file moves until the replacement lands. At
launch the app reports a backup still carrying it as an interrupted
update (the installation may mix two versions; the old files are
preserved), and silently removes marker-less backups a finished
update failed to delete past an antivirus hold.
* fix(update): verify the guard's writer by start time, and guard manual Setup runs
Review round three, both findings and all three minors:
- The guard no longer expires a live, verified holder: a pid is believed
to be the writer only if the process behind it started before the
guard was written (winproc::creation_time via GetProcessTimes), which
is what tells a genuine holder from a recycled pid. The TTL now bounds
only the unverifiable case, so an install slowed past ten minutes by
an antivirus sweep keeps its protection.
- Manual Setup runs get the guard too: the --stop-daemon
--update-install-dir helper holds it in its parent's name — the Setup
or uninstaller that keeps replacing files after the helper returns —
and it goes stale when that parent exits. ensure_running gained five
seconds of patience so the post-install "Launch tty7" click, racing
Setup's own exit, gets its daemon instead of an error.
- processes_running_from also matches images against the canonicalized
install-dir spelling (junction, subst, 8.3 given form).
- reconcile_portable_backups reports every interrupted backup, not the
first.
- The unix signal-and-wait loop now reuses wait_for_recorded_exit.
* style: rustfmt
---------
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
255 lines
13 KiB
Plaintext
255 lines
13 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> display version parsed from Cargo.toml
|
|
; /DVersionInfoVersion=<numeric version> PE-compatible file version
|
|
; /DStageDir=<abs path> staged payload (app, CLI, updater, marker, resources)
|
|
; /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
|
|
#ifndef VersionInfoVersion
|
|
#error Missing /DVersionInfoVersion — 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}
|
|
VersionInfoVersion={#VersionInfoVersion}
|
|
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
|
|
; Writing shell verbs is an install-time decision, the way VS Code and Git for
|
|
; Windows treat theirs — not a runtime preference, so tty7 has no setting for
|
|
; it. Off by default: the registry is user-visible system state. The keys land
|
|
; under HKCU even for an all-users install, so this only ever affects whoever
|
|
; ran the installer. Inno restores the previous choice when upgrading.
|
|
Name: "explorermenu"; Description: "Add ""Open in tty7"" to the folder context menu"; GroupDescription: "Shell integration:"; 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}\tty7-updater.exe"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
|
|
; This installer-only marker is the authority for enabling automatic Windows
|
|
; updates. The portable archive is created before the marker enters the stage.
|
|
Source: "{#StageDir}\.tty7-inno-install"; DestDir: "{app}"; Flags: ignoreversion skipifsourcedoesntexist
|
|
Source: "{#StageDir}\completions\*"; DestDir: "{app}\completions"; Flags: ignoreversion recursesubdirs
|
|
; Microsoft's redistributable ConPTY. It has to land in {app} rather than a
|
|
; subdirectory: `portable-pty` finds it through the DLL search path, which
|
|
; starts at the directory of the executable that loads it (the daemon is
|
|
; {app}\tty7-app.exe). The two files are a matched pair — never update one
|
|
; alone. See assets\windows\conpty\README.md.
|
|
Source: "{#StageDir}\conpty.dll"; DestDir: "{app}"; Flags: ignoreversion
|
|
Source: "{#StageDir}\OpenConsole.exe"; DestDir: "{app}"; Flags: ignoreversion
|
|
Source: "{#StageDir}\LICENSE-ConPTY.txt"; DestDir: "{app}"; Flags: ignoreversion
|
|
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
|
|
|
|
; AppUserModelID is what lets toast notifications carry the tty7 name and icon
|
|
; instead of the notify-rust PowerShell fallback: Windows only honors an
|
|
; unpackaged app's toast identity when a shortcut stamps it. Must match
|
|
; `core::aumid::AUMID` (src/core/aumid.rs), which at startup stamps the
|
|
; per-user shortcut below if some older installer left it unstamped, and
|
|
; writes one from scratch for the portable zip. It deliberately leaves an
|
|
; all-users install alone — it cannot write {commonprograms} unelevated, and a
|
|
; per-user twin would both duplicate the Start Menu entry and outlive this
|
|
; uninstaller — so an elevated install depends on the stamp right here.
|
|
[Icons]
|
|
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"; AppUserModelID: "com.github.tty7"
|
|
Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon; AppUserModelID: "com.github.tty7"
|
|
|
|
[Run]
|
|
; The registry shape lives in core::explorer_context_menu, not here: the app
|
|
; reads those same keys to decide whether an existing registration still points
|
|
; at this install, and two hand-kept copies of the layout would drift. Runs
|
|
; before the launch entry below so a first start already sees the final state.
|
|
Filename: "{app}\tty7-app.exe"; Parameters: "--register-explorer-menu"; Tasks: explorermenu; Flags: runhidden waituntilterminated
|
|
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. Naming {app}
|
|
; widens the stop the same way PrepareToInstall's does: ConPTY hosts orphaned by
|
|
; a daemon that never got to shut down keep the installed images open, and their
|
|
; DeleteFile fails an uninstall exactly as it fails an upgrade. (The stop excludes
|
|
; the calling process itself, so the binary running this step is safe.) This runs
|
|
; at the start of uninstallation, before any files are removed. The installed
|
|
; binary is this version, which understands the flags; 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 --update-install-dir ""{app}"""; Flags: runhidden waituntilterminated; RunOnceId: "StopDaemon"
|
|
; Unconditional, and deliberately not gated on the task: an install that had the
|
|
; menu registered and was later upgraded without the box ticked still holds the
|
|
; keys, and verbs pointing at a deleted exe are worse than a no-op. Removing keys
|
|
; that were never written succeeds silently.
|
|
Filename: "{app}\tty7-app.exe"; Parameters: "--unregister-explorer-menu"; Flags: runhidden waituntilterminated; RunOnceId: "UnregisterExplorerMenu"
|
|
|
|
[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. Naming {app} widens the stop into "make this directory
|
|
replaceable": ConPTY hosts (OpenConsole.exe) orphaned by a daemon that never got
|
|
to shut down keep the installed images open — invisible to the daemon stop, fatal
|
|
to the DeleteFile below — so anything still running from {app} is terminated and
|
|
the call waits until the images there actually open for writing. Best effort: any
|
|
failure falls through to the Restart Manager backstop, and a fresh install simply
|
|
has nothing to stop. *)
|
|
function PrepareToInstall(var NeedsRestart: Boolean): String;
|
|
var
|
|
ResultCode: Integer;
|
|
begin
|
|
ExtractTemporaryFile('tty7-app.exe');
|
|
Exec(ExpandConstant('{tmp}\tty7-app.exe'),
|
|
'--stop-daemon --update-install-dir "' + ExpandConstant('{app}') + '"', '',
|
|
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;
|
|
|
|
(* Remove the optional Explorer verbs if this user enabled them in Settings.
|
|
|
|
The application owns only the final `tty7` subkeys. Deleting those trees
|
|
removes their command children without touching another application's verb
|
|
or a shared `shell` parent. Missing keys are the normal default and make both
|
|
calls harmless no-ops. This cleanup is uninstall-only: upgrades keep the
|
|
user's explicit registration, and the next app launch reports "Needs update"
|
|
if an install path ever changes. *)
|
|
procedure RemoveExplorerContextMenu();
|
|
begin
|
|
RegDeleteKeyIncludingSubkeys(
|
|
HKEY_CURRENT_USER, 'Software\Classes\Directory\shell\tty7');
|
|
RegDeleteKeyIncludingSubkeys(
|
|
HKEY_CURRENT_USER, 'Software\Classes\Directory\Background\shell\tty7');
|
|
end;
|
|
|
|
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
|
|
begin
|
|
if CurUninstallStep = usUninstall then
|
|
begin
|
|
RemoveAppDirFromUserPath();
|
|
RemoveExplorerContextMenu();
|
|
end;
|
|
end;
|