8000 feat(updater): in-place updates for portable zip installs by 2more-ger · Pull Request #184 · amirlehmam/wmux · GitHub
[go: up one dir, main page]

Skip to content

feat(updater): in-place updates for portable zip installs - #184

Merged
amirlehmam merged 2 commits into
amirlehmam:masterfrom
2more-ger:feat/portable-zip-updater
Aug 21, 2026
Merged

amirlehmam merged 2 commits into
amirlehmam:masterfrom
2more-ger:feat/portable-zip-updater

Conversation

@2more-ger
Copy link
Copy Markdown
Contributor

Summary

In-place updates for portable zip extracts — the README-recommended install. NSIS installs are untouched.

Follow-up to #96 and #125. Those shipped an in-app path through electron-updater's NsisUpdater, which can replace an NSIS install. A zip extract (no Uninstall wmux.exe next to wmux.exe) still cannot: listing the zip in latest.yml is the endless "update ready" loop from #96, and listing setup.exe would install a second copy under %LOCALAPPDATA% while leaving the zip folder on the old version. Clicking the badge then fell back to the GitHub release page (#125).

This PR gives zip extracts their own download → extract → swap path, driven by the same badge click and a new Check for updates button next to the version in Settings → Help.

Zip only

Detection is conservative, so NSIS behaviour does not change:

Layout Marker Updater
NSIS (wmux-*-setup.exe) Uninstall wmux.exe next to wmux.exe existing NsisUpdater (quarantine, latest.yml, quitAndInstall)
Portable zip (wmux-*-win-x64.zip) wmux.exe, no uninstaller this PR: GitHub zip asset, extract, robocopy over the install folder after exit
npm run dev / WMUX_DISABLE_UPDATER=1 unpackaged or kill switch still handled: false → GitHub releases page

initAutoUpdater() returns before registering NsisUpdater on a zip extract, so a portable install can never enter the #96 loop. Zip updates are user-initiated only (badge / Help button). There is no unattended zip download; the 3-day quarantine window continues to apply only to the NSIS path.

Runtime tools (no curl, no extra npm deps)

The download does not use curl.exe or Invoke-WebRequest (the latter's progress callback is what made large zips crawl). Packaged Electron already has Chromium networking.

Step Tool Why it's always there
Download electron.net.request Chromium, inside the packaged app
Extract %SystemRoot%\System32\tar.exe, then Windows PowerShell Expand-Archive tar.exe ships with Windows 10 1803+, which Electron 43 already requires; PowerShell is the fallback
Apply cmd.exe + robocopy / tasklist / timeout / findstr via %SystemRoot%\System32 not looked up on PATH
MOTW Unblock-File best-effort; a missing/constrained PowerShell does not block relaunch

Integrity: GitHub asset.size plus digest (sha256:…) when the API sends it. The payload is rejected if it does not contain wmux.exe.

UI

  • Titlebar badge: same click as today. Zip extracts no longer bounce to GitHub when latest.yml is an NSIS artifact.
  • Settings → Help: button beside the version — Check for updates / Update to vX / download percent / Restart.

Tests

  • tests/unit/zip-updater.test.ts — portable vs NSIS detection, win-x64 asset pick, payload root, helper script.
  • tests/unit/updater.test.ts — zip extracts do not call NsisUpdater; already-current zip reports no_update; NSIS path unchanged.

npx vitest run tests/unit/updater.test.ts tests/unit/zip-updater.test.ts — 36 passed. npm run typecheck clean.

8000

NSIS-based electron-updater cannot replace a zip extract (issue amirlehmam#96):
latest.yml pointing at a zip loops, and setup.exe would install a second
copy. The README-recommended install is the zip, so the titlebar badge
and a new Help-panel button now download the win-x64 zip, extract it,
and swap over the install folder after wmux exits.

The download uses Electron net.request (no Invoke-WebRequest progress
tax). NSIS installs keep the existing electron-updater path.
Download already uses Electron net.request, not curl. Point extract/apply
at %SystemRoot%\System32 so a missing or hijacked PATH cannot break a
portable zip update, and treat Unblock-File as best-effort.
@amirlehmam
amirlehmam merged commit 5b1341c into amirlehmam:master Aug 21, 2026
@amirlehmam
Copy link
Copy Markdown
Owner

Merged, and shipping in v1.7.0 (release build running now).

Thanks — this is the piece #96 and #125 were missing. Those two got the badge right but left the README install, the one the docs actually tell people to download, as the one layout that couldn't self-update. Routing by install layout instead of trying to make NsisUpdater swallow a zip is the right call.

What I verified before merging

  • The detection marker. isPortableZipInstall() is the whole contract here — misfire it and an NSIS install gets a zip payload robocopied over it with a stale uninstaller and stale registry entries. I traced Uninstall wmux.exe back through the electron-builder we actually ship with rather than the docs: common.nsh defines UNINSTALL_FILENAME "Uninstall ${PRODUCT_FILENAME}.exe", PRODUCT_FILENAME is appInfo.productFilename, which falls back to the sanitized productName since we set no executableName. productName is "wmux", so the constant is exact. I've written that derivation into CLAUDE.md next to the file, because the name silently moves if either field is ever renamed.
  • The artifact side. CI zips release/win-unpacked/*, so wmux.exe lands at the zip root — findPayloadRoot()'s direct case. Name is wmux-${version}-win-x64.zip, which pickZipAsset() matches on the preferred win-x64 branch. Refusing to fall back to a marker-less .zip is a good instinct; that's exactly where a source archive would have slipped in.
  • 1252 tests green, tsc --noEmit clean, and ESLint identical to the master baseline (29 problems both before and after — nothing new).

One thing I changed on merge

c35884c — the helper's robocopy failure branch.

'if %ERRORLEVEL% GEQ 8 exit /b 1',

applyStagedPortableUpdate() calls app.quit() the moment the helper is detached, so by the time robocopy runs wmux is already gone. On >= 8 the helper exited and nothing came back — the user clicked "Install and restart" and their multiplexer just disappeared, with no window and no error to read. That's reachable without anything exotic: a portable folder somewhere non-writable, or a leftover child still holding a DLL past /R:5 /W:1.

The failure branch now falls through to the same relaunch as the happy path, skipping only the MOTW strip:

'if %ERRORLEVEL% GEQ 8 goto relaunch',
'if exist "%SYS%\WindowsPowerShell\v1.0\powershell.exe" ... Unblock-File ...',
':relaunch',
'start "" "%EXE%"',

A failed copy usually leaves the old build in place, so %EXE% still starts — on the old version, which beats not starting. Test added in zip-updater.test.ts.

Same commit flattens downloadToFile()'s stream callbacks and runHidden()'s error string; our sonarjs gate rejects nesting past 4 levels and nested template literals, and the new file tripped both. Pure refactor, behaviour identical — the size/sha256 checks moved into a verifyDownload() helper.

Follow-ups, not blockers

  • i18n. The 4 new settings.help.* keys landed in en and de only. translate() chains through English, so the other 16 locales render readable English rather than raw keys — degrades fine, but they're still untranslated. I deliberately di A296 dn't machine-translate them into 16 languages on your behalf; happy to take a follow-up PR, or they'll get picked up the next time someone sweeps the locales.
  • robocopy has no /PURGE. Files dropped in a newer version linger in the install root instead of being removed. Almost certainly the right default — /PURGE over a folder a user may have put things in is how you delete someone's data — but worth a comment so the next reader knows it's a choice and not an oversight.
  • Backpressure in downloadToFile(). out.write(chunk)'s return value is ignored, so a slow disk buffers the whole ~100MB in memory. Disk normally outruns network here, so it's theoretical; a res.pause()/out.once('drain') pair would close it if it ever shows up.

One note for anyone reading this later: nobody on <= 1.6.0 gets the new path, since the code doing the swap ships in the update. Zip extracts have to take one more manual trip to the releases page to reach 1.7.0, and self-update from there on.

amirlehmam added a commit that referenced this pull request Aug 21, 2026
…n i18n

TRACE rows inherited classic's vertical rhythm, which was sized for a title
plus one status line. TRACE routinely stacks four or five lines, so a
three-session workspace was a ~114px block and a handful of them filled the
sidebar. Compacts the leading only — no glyph, dot or label is hidden or
truncated, and type stays at 10px/9.5px. 114px -> 89px per row (-22%).

The 13px session line is the floor: below that the 5px status dot and its
1px via stop sitting on the optical centre of the line box.

Also completes settings.help.checkUpdate / updateTo / upToDate / downloading
across the 16 locales PR #184 left on the English fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0156K5nxrn7jMBNq8LSNY2Pa
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.

2 participants

0