mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-21 05:23:19 +00:00
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:
parent
2bd44113c7
commit
d59802c1ec
6 changed files with 242 additions and 4 deletions
1
app/.gitignore
vendored
1
app/.gitignore
vendored
|
|
@ -2,5 +2,6 @@ node_modules/
|
|||
dist/
|
||||
release/
|
||||
build/cli/
|
||||
build/menubar/
|
||||
*.log
|
||||
.vite/
|
||||
|
|
|
|||
|
|
@ -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/`:
|
||||
|
|
|
|||
9
app/build/appx-extensions.xml
Normal file
9
app/build/appx-extensions.xml
Normal 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
15
app/build/installer.nsh
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
113
app/scripts/stage-menubar.mjs
Normal file
113
app/scripts/stage-menubar.mjs
Normal 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}`)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue