feat(app): ship the tray app with the Windows desktop builds

stage-menubar.mjs is stage-cli.mjs for the Tauri tray app: it copies rather than
downloads, taking the local `cargo tauri build` output by default and whatever
CI fetched with --from. The two routes need different things staged, so it has
two shapes. The NSIS build gets the .msi and its .sha256, computing the digest
when a local build has none, so the installer's verification means the same
thing there as it does for a release. The Store build gets the executable, since
a packaged app cannot run msiexec at all.

build/menubar rides into both packages as win.extraResources. Unlike the CLI it
carries no node_modules, so electron-builder's production-dependency filter has
nothing to strip and the afterPack hook is not needed.

installer.nsh removes the tray app on uninstall only when the marker credits
this app with installing it, which is what keeps a manual `codeburn menubar`
install alive through a desktop uninstall. PowerShell reads the marker and pulls
the product code out of the uninstall string, because NSIS has no JSON and the
alternative was hand-parsing one in a macro.

appx-extensions.xml declares the tray app's launch at login as the package's own
windows.startupTask, so it appears in Settings > Apps > Startup like anything
else the Store installed. No Run value is written on that route.
This commit is contained in:
iamtoruk 2026-09-02 04:48:03 -07:00
parent 2bd44113c7
commit d59802c1ec
6 changed files with 242 additions and 4 deletions

1
app/.gitignore vendored
View file

@ -2,5 +2,6 @@ node_modules/
dist/
release/
build/cli/
build/menubar/
*.log
.vite/

View file

@ -52,6 +52,84 @@ bundled CLI's dependency tree gets stripped out of an `extraResources` copy
in verbatim, which is the electron-builder-recommended mechanism for adding
unpacked files that must not be ASAR-archived.
## The bundled tray app and Capacity Dock (Windows only)
The Windows desktop app also carries the Tauri tray app from `windows/`, so
installing the desktop app gives the user the tray icon and the Capacity Dock
without their having to know that `codeburn menubar` exists. Two switches in the
sidebar's bottom-left corner turn each surface off ("Menu bar" is the tray
process, "Sidebar" is the dock rail); both default on, and neither appears on
macOS or Linux, or in a build with nothing staged.
`scripts/stage-menubar.mjs` puts the tray app into `app/build/menubar`, which
`build.win.extraResources` ships as `resources/menubar`. It copies rather than
downloads, exactly like `stage-cli.mjs`: with no `--from` it takes the local
`windows/src-tauri/target/release` output, and CI downloads the `windows-v*`
release assets in its own step and passes `--from <dir>`.
The two routes need different things staged, which is why there are two scripts:
- `npm run stage-menubar` (run by `package:win`) stages
`CodeBurn.Menubar_<version>_x64_en-US.msi` and its `.sha256`. The digest is
copied from the release when it is there and computed when it is not, so a
local `cargo tauri build` stages the same shape a release does.
- `npm run stage-menubar:store` (run by `package:store`) stages
`codeburn-menubar.exe` itself, plus any sidecar DLL beside it.
### The NSIS route: install through the CLI, never downgrade
`app/electron/menubar.ts` runs at every launch (which is also what covers a
desktop-app update, since the staged MSI travels with the app) and hands the
staged file to the CLI's own Windows installer by setting `CODEBURN_MENUBAR_MSI`
on a `codeburn menubar` spawn. The install itself is not reimplemented on the
Electron side: `src/menubar-installer.ts` owns the uninstall-registry read, the
checksum verification, the `msiexec /i ... /passive /norestart` call and the
rules that go with a bundled copy. It prints one `CODEBURN_MENUBAR_RESULT <json>`
line that the desktop app reads.
Two of those rules matter here:
- **Deduplication.** The MSI carries a fixed WiX upgrade code
(`windows/src-tauri/tauri.conf.json`, `bundle.windows.wix.upgradeCode`), which
is the value Tauri already derived from the product name, so installing the
bundled copy over a manual `codeburn menubar` install is an in-place upgrade
rather than a second entry in Programs and Features.
- **Never downgrade.** If the installed `DisplayVersion` is newer than the
bundled one, nothing is installed and the existing app is left alone.
**The marker.** The installer writes
`%LOCALAPPDATA%\codeburn-menubar\installed-by.json`, crediting `desktop` only
when it installed onto a machine that had no tray app, and `manual` when it
upgraded one that was already there. An existing marker's verdict is never
rewritten. `build/installer.nsh` (wired as `nsis.include`) reads it in the
uninstaller and removes the tray app only when the marker says `desktop`, so a
tray app the user installed by hand survives an uninstall of the desktop app.
Launch at login on this route is a single `HKCU\...\CurrentVersion\Run` value
named `CodeBurn`, the same value the tray app's own settings toggle writes
(`windows/src-tauri/src/autostart.rs`), so the two can never leave two entries.
### The Store route: no msiexec
A packaged app cannot run `msiexec` and would be fighting the package manager if
it could, so the AppX build ships `codeburn-menubar.exe` inside the package and
launches it from `app\resources\menubar\`. Launch at login is a
`windows.startupTask` extension (`build/appx-extensions.xml`, wired as
`appx.customExtensionsPath`), which also lets the user turn it off in
**Settings > Apps > Startup**; no Run value is written. The tray app detects the
package with `GetCurrentPackageFullName` and skips its own update checker
entirely, since Store updates cover it.
### Talking to a running tray app
The tray app has no window of its own, so its argv is the control channel: the
Tauri single-instance plugin hands a running instance whatever a second launch
was started with (`windows/src-tauri/src/lib.rs`). `--quit` exits and
`--reload-settings` re-reads the preference files. Turning "Sidebar" off writes
`enabled: false` into `~/.config/codeburn/windows-dock.json` and then sends
`--reload-settings`, so a running rail disappears at once rather than at the next
launch.
## Versioning
`app/package.json`'s `version` tracks the CLI's version (root
@ -176,6 +254,11 @@ Config (`build.win` + `build.nsis`):
- `win.target: nsis`, `arch: x64`.
- `win.icon: build/icon.png` — electron-builder converts the 1024x1024 PNG to
a multi-resolution `.ico` at build time (same source PNG as the mac icon).
- `win.extraResources: build/menubar -> menubar`: the staged tray app (see
"The bundled tray app and Capacity Dock" above). No `node_modules` is
involved, so unlike the CLI this one can use `extraResources`.
- `nsis.include: build/installer.nsh`: the uninstall macro that removes the
tray app only when the marker says the desktop app installed it.
- `nsis.oneClick: false` — an assisted installer with a wizard, so users get
an **install-directory choice** instead of a silent one-click install.
- `nsis.perMachine: false` — installs per-user (into the user's `AppData`),
@ -209,6 +292,10 @@ certificate. The AppX declares `runFullTrust` (electron-builder's required
default for Electron apps), so CodeBurn retains access to the user's local
provider session files rather than running in a UWP application sandbox.
The tray app ships inside this package too (`app\resources\menubar\`), with a
`windows.startupTask` extension for launch at login. `msiexec` is never run on
this route. See "The Store route" above.
### Linux (`package:linux`)
`electron-builder --linux` produces a single artifact in `app/release/`:

View file

@ -0,0 +1,9 @@
<!-- Launch at login for the tray app the Store package carries. The NSIS route writes an
HKCU Run value instead (app/electron/menubar.ts); a packaged app declares it here so the
person can turn it off in Settings > Apps > Startup like any other packaged app.
electron-builder splices this file inside the manifest's <Extensions> element, and the
desktop namespace is already declared by its template. The path is where extraResources
land inside the package: the app itself is app\CodeBurn.exe. -->
<desktop:Extension Category="windows.startupTask" Executable="app\resources\menubar\codeburn-menubar.exe" EntryPoint="Windows.FullTrustApplication">
<desktop:StartupTask TaskId="CodeBurnMenubar" Enabled="true" DisplayName="CodeBurn Menubar" />
</desktop:Extension>

15
app/build/installer.nsh Normal file
View file

@ -0,0 +1,15 @@
; Uninstalling CodeBurn Desktop removes the tray app only when this app is what
; put it there. src/menubar-installer.ts writes the marker at install time and
; credits whoever got there first, so a tray app someone installed by hand with
; `codeburn menubar` survives, and the one the desktop app installed goes with it.
;
; PowerShell does the reading: the marker is JSON, and the product code lives
; inside the uninstall string Windows Installer wrote. `$$` is a literal dollar
; for NSIS, so every `$$` below is a PowerShell variable, and every string inside
; the command is single-quoted so nothing needs escaping through two parsers.
!macro customUnInstall
DetailPrint "Checking whether CodeBurn installed the tray app..."
nsExec::ExecToLog '"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$marker = Join-Path $$env:LOCALAPPDATA (Join-Path ''codeburn-menubar'' ''installed-by.json''); if (Test-Path $$marker) { $$record = Get-Content -Raw $$marker | ConvertFrom-Json; if ($$record.installedBy -eq ''desktop'') { Get-Process -Name ''codeburn-menubar'' -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue; if ($$record.uninstallString -match ''\{[0-9A-Fa-f-]{36}\}'') { Start-Process -Wait -FilePath ''msiexec.exe'' -ArgumentList ''/x'', $$Matches[0], ''/passive'', ''/norestart'' }; Remove-Item -Force -ErrorAction SilentlyContinue $$marker } }"'
Pop $0
!macroend

View file

@ -12,11 +12,13 @@
"test": "vitest run",
"typecheck": "tsc --noEmit -p tsconfig.json",
"stage-cli": "npm --prefix .. run build:cli && node scripts/stage-cli.mjs",
"stage-menubar": "node scripts/stage-menubar.mjs --target nsis",
"stage-menubar:store": "node scripts/stage-menubar.mjs --target appx",
"package": "npm run stage-cli && npm run build && electron-builder --mac",
"package:arm64": "npm run stage-cli && npm run build && electron-builder --mac --arm64",
"package:x64": "npm run stage-cli && npm run build && electron-builder --mac --x64",
"package:win": "npm run stage-cli && npm run build && electron-builder --win",
"package:store": "npm run stage-cli && npm run build && electron-builder --win appx --x64",
"package:win": "npm run stage-cli && npm run stage-menubar && npm run build && electron-builder --win",
"package:store": "npm run stage-cli && npm run stage-menubar:store && npm run build && electron-builder --win appx --x64",
"package:linux": "npm run stage-cli && npm run build && electron-builder --linux",
"package:snap": "npm run stage-cli && npm run build && electron-builder --linux snap --x64"
},
@ -96,14 +98,25 @@
]
}
],
"icon": "build/icon.png"
"icon": "build/icon.png",
"extraResources": [
{
"from": "build/menubar",
"to": "menubar",
"filter": [
"**/*"
]
}
]
},
"nsis": {
"oneClick": false,
"perMachine": false,
"artifactName": "CodeBurn-Setup-${version}.${ext}"
"artifactName": "CodeBurn-Setup-${version}.${ext}",
"include": "build/installer.nsh"
},
"appx": {
"customExtensionsPath": "build/appx-extensions.xml",
"applicationId": "CodeBurn",
"identityName": "Codeburn.CodeBurn",
"publisher": "CN=3EFA3336-87E1-46F2-9DFA-2EB5A7693F89",

View file

@ -0,0 +1,113 @@
// Stage the Windows tray app into app/build/menubar so electron-builder ships it
// as extraResources. The counterpart of stage-cli.mjs, and it copies rather than
// downloads for the same reason: CI fetches the release assets in its own step
// and passes them with --from, and a developer points at a local build.
//
// Two layouts, because the two routes need different things:
//
// --target nsis (default)
// build/menubar/CodeBurn.Menubar_<version>_x64_en-US.msi
// build/menubar/CodeBurn.Menubar_<version>_x64_en-US.msi.sha256
//
// app/electron/menubar.ts hands that path to `codeburn menubar`, whose
// Windows installer verifies the digest before msiexec sees the file. The
// digest is copied when the source has one and computed when it does not, so
// a local `cargo tauri build` stages the same shape a release does.
//
// --target appx
// build/menubar/codeburn-menubar.exe (plus anything beside it the app needs)
//
// A packaged app cannot run msiexec, so the Store build launches the exe out
// of its own package and the manifest's startup task covers launch at login.
//
// Sources, in order: --from <dir>, then the local Tauri build output.
import { createHash } from 'node:crypto'
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url)) // app/scripts
const appDir = join(here, '..')
const root = join(appDir, '..')
const stage = join(appDir, 'build', 'menubar')
const MSI_PATTERN = /^CodeBurn\.Menubar_(.+)_x64_en-US\.msi$/
const TRAY_EXE = 'codeburn-menubar.exe'
/// What the Tauri build puts beside the exe and the app needs at runtime. Anything absent is
/// simply not copied: WebView2Loader ships only when the loader is not linked statically.
const TRAY_SIDECARS = ['WebView2Loader.dll']
function option(name, fallback) {
const index = process.argv.indexOf(name)
if (index === -1) return fallback
const value = process.argv[index + 1]
if (!value) throw new Error(`${name} requires a value`)
return value
}
const target = option('--target', 'nsis')
if (target !== 'nsis' && target !== 'appx') {
throw new Error(`stage-menubar: --target must be nsis or appx, not ${target}`)
}
const defaultSource = target === 'nsis'
? join(root, 'windows', 'src-tauri', 'target', 'release', 'bundle', 'msi')
: join(root, 'windows', 'src-tauri', 'target', 'release')
const source = option('--from', defaultSource)
function fail(message) {
throw new Error(
`stage-menubar: ${message}\n` +
` looked in ${source}\n` +
' build it with `cargo tauri build` in windows/, or pass --from <dir> with the\n' +
' downloaded windows-v* release assets.'
)
}
// The stage is rebuilt from scratch every time: a leftover .msi from an older version would
// otherwise ship beside the current one and be found first.
rmSync(stage, { recursive: true, force: true })
mkdirSync(stage, { recursive: true })
if (!existsSync(source) || !statSync(source).isDirectory()) fail('no source directory')
if (target === 'nsis') {
const msiName = readdirSync(source).find(name => MSI_PATTERN.test(name))
if (!msiName) fail('no CodeBurn.Menubar_<version>_x64_en-US.msi')
const msiSource = join(source, msiName)
copyFileSync(msiSource, join(stage, msiName))
// The release carries its own .sha256; a local build does not, and computing it here keeps
// the installer's verification meaningful in both cases rather than skipped in one.
const checksumSource = `${msiSource}.sha256`
if (existsSync(checksumSource)) {
copyFileSync(checksumSource, join(stage, `${msiName}.sha256`))
} else {
const digest = createHash('sha256').update(readFileSync(msiSource)).digest('hex')
writeFileSync(join(stage, `${msiName}.sha256`), `${digest} ${msiName}\n`)
}
const version = MSI_PATTERN.exec(msiName)[1]
const appVersion = JSON.parse(readFileSync(join(appDir, 'package.json'), 'utf8')).version
// A mismatch is not fatal: a desktop release can legitimately ship the newest tray build
// there is. It is worth saying out loud, because it is usually a stale local bundle.
if (version !== appVersion) {
console.warn(`stage-menubar: staging tray app ${version} into desktop app ${appVersion}`)
}
console.log(`stage-menubar: staged ${msiName} -> ${stage}`)
} else {
const exeSource = join(source, TRAY_EXE)
if (!existsSync(exeSource)) fail(`no ${TRAY_EXE}`)
copyFileSync(exeSource, join(stage, TRAY_EXE))
const copied = [basename(exeSource)]
for (const sidecar of TRAY_SIDECARS) {
const path = join(source, sidecar)
if (!existsSync(path)) continue
copyFileSync(path, join(stage, sidecar))
copied.push(sidecar)
}
console.log(`stage-menubar: staged ${copied.join(', ')} -> ${stage}`)
}