Skip to content

Windows portable build - #275

Merged
fylorn merged 40 commits into
devfrom
feat/windows-portable
Oct 3, 2026
Merged

fylorn merged 40 commits into
devfrom
feat/windows-portable

Conversation

@fylorn

@fylorn fylorn commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

What

A portable Windows build next to the installer: one zip per architecture (ThinkWatch-Lite-<v>-windows-<x64|arm64>-portable.zip, containing ThinkWatch Lite.exe and twcore.exe). Extract it and run; no administrator rights needed.

Portable build

  • Detected as an official release build (TW_OFFICIAL_BUILD) with no uninstall.exe beside it. A developer's own release build counts as a dev build.
  • Data lives in data\ next to the exe, including the WebView2 profile, separate from the installed copy (%APPDATA%\ThinkWatch). An unwritable folder, or a data\ folder owned by another Windows account, gets an accurate native dialog. A THINKWATCH_HOME inherited from another copy is dropped (THINKWATCH_PORTABLE_EXE marker).
  • twcore.exe is taken only from the exe's own folder.
  • Self-update without elevation:
    1. download and verify;
    2. check the zip holds exactly the two files;
    3. wait for in-flight requests, then stop core;
    4. rename each running exe aside and write the new one;
    5. roll back on any failure, then restart.
      CI exercises the rename on NTFS, FAT32, exFAT and an SMB share.

One running instance, whoever runs owns the system hooks

  • thinkwatch://, the launch-at-login entry and the toast AUMID registration (HKCU) point at the running exe. They are claimed on every start, and cleanup removes only entries pointing at this exe.
  • Toasts no longer depend on a Start menu shortcut. HKCU\Software\Classes\AppUserModelId\<AUMID> carries the display name and icon: app.thinkwatch.lite for the installed copy, .portable for the portable one.
  • Opening a copy from another location shows a TaskDialog with a choice: stop the running program and start this one, or cancel. The running side waits for in-flight requests (at most 190 s), then stops core and quits. Links, toast clicks and autostart still forward silently. A running instance with higher rights is reported instead of starting a second one.
  • New app.manifest: PerMonitorV2 DPI awareness and Common-Controls v6.

Uninstall

  • New --uninstall-cleanup [--delete-data] headless mode, shared with the in-app uninstall. It restores the clients this copy took over (WSL included), removes HKCU entries pointing at this exe, and deletes the data dir only when asked and every client was restored.
  • NSIS hooks run the cleanup before uninstalling (skipped for updates), keep a launch-at-login entry that points elsewhere, and warn when the cleanup did not complete.
  • The "delete app data" checkbox now really deletes our data. Its wording is "Also delete data (configuration, API keys, request history)".
  • Clients taken over by another ThinkWatch Lite are labelled as such and cannot be restored from this copy (adopt.other_instance).

Other

  • A missing WebView2 runtime gets a native dialog with a download link.
  • Release assets are renamed to ThinkWatch-Lite-<v>-<darwin|windows|linux>-<arch>…. latest.json keeps every existing key and adds windows-<arch>-portable; installed apps keep updating as before.
  • New windows-e2e job in release.yml, which publish depends on. It runs 43 checks against the real installer and zip:
    • portable start, data location and registrations;
    • switching, cancelling and the same exe twice;
    • an unwritable folder;
    • the installed copy;
    • installing or uninstalling the installed copy while a portable copy runs;
    • uninstall cleanup in both modes.
  • Cost:
    • CI cancels superseded runs on branches and PRs; main and dev are never cancelled.
    • Release rehearsals cancel superseded runs.
    • rehearse/windows/ builds only Windows.
  • Tests remove their temporary directories, and two supervisor timing tests no longer flake (macOS pauses the first exec of a freshly written file).

Verification

  • Local: fmt, clippy, full cargo test, pnpm typecheck, pnpm test (747), scripts/release_notes_test.py.
  • Full release rehearsal with the end-to-end job, all green: https://github.com/ThinkWatchProject/ThinkWatch-Lite/actions/runs/37137679640
  • Not verifiable before release: a portable copy updating itself from an older portable release (the first portable release has nothing older to update from).

Release coordination

Do not merge homebrew-tap asset-names or the website's lite-portable branch before the release is published. The tap needs a rebase + scripts/bump.sh on that branch, then a push to main.

🤖 Generated with Claude Code

fylorn and others added 30 commits October 3, 2026 21:56
Integration branch for the Windows portable build. Each module is a
no-op placeholder that one work path fills in:

- portable: data next to the exe, write check (path A)
- dialog: native dialogs before Tauri starts (path B; MessageBoxW stub)
- single: one running instance, prompt and switch (path B)
- winreg: HKCU protocol, notification registration, autostart (path B)
- cleanup: --uninstall-cleanup for the system uninstaller (path C)
- webview2: refuse to start without the WebView2 runtime (path C)

run() calls them in order before the builder; setup() claims the HKCU
entries before the notification sink is chosen and starts the switch
listener once AppState exists.

TEMP: ci.yml also runs on pushes to this branch and portable/**; revert
before merging into dev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…set names

The portable copy is a release build on Windows with no uninstall.exe
beside it (update::windows_kind). It keeps its data in <exe dir>\data,
checked for write access at startup (a native dialog and exit when the
folder is not writable), runs only the twcore.exe next to it, and gives
WebView2 its own data\webview directory at all three window sites.

Self-update asks the updater for the windows-<arch>-portable key,
checks that the zip holds exactly thinkwatch-lite.exe and twcore.exe at
its root, waits for requests in flight, stops core, renames both exes
to <name>.old, writes the new ones and restarts. Any failure renames
them back and brings the gateway back up; the .old files are removed
on the next start. The rename/write/rollback step does not need an
AppHandle and is tested on every platform, plus on Windows against two
running executables.

Release assets are renamed to ThinkWatch-Lite-<v>-<os>-<arch>... with
darwin/windows/linux, and each Windows job also publishes a portable
zip (sha256 + signature) built from the exes inside the installer and
checked for shape, PE architecture, versions and Visual C++ runtime
imports. latest.json keeps every existing key and adds
windows-x86_64-portable and windows-aarch64-portable; manifest.py now
pins which file suffix each key may point to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dialog: TaskDialogIndirect with our own button labels, a main
instruction and content, loaded from comctl32 at call time so the
test binaries (no v6 manifest) still start; MessageBoxW as fallback.
dialog::wait shows a marquee dialog that cannot be dismissed while a
closure runs on another thread and closes itself when it returns.

winreg: claim() points thinkwatch:// at the running exe under HKCU,
repoints an existing autostart Run entry to it, and registers the
notification AUMID (DisplayName + IconUri to a png written into the
data dir). release_all() removes only entries that point at the given
exe, plus this copy's AUMID registration and icon. Dev builds never
touch the registry.

Autostart on Windows no longer goes through the plugin: the toggle is
the one shared Run entry (on = present anywhere and not disabled in
Settings > Apps > Startup), enabling writes a quoted command for this
exe, disabling deletes it.

Notifications: AUMID app.thinkwatch.lite (installed) or
app.thinkwatch.lite.portable; availability follows the registration
instead of the Start menu shortcut.

single: precheck() asks when the running instance is a different exe
(pure verdict() decides, tested everywhere), signals a named event the
running side creates in listen(), shows the waiting dialog until the
plugin's mutex is released (abandoned counts), and releases and closes
its own handle before Tauri starts. The running side waits for
in-flight requests (up to 3 minutes), marks the exit as user-initiated,
stops core and exits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ebView2 check

- cleanup: `--uninstall-cleanup [--delete-data]` runs before Tauri. It
  restores this copy's clients (WSL too), calls winreg::release_all for the
  running exe, waits for this copy's twcore.exe to exit, deletes the data
  directory only when asked and every client came back (retrying while the
  core lets go), and exits 0/1.
- uninstall: the in-app uninstall and the cleanup share the steps
  (restore_everywhere, restore_steps, drop_data, last_line). On Windows the
  in-app uninstall releases the HKCU entries through winreg::release_all;
  the portable build ends with "the whole folder can now be deleted" and
  keeps its in-use data\webview when deleting data.
- NSIS: PREUNINSTALL hook (skipped on /UPDATE) closes the running app and
  runs the cleanup, with --delete-data when the checkbox is ticked. English
  and SimpChinese language files copied from Tauri CLI 2.11.4 with the
  checkbox reworded.
- Clients taken over by another ThinkWatch Lite (the full backup named in
  the sidecar is not in this copy's backups dir) are marked as such: no
  restore or adopt here, restore-all skips and lists them, key sync and
  retargeting leave them alone, and the backend refuses to touch them
  (adopt.other_instance).
- webview2: refuse to start without the WebView2 runtime, offering
  Microsoft's download page.
- Settings: the uninstall section says the system uninstaller now does the
  same steps (installed) or that the folder can be deleted (portable).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able/c-uninstall

# Conflicts:
#	src-tauri/Cargo.toml
Tauri's uninstall section deletes the HKCU Run value named after the
product whatever it points at. There is one such entry for both the
installed and the portable copy, so uninstalling the installed copy would
also turn off launch at login for a portable copy. The pre-uninstall hook
now remembers the value when it does not point into $INSTDIR (with or
without quotes, case-insensitive) and the post-uninstall hook writes it
back. StartupApproved is not touched by the template and stays as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The download table uses the ThinkWatch-Lite-<version>-<os>-<arch> names and
lists the portable zip next to each Windows installer. A portable section
covers data in data\, separate settings from the installed copy, one copy
running at a time and how to remove it; an uninstall section covers the
in-app uninstall, the system uninstaller's checkbox and the case of a
standard account entering an administrator's password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The portable copy keeps data\webview while the app is open. Compare the
folder by its real path when the two spellings differ (a \\?\ prefix, a
symlinked temp dir), and skip it by name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore publishing

A windows-e2e job takes the x64 setup.exe and portable zip from the same
run and uses them on windows-latest with Windows PowerShell 5.1: portable
start and data folder, second copy switch / cancel / same exe, a folder
that is not writable, silent install, and (once uninstall cleanup lands)
silent uninstall plus --uninstall-cleanup. Screenshots go to e2e-shots,
the app's own logs to e2e-logs. publish now needs it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…names ignore case)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…heck, docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… dialog's UI tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The pre-uninstall hook now keeps the cleanup's exit code. When it is not 0,
or the program could not be started at all (ExecWait's error flag), an
interactive uninstall shows a warning that points to the in-app Uninstall
for the remaining steps, then carries on; a silent uninstall carries on
without it. The sentence is a new LangString in both language files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…og buttons with the mouse

TaskDialog buttons show up in UI Automation as CCPushButton panes without
an Invoke pattern, so they are clicked where they are drawn and the dialog
must close; TDM_CLICK_BUTTON by id stays only as a reported fallback. The
cleanup of the portable copy waits for its WebView2 processes first, as a
user who quit the app would.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… not complete

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- dialog: the MessageBoxW fallback asks Yes/No with the first button's
  label as the question, so "OK" no longer silently means the first
  choice.
- build.rs: our own app manifest keeps the Common-Controls v6
  dependency and declares PerMonitorV2 DPI awareness. tao sets it at
  runtime, which Windows requires before any UI exists; the switch
  dialogs run earlier. The per-thread DPI switch in dialog is gone.
  A Windows test builds an activation context from the manifest.
- winreg: IconUri is written even when the icon file cannot be, so
  release_all still recognises the registration as this copy's.
- portable: an unknown exe folder refuses to start instead of falling
  back to the installed copy's data dir; a data folder created by
  another Windows account gets its own message (folder writable, data\
  listed but denied); THINKWATCH_HOME is set together with a
  THINKWATCH_PORTABLE_EXE marker and both are dropped at startup when
  the marker names another exe, so programs launched from a portable
  copy do not hand its data dir to the installed one.
- single: a stale signalled handoff event is reset when recreated; an
  unreachable mutex or event (running instance elevated) is reported
  and this process exits instead of starting a second instance; when
  the event cannot be opened, a mutex already released means the other
  copy quit, so start normally; stepping aside has a 190 s overall
  deadline, and wait_for_quiet times out each status call after 5 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bool::then_some evaluated Self(h) eagerly, so a failed Open* call
dropped a Handle(null) whose CloseHandle(NULL) replaced the last error
with ERROR_INVALID_HANDLE before open_mutex read it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up dev (core v0.60.0) and path C's uninstall notice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI (Windows) now makes a FAT32 and an exFAT VHD with diskpart and a
local SMB share, and runs the portable tests (including the one that
renames running executables) on each. TW_PORTABLE_TEST_DIR picks the
folder the tests use; without it they use the system temp dir as before.

Supervisor tests: macOS holds back the first run of a freshly written
executable (measured up to 49 s on a busy machine; the second run of the
same file takes under a millisecond). The fake cores were written and
started inside the tests' 5-second windows, so under load the two tests
that count starts timed out. Each fake core now runs once with `warm`
before the clock starts, liveness waits use a generous shared bound, and
"returns at once" is measured on a paused clock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A release build with no uninstall.exe beside it was treated as the portable
copy, so a developer's own `cargo build --release` wrote its data into
target\release\data and pointed the HKCU link and autostart at target\.
The Windows build step in release.yml now sets TW_OFFICIAL_BUILD=1, read
with option_env! (build.rs reruns when it changes); windows_kind needs it
for Portable, and on Windows the registry claims follow the same answer.
Installed copies are unchanged: uninstall.exe beside the exe still decides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
fylorn and others added 10 commits October 4, 2026 00:18
The NSIS template's CheckIfAppIsRunning finds and ends processes by file
name (thinkwatch-lite.exe; for a per-machine install, any user's), so
silently installing or uninstalling the installed copy also closed a
running portable copy. The zip now holds the same exe under the product
name; twcore.exe keeps its name. The self-update expects the new name
and still writes over the running exe's own path. Release notes and both
READMEs name the file; winreg tests cover a file name with a space.

The e2e script runs the portable copy under the new name and adds a
check: with the installed copy installed and a portable copy running, a
silent install and a silent uninstall leave the portable process and its
gateway running and its link untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…supervisor tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new push to a branch or PR makes the run still in progress for the
previous push worthless, yet it keeps holding concurrent job slots (the
free plan allows 20 at a time, 5 of them macOS). main and dev keep a
result for every commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e old rehearsal

Branches under rehearse/windows/ skip the macOS and Linux builds and run
the two Windows builds, windows-e2e and the release page text, for work
that only touches Windows. Runs are grouped by ref and a newer push to
the same branch cancels the older rehearsal; tag runs are never cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
About 25 test helpers made directories (and a few files) under the
system temp dir with fixed names and either never removed them or
removed them only at the end of a passing run. On a busy Mac that left
thousands of tw-* entries behind. Each now uses a tempfile::TempDir
held for the test's lifetime, with a recognisable tw-<name>- prefix.

Where the directory has to outlive something else, it is declared
first: the supervisor's fake cores (the supervisor runs them from
there), the D-Bus notifier tasks (they write into it), and the control
plane test's core (killed and waited for in Drop before the field goes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts dir

When an assertion fails part-way, the notifier tasks are still running
and one may be saving the table; removing the temporary directory first
let that write create it again. A guard now aborts the tasks, waits for
them to finish, then removes the directory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…r, portable exe name

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fylorn
fylorn merged commit 1fbc381 into dev Oct 3, 2026
4 checks passed
@fylorn
fylorn deleted the feat/windows-portable branch October 3, 2026 17:15
@fylorn fylorn mentioned this pull request Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant