From f72195e16018e937cc82109e9682b9a0f838eafc Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:04 +0200 Subject: [PATCH 1/7] fix(installer): close Wind in other sessions before replacing or removing it (#498) WIND_QUIT_RUNNING only stopped this session's Wind, but every session runs the same Program Files exes, so another user's Wind kept them locked and the upgrade or uninstall left a broken install. After the clean local quit, ask to force-close Wind, WindTray and WindConfig in other sessions (silent runs do), abort without changes on No, give up after three tries. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- installer/app.nsh | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/installer/app.nsh b/installer/app.nsh index 1d1dc84e..92d9f5bc 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -43,8 +43,8 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then DetailPrint "Closing Wind..." ; Every Wind object here is session-local (the quit event, the mutex), and an HKLM Run entry - ; starts one Wind per logged-on user. So the process checks and the force-kill fallback below - ; are limited to this session; another user's Wind keeps running. + ; starts one Wind per logged-on user. So the clean quit and its force-kill fallback below are + ; limited to this session; another user's Wind is handled at the end, by asking first. System::Call 'kernel32::GetCurrentProcessId() i .r0' System::Call 'kernel32::ProcessIdToSessionId(i r0, *i .r1)' StrCpy $WindSession $1 @@ -122,6 +122,32 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then nsExec::Exec 'taskkill /IM WindTray.exe /FI "SESSION eq $WindSession" /F' Pop $0 ${EndIf} + + ; Another signed-in user's Wind runs the same exes from the same folder (the Run entry is + ; HKLM), so it keeps them locked: the copy or delete below would fail and leave a broken + ; install. Its quit event is in that session's namespace, out of reach from here, so the only + ; way to free the files is a forced close, and that is the user's call. A silent run closes it. + ; Three tries, then stop: a close that keeps failing must not loop a silent run forever. + StrCpy $3 0 + ${Do} + nsExec::Exec 'cmd /c tasklist /FI "SESSION ne $WindSession" /NH | findstr /I /B "Wind.exe WindTray.exe WindConfig.exe"' + Pop $4 + ${If} $4 != 0 + ${Break} ; nothing running in any other session + ${EndIf} + ${If} $3 >= 3 + MessageBox MB_ICONSTOP|MB_OK "Setup couldn't close Wind for another user signed in to this PC. Ask them to quit Wind, then run Setup again." /SD IDOK + Abort "Wind is still running for another user." + ${EndIf} + IntOp $3 $3 + 1 + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Wind is also running for another user signed in to this PC, and Setup can't update or remove it while it runs.$\r$\n$\r$\nClose it for them now? If you choose No, Setup stops without changing anything." /SD IDYES IDYES windCloseOthers + Abort "Wind is running for another user." + windCloseOthers: + DetailPrint "Closing Wind in other sessions..." + nsExec::Exec 'taskkill /IM WindConfig.exe /IM Wind.exe /IM WindTray.exe /FI "SESSION ne $WindSession" /F' + Pop $0 + Sleep 500 + ${Loop} Sleep 300 !macroend From 17ecd666e4d22cfc12b05239b684b0dc5746b1ff Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:04 +0200 Subject: [PATCH 2/7] fix(build): never pack a stale build\WindUA.exe into the installer (#498) wind.nsi turns on local signing and installs WindUA.exe as Wind.exe whenever the file exists, and only release.ps1 refreshed it. Every Wind.exe build now deletes it unless release.ps1 sets WIND_KEEP_UA for the build made from the same tree, and a certificate run deletes it even with -SkipBuild. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- build.bat | 5 +++++ installer/README.md | 4 +++- tools/release.ps1 | 12 +++++++++--- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/build.bat b/build.bat index 1d3212e9..12fb43d9 100644 --- a/build.bat +++ b/build.bat @@ -68,6 +68,11 @@ rem --- Wind.exe itself, shared by the app and uiaccess targets. %1 = extra defi rem %2 = the manifest to embed, %3 = its object dir under build\obj. Everything else (sources, rem flags, libraries) is the same. Output: build\Wind.exe and build\Wind.pdb. :core_exe +rem A new Wind.exe makes any build\WindUA.exe stale: the installer packs WindUA.exe as the +rem Wind.exe it installs whenever the file exists (installer\wind.nsi, LOCAL_SIGN), so a leftover +rem one would ship old code. tools\release.ps1 sets WIND_KEEP_UA for the one build that must keep +rem the WindUA.exe it has just made from the same tree. +if not "%WIND_KEEP_UA%"=="1" if exist "%BUILD%\WindUA.exe" del /q "%BUILD%\WindUA.exe" set "CORE_OBJ=%OBJ%\%~3" if not exist "%CORE_OBJ%" mkdir "%CORE_OBJ%" rem Compile the app-icon resource (rc.exe ships with the Windows SDK, on PATH via vcvars). diff --git a/installer/README.md b/installer/README.md index 6f3808c1..42007c21 100644 --- a/installer/README.md +++ b/installer/README.md @@ -56,7 +56,9 @@ which makes `wind.nsi` define `LOCAL_SIGN`. Setup then runs `local-sign.ps1`: over only after its signature verifies. An unsigned UIAccess `Wind.exe` does not start at all, so any failure leaves the ordinary build. Wind refuses to start without UIAccess (issue #460), so setup then shows an error and does not launch it. -- A release signed with a real certificate has no `WindUA.exe` and skips this. +- A release signed with a real certificate has no `WindUA.exe` and skips this. Any `Wind.exe` build + deletes `WindUA.exe` (`build.bat :core_exe`) unless `release.ps1` just made both, so `build.bat + installer` never packs a stale one. ## Licence screen diff --git a/tools/release.ps1 b/tools/release.ps1 index 56010f23..e8fe73e2 100644 --- a/tools/release.ps1 +++ b/tools/release.ps1 @@ -89,11 +89,14 @@ if ($cert) { # build.bat puts every binary in build\ (never the repo root); the installer packs them from there. $bin = "$root\build" $ua = "$bin\WindUA.exe" +# With a certificate the signed uiAccess build IS Wind.exe, so a WindUA.exe left over from an +# earlier run must never reach the installer, -SkipBuild or not: its presence turns local +# signing on, and setup would install it over the properly signed Wind.exe. +if ($cert -and (Test-Path $ua)) { Remove-Item $ua -Force } if (-not $SkipBuild) { # No certificate: build the uiAccess variant as well, as WindUA.exe. Setup signs it on the # PC it installs to (installer\local-sign.ps1, issue #261) and keeps the standard build as - # the fallback. With a certificate the signed uiAccess build IS Wind.exe, so a WindUA.exe - # left over from an earlier run must not reach the installer. + # the fallback. if (Test-Path $ua) { Remove-Item $ua -Force } if (-not $cert) { Write-Host "=== building WindUA.exe (uiaccess, signed at install) ===" @@ -101,7 +104,10 @@ if (-not $SkipBuild) { Move-Item "$bin\Wind.exe" $ua -Force } Write-Host "=== building Wind.exe ($(if ($variant) { $variant } else { 'standard' })) ===" - Invoke-Build $variant + # Every Wind.exe build deletes build\WindUA.exe as stale (build.bat :core_exe); this one is + # from the same tree as the WindUA.exe just made, so it keeps it. + $env:WIND_KEEP_UA = '1' + try { Invoke-Build $variant } finally { Remove-Item Env:WIND_KEEP_UA -ErrorAction SilentlyContinue } Write-Host "=== building WindConfig.exe + ui\dist ===" Invoke-Build 'config' } From b4bc915e9ef473d09755238bcc2bc7b1016bef25 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:04 +0200 Subject: [PATCH 3/7] fix(deploy): quit Wind cleanly and trust the dev cert without a %TEMP% file (#498) uiaccess_setup.ps1 force-killed every session's Wind, skipping the cursor and clip restore. It now sets Local\Wind_QuitRequest, waits, and kills only as a fallback, in this session only. The dev root is added to LocalMachine Root and TrustedPublisher through X509Store instead of a fixed-name .cer in the user-writable %TEMP%. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- tools/uiaccess_setup.ps1 | 45 +++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/tools/uiaccess_setup.ps1 b/tools/uiaccess_setup.ps1 index 3f2aa5a9..9331700c 100644 --- a/tools/uiaccess_setup.ps1 +++ b/tools/uiaccess_setup.ps1 @@ -5,6 +5,33 @@ $ErrorActionPreference = 'Stop' # Derive paths from the script's own location ($PSScriptRoot = the tools\ dir) so this runs # from any clone, not just the original dev machine. $log = Join-Path $PSScriptRoot 'uiaccess_setup.log' + +# Stop this session's Wind the way the installer does (installer\app.nsh, WIND_QUIT_RUNNING): set +# its quit event so it exits CLEANLY, restoring the OS cursor, releasing any ClipCursor and the +# Magnification runtime, and force-kill only what is still running after a few seconds. A kill +# alone can leave the pointer hidden or pinned to one pixel. This session only: Get-Process from +# an elevated shell sees every session, and another user's Wind is theirs to close. +function Stop-WindHere { + $sid = (Get-Process -Id $PID).SessionId + $mine = { param($names) @(Get-Process $names -ErrorAction SilentlyContinue | Where-Object SessionId -eq $sid) } + # WindConfig first and by force, as setup does: it holds no OS state, and a close with unsaved + # settings would open a confirm dialog. + & $mine 'WindConfig' | Stop-Process -Force -ErrorAction SilentlyContinue + $ev = $null + if ([System.Threading.EventWaitHandle]::TryOpenExisting('Local\Wind_QuitRequest', [ref]$ev)) { + try { [void]$ev.Set() } finally { $ev.Dispose() } + } + foreach ($w in @(@{ Name = 'Wind'; Ms = 5000 }, @{ Name = 'WindTray'; Ms = 2000 })) { + foreach ($p in (& $mine $w.Name)) { + if (-not $p.WaitForExit($w.Ms)) { + Write-Output "$($w.Name) (pid $($p.Id)) did not exit; stopping it." + Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue + } + } + } + Start-Sleep -Milliseconds 300 +} + Start-Transcript -Path $log -Force try { $root = Split-Path -Parent $PSScriptRoot @@ -15,9 +42,8 @@ try { $traySrc = "$root\build\WindTray.exe" # built by build.bat uiaccess; plain manifest, NO uiAccess (#291) $uiSrc = "$root\ui\dist" - Write-Output "=== 0a. stop any running Wind / WindConfig (dev or deployed) so the exes are not locked ===" - Get-Process Wind,WindTray,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force - Start-Sleep -Milliseconds 400 + Write-Output "=== 0a. stop this session's Wind / WindConfig (dev or deployed) so the exes are not locked ===" + Stop-WindHere Write-Output "=== 0b. build the UIAccess variant (uiAccess=true manifest) ===" & cmd /c "`"$root\build.bat`" uiaccess" @@ -56,15 +82,19 @@ try { } Write-Output "=== 2. trust it (LocalMachine Root + TrustedPublisher, if not already) ===" - $cer = "$env:TEMP\winddev.cer" - Export-Certificate -Cert $cert -FilePath $cer | Out-Null + # Straight into the stores, with no .cer round trip: a file in %TEMP% is writable by every + # unelevated process of this user, which could swap its own root in between export and import. + # Only the public part goes in (no private key in Root or TrustedPublisher). + $public = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.RawData) foreach ($store in 'Root','TrustedPublisher') { $present = Get-ChildItem "Cert:\LocalMachine\$store" | Where-Object { $_.Thumbprint -eq $cert.Thumbprint } if ($present) { Write-Output "$store : already trusted" } else { - Import-Certificate -FilePath $cer -CertStoreLocation "Cert:\LocalMachine\$store" | Out-Null + $st = [System.Security.Cryptography.X509Certificates.X509Store]::new($store, 'LocalMachine') + $st.Open('ReadWrite') + try { $st.Add($public) } finally { $st.Close() } Write-Output "$store : imported" } } @@ -82,8 +112,7 @@ try { Write-Output "WindTray.exe sign status=$($sigTray.Status)" Write-Output "=== 4. deploy Wind.exe, WindTray.exe, WindConfig.exe and ui\dist to C:\Program Files\Wind ===" - Get-Process Wind,WindTray,WindConfig -ErrorAction SilentlyContinue | Stop-Process -Force - Start-Sleep -Milliseconds 300 + Stop-WindHere $dst = "C:\Program Files\Wind" New-Item -ItemType Directory -Force $dst | Out-Null Copy-Item $src "$dst\Wind.exe" -Force From 71cd98dd5e68892d10a3391745e86703deac87af Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:04 +0200 Subject: [PATCH 4/7] fix(installer-check): admin-only smoke dir and keep the real install's shortcuts (#498) The elevated smoke install ran setup's staged signing script from %TEMP%, which the user can write; it now installs into a fresh folder under Program Files. The all-users Start menu and desktop Wind.lnk are saved and restored like the registry values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- tools/installer_check.ps1 | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tools/installer_check.ps1 b/tools/installer_check.ps1 index 181bb400..4c2e2e34 100644 --- a/tools/installer_check.ps1 +++ b/tools/installer_check.ps1 @@ -138,9 +138,22 @@ if (-not (Test-Path $setup)) { $wasRunning = @(Get-Process -Name Wind, WindTray -ErrorAction SilentlyContinue | ForEach-Object { $_.Path } | Where-Object { $_ }) - $scratch = Join-Path $env:TEMP 'wind-installer-check' + # Not under %TEMP%: with local signing, setup stages local-sign.ps1 and the uiAccess build in + # $INSTDIR\.stage and runs the script as admin, which is only safe in an admin-only folder + # (wind.nsi). A new folder in Program Files inherits its ACL, so nothing unelevated can write + # there, or create it in advance. + $scratch = Join-Path $env:ProgramFiles 'Wind-installer-check' if (Test-Path $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force } + # The smoke install points the all-users Start menu shortcut at $scratch, and the smoke + # uninstall deletes it and the all-users desktop one, so a real install's shortcuts are saved + # and put back like its registry values. (Bytes in memory: no file to restore from.) + $links = @(foreach ($f in 'CommonPrograms', 'CommonDesktopDirectory') { + $lnk = Join-Path ([Environment]::GetFolderPath($f)) 'Wind.lnk' + $raw = if (Test-Path -LiteralPath $lnk) { [IO.File]::ReadAllBytes($lnk) } else { $null } + [pscustomobject]@{ Path = $lnk; Raw = $raw } + }) + # The uninstaller must KEEP the user's settings unless they say otherwise, and a silent # uninstall answers its prompt with /SD IDNO. Testing that needs something to preserve: on a # machine that has never run Wind (every CI runner) the folder does not exist, and asserting @@ -221,6 +234,12 @@ if (-not (Test-Path $setup)) { if (Test-Path $scratch) { Remove-Item -LiteralPath $scratch -Recurse -Force -ErrorAction SilentlyContinue } + # A shortcut that was here is put back; one that was not came from the smoke install. + foreach ($l in $links) { + if ($null -ne $l.Raw) { [IO.File]::WriteAllBytes($l.Path, [byte[]]$l.Raw) } + elseif (Test-Path -LiteralPath $l.Path) { Remove-Item -LiteralPath $l.Path -Force -ErrorAction SilentlyContinue } + } + # Put back whatever was here before the smoke test. if ($savedArp) { New-Item -Path $ARP -Force | Out-Null From 3b41c7f72dd00ec50ac0bd79b0831dc773b87a1f Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:05 +0200 Subject: [PATCH 5/7] ci: republish on shipped text changes and build the installer on PRs (#498) release.yml ignored LICENSE and every .md, but the installer ships LICENSE and THIRD-PARTY-NOTICES.md and publishes release-notes.md; a paths list takes those back. build.yml now runs the shared build-installer action, so makensis /WX and installer_check.ps1 failures break the PR instead of main. Nothing is published. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- .github/workflows/build.yml | 32 ++++++++------------------------ .github/workflows/release.yml | 27 ++++++++++++++++----------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f0cc17a3..8a7bcd66 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,27 +15,11 @@ jobs: - name: Build app (compile + link + manifest) shell: cmd run: build.bat - - name: Build and run unit tests - shell: cmd - run: build.bat test - # Everything release.yml builds must also break the PR, not main (review 2026-10-09 M7): the - # settings host and UI (build.bat config), the signed-variant compile (uiaccess) and the - # settings UI's Playwright suite. Node comes with the runner image, as in release.yml. - - name: Install UI dependencies - shell: cmd - working-directory: ui - run: npm ci - - name: Keybind rules, JS half (node --test) - shell: cmd - working-directory: ui - run: npm run test:rules - - name: Build settings host and UI (build/WindConfig.exe, ui/dist) - shell: cmd - run: build.bat config - - name: Build the UIAccess variant - shell: cmd - run: build.bat uiaccess - - name: UI tests (Playwright, headless) - shell: cmd - working-directory: ui - run: npx playwright install chromium && npx playwright test --reporter=line + # Everything release.yml builds must also break the PR, not main (review 2026-10-09 M7, and + # 2026-10-16: the installer itself). So the PR runs the same shared action release.yml and + # alpha.yml publish from: the unit tests, the keybind JS test, the Playwright UI suite and tools/release.ps1, + # which builds the UIAccess variant, the standard build, the settings host and UI, compiles + # the installer (makensis /WX) and runs tools/installer_check.ps1. The runner is + # administrator, so the silent install/uninstall round-trip runs too. Nothing is published. + - name: Build installer (shared with release.yml and alpha.yml) + uses: ./.github/actions/build-installer diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dfa020e6..22e9397a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,18 +19,23 @@ name: release on: push: branches: [ main ] - # Docs cannot change the installer, so they do not need to republish it. - paths-ignore: - - '**.md' - - 'docs/**' - - '.github/ISSUE_TEMPLATE/**' - # Licence text cannot change the binary, and republishing would hand the current - # release a new asset hash for no reason, resetting what little SmartScreen - # reputation the existing download has earned. + # Docs cannot change the installer, so they do not need to republish it: a republish gives + # the current release a new asset hash for nothing, resetting what little SmartScreen + # reputation the existing download has earned. A `paths` list rather than paths-ignore, + # because only it can take a file back after a broader pattern dropped it (later lines win). + paths: + - '**' + - '!**.md' + - '!docs/**' + - '!.github/ISSUE_TEMPLATE/**' + # Test harness only. It cannot change the shipped binary. + - '!tools/testenv/**' + # Text that ships IN the installer, so a change to it must republish: the licence it + # shows and installs (wind.nsi, LICENSE.txt), the notices it installs, and the release + # notes this workflow publishes with it. - 'LICENSE' - # Test harness only. It cannot change the shipped binary, and a republish would give - # the current release a new asset hash for nothing. - - 'tools/testenv/**' + - 'THIRD-PARTY-NOTICES.md' + - '.github/release-notes.md' workflow_dispatch: permissions: From 249bbfa8507c2c488ca3c0f2e00cbb56e01891fd Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 03:48:05 +0200 Subject: [PATCH 6/7] docs(architecture): installer, deploy and CI changes from the ship review (#498) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/11-build-test-release.md | 30 ++++++++++++++-------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index f91f0f24..298fe5ad 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -87,11 +87,12 @@ headlessly, so the unit tests are the only verification loop that runs everywher the host's messages and records them in `window.__msgs`. When you add a bridge message to `HandleWebMessage`, extend the mocks of the specs that use it in the same change. Details in [09](09-settings-ui.md). -**CI** (`.github/workflows/build.yml`, on pull requests and pushes to `main`) builds the app and -runs the doctest suite, the JS node tests (`npm run test:rules`: keybind rules, config defaults and -global keys) and the Playwright UI suite. Run the Playwright suite locally too when anything under -`ui/` changes. The release and alpha workflows run the same gates before they publish (see Release -automation). +**CI** (`.github/workflows/build.yml`, on pull requests and pushes to `main`) builds the app, then +runs the same `.github/actions/build-installer` the release and alpha workflows publish from: the +doctest suite, the JS node tests (`npm run test:rules`: keybind rules, config defaults and global +keys), the Playwright UI suite and `tools/release.ps1`, so an NSIS compile error or a failing +`installer_check.ps1` breaks the PR, not main. Nothing is published from it. Run the Playwright suite +locally too when anything under `ui/` changes. ## Local UIAccess deploy @@ -100,7 +101,8 @@ elevated windows and the opt-in band-16 overlay. Windows grants it only to a bin locally trusted certificate and run from a secure location, in practice `C:\Program Files\Wind`. Without it, Wind reads `TokenUIAccess` at init and Auto keeps the desktop on Render. -`tools/uiaccess_setup.ps1` (elevated) stops Wind, builds `uiaccess` and `config`, creates or reuses +`tools/uiaccess_setup.ps1` (elevated) stops this session's Wind the way setup does (quit event, +wait, kill only as a fallback; another session's Wind is left alone), builds `uiaccess` and `config`, creates or reuses a self-signed "Wind Dev Test Cert", trusts it, signs all three exes in `build\` (unsigned fresh builds trip a Defender false positive) and copies them with `ui\dist` to Program Files. It does not deploy an ini. It logs to `tools\uiaccess_setup.log`; a good deploy ends with `status=Valid` and `DONE`. @@ -126,11 +128,15 @@ install-location chooser, because UIAccess is granted only in a secure location. 3. **Stop Wind by asking, not killing.** `WIND_QUIT_RUNNING` sets `Local\Wind_QuitRequest` and waits for the process to exit; only a clean exit restores the OS cursor and releases `ClipCursor`. The single-instance mutex frees ~3 ms into teardown, long before the process is gone, so the macro - polls for the process before falling back to `taskkill`. + polls for the process before falling back to `taskkill`. That quit reaches this session only; a + Wind in another user's session would keep the exes locked, so the macro then asks to force-close + it (a silent run does) and stops setup without changes on a No. `tools/installer_check.ps1` checks what a compile cannot: every `File` source exists, every hit-tested rectangle was generated into `over.nsh`, and a silent install/uninstall round-trips -(needs elevation; skipped from a normal shell, runs in CI). +(needs elevation; skipped from a normal shell, runs in CI). It installs into +`%ProgramFiles%\Wind-installer-check` (admin-only, because setup runs a staged script there as admin) +and saves and restores a real install's registry values, signing roots and all-users shortcuts. ## Release automation @@ -141,7 +147,7 @@ mechanical. ```mermaid flowchart TD - P[push to main] --> F{only docs, LICENSE or tools/testenv?} + P[push to main] --> F{only docs or tools/testenv?} F -- yes --> S[skip] F -- no --> V[read version from src/version.h] V --> T[build.bat test] @@ -159,7 +165,8 @@ flowchart TD - Each release carries the installer twice: `Wind-Setup-x64-.exe` and the stable name `Wind-Setup-x64.exe`, so `releases/latest/download/Wind-Setup-x64.exe` always serves the newest. - Release notes come from `.github/release-notes.md`; the workflow fills `__VERSION__`, - `__SHA256__` and `__COMMIT__`. + `__SHA256__` and `__COMMIT__`. The trigger skips `*.md`, `docs/**` and `tools/testenv/**`, but takes + back what ships with the installer: `LICENSE`, `THIRD-PARTY-NOTICES.md` and the release notes. - Tests gate the build: the doctest suite, the JS keybind-rule test and the Playwright UI suite must all pass before anything is published. A concurrency group serializes runs. - The build itself (version read, test gates, `tools/release.ps1`) is the composite action @@ -174,6 +181,9 @@ environment-driven: `WIND_SIGN_THUMBPRINT`, or `WIND_SIGN_PFX` plus `WIND_SIGN_P - Without one (CI today) it packs both variants: the ordinary build and the UIAccess build as `build\WindUA.exe` (the `uiaccess` build's `build\Wind.exe`, renamed). Setup signs `WindUA.exe` on each PC with a per-machine certificate whose private key it deletes at once (`installer/local-sign.ps1`). If that fails, setup keeps the ordinary build, says signing failed and does not start Wind: without UIAccess, Wind refuses to start ("Wind can't start", `src/core/uiaccess_gate.h`). + `WindUA.exe` is never left stale: every `Wind.exe` build deletes it (`build.bat :core_exe`) except + the one `release.ps1` makes from the same tree (`WIND_KEEP_UA=1`), and a certificate run deletes it + even with `-SkipBuild`. - **Never put the self-signed dev certificate in CI**: it is trusted by nobody and looks worse than no signature. From e2fba054455fc46b67eefbb8da2e9b09a87dbde2 Mon Sep 17 00:00:00 2001 From: Max <112043822+Maxaubert@users.noreply.github.com> Date: Fri, 16 Oct 2026 04:05:43 +0200 Subject: [PATCH 7/7] fix(installer): ask about another user's Wind before closing this session's The other-session probe and its Yes/No prompt ran after this session's Wind had already been quit or killed, so a No (or the 3-try give-up) left this user's Wind closed while the message said nothing had changed. The probe, prompt and force-close loop now run first in WIND_QUIT_RUNNING, which both the install and uninstall sections use. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TdjopXZfsUiesYxYLYHEdH --- docs/architecture/11-build-test-release.md | 4 +- installer/app.nsh | 55 ++++++++++++---------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/docs/architecture/11-build-test-release.md b/docs/architecture/11-build-test-release.md index 298fe5ad..72718fd0 100644 --- a/docs/architecture/11-build-test-release.md +++ b/docs/architecture/11-build-test-release.md @@ -129,8 +129,8 @@ install-location chooser, because UIAccess is granted only in a secure location. for the process to exit; only a clean exit restores the OS cursor and releases `ClipCursor`. The single-instance mutex frees ~3 ms into teardown, long before the process is gone, so the macro polls for the process before falling back to `taskkill`. That quit reaches this session only; a - Wind in another user's session would keep the exes locked, so the macro then asks to force-close - it (a silent run does) and stops setup without changes on a No. + Wind in another user's session would keep the exes locked, so the macro first asks to force-close + it (a silent run does), before touching this session's Wind, and stops setup without changes on a No. `tools/installer_check.ps1` checks what a compile cannot: every `File` source exists, every hit-tested rectangle was generated into `over.nsh`, and a silent install/uninstall round-trips diff --git a/installer/app.nsh b/installer/app.nsh index 92d9f5bc..a4ebc1cf 100644 --- a/installer/app.nsh +++ b/installer/app.nsh @@ -44,11 +44,39 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then ; Every Wind object here is session-local (the quit event, the mutex), and an HKLM Run entry ; starts one Wind per logged-on user. So the clean quit and its force-kill fallback below are - ; limited to this session; another user's Wind is handled at the end, by asking first. + ; limited to this session; another user's Wind is handled first, by asking. System::Call 'kernel32::GetCurrentProcessId() i .r0' System::Call 'kernel32::ProcessIdToSessionId(i r0, *i .r1)' StrCpy $WindSession $1 + ; Another signed-in user's Wind runs the same exes from the same folder (the Run entry is + ; HKLM), so it keeps them locked: the copy or delete below would fail and leave a broken + ; install. Its quit event is in that session's namespace, out of reach from here, so the only + ; way to free the files is a forced close, and that is the user's call. A silent run closes it. + ; Asked FIRST, before this session's Wind is touched, so a No (or giving up) really does leave + ; everything as it was. Three tries, then stop: a close that keeps failing must not loop a + ; silent run forever. + StrCpy $3 0 + ${Do} + nsExec::Exec 'cmd /c tasklist /FI "SESSION ne $WindSession" /NH | findstr /I /B "Wind.exe WindTray.exe WindConfig.exe"' + Pop $4 + ${If} $4 != 0 + ${Break} ; nothing running in any other session + ${EndIf} + ${If} $3 >= 3 + MessageBox MB_ICONSTOP|MB_OK "Setup couldn't close Wind for another user signed in to this PC. Ask them to quit Wind, then run Setup again." /SD IDOK + Abort "Wind is still running for another user." + ${EndIf} + IntOp $3 $3 + 1 + MessageBox MB_YESNO|MB_ICONEXCLAMATION "Wind is also running for another user signed in to this PC, and Setup can't update or remove it while it runs.$\r$\n$\r$\nClose it for them now? If you choose No, Setup stops without changing anything." /SD IDYES IDYES windCloseOthers + Abort "Wind is running for another user." + windCloseOthers: + DetailPrint "Closing Wind in other sessions..." + nsExec::Exec 'taskkill /IM WindConfig.exe /IM Wind.exe /IM WindTray.exe /FI "SESSION ne $WindSession" /F' + Pop $0 + Sleep 500 + ${Loop} + ; WindConfig first, so it cannot relaunch Wind while we are stopping it. Force, because ; a WM_CLOSE with unsaved settings opens a confirm dialog that would hang setup ; (src\config_ui\main.cpp:490). It holds no OS state, so there is nothing to lose. @@ -123,31 +151,6 @@ Var LicenceDir ; where "Read the full licence" put its copy, empty until then Pop $0 ${EndIf} - ; Another signed-in user's Wind runs the same exes from the same folder (the Run entry is - ; HKLM), so it keeps them locked: the copy or delete below would fail and leave a broken - ; install. Its quit event is in that session's namespace, out of reach from here, so the only - ; way to free the files is a forced close, and that is the user's call. A silent run closes it. - ; Three tries, then stop: a close that keeps failing must not loop a silent run forever. - StrCpy $3 0 - ${Do} - nsExec::Exec 'cmd /c tasklist /FI "SESSION ne $WindSession" /NH | findstr /I /B "Wind.exe WindTray.exe WindConfig.exe"' - Pop $4 - ${If} $4 != 0 - ${Break} ; nothing running in any other session - ${EndIf} - ${If} $3 >= 3 - MessageBox MB_ICONSTOP|MB_OK "Setup couldn't close Wind for another user signed in to this PC. Ask them to quit Wind, then run Setup again." /SD IDOK - Abort "Wind is still running for another user." - ${EndIf} - IntOp $3 $3 + 1 - MessageBox MB_YESNO|MB_ICONEXCLAMATION "Wind is also running for another user signed in to this PC, and Setup can't update or remove it while it runs.$\r$\n$\r$\nClose it for them now? If you choose No, Setup stops without changing anything." /SD IDYES IDYES windCloseOthers - Abort "Wind is running for another user." - windCloseOthers: - DetailPrint "Closing Wind in other sessions..." - nsExec::Exec 'taskkill /IM WindConfig.exe /IM Wind.exe /IM WindTray.exe /FI "SESSION ne $WindSession" /F' - Pop $0 - Sleep 500 - ${Loop} Sleep 300 !macroend