fix(app): typed IPC patches, the staged-msi flag, and honest consent copy

- Tray preference patches from the window were used with `in` before
  any type check; every patch handler now refuses anything that is not
  a plain object.
- The tray install passes `--staged-msi <path>` to the CLI instead of
  an environment variable.
- A timeout or a missing CLI no longer counts as a declined install,
  so it is retried at the next launch; only a cancelled prompt is
  remembered. After msiexec reports a pending restart the old binary is
  not launched, and the sidebar says a restart finishes the install.
- The telemetry opt-in event fired before the toggle was applied and
  could never be sent; it fires after a successful write.
- The ARM64 installer carries the x64-named tray, since the CLI accepts
  only that asset name; the distribution notes say so plainly.
- The README, the onboarding screen and the privacy settings say that
  the daily report includes the names of the models, tools, skills and
  MCP servers in use, and the renderer's fallback builder reports zero
  turns as zero.
This commit is contained in:
iamtoruk 2026-09-03 03:06:13 -07:00
parent d84769edfe
commit ff2ba5c94a
14 changed files with 367 additions and 55 deletions

View file

@ -798,7 +798,8 @@ a clock time. Alongside each batch go the app version, platform, architecture an
**`usage_snapshot`** goes out at most once per calendar day. It is computed by the CLI so the app
and the tray report the identical shape, and every magnitude in it is a bucket, never an exact
figure:
figure. The daily report includes the names of the models, tools, skills and MCP servers you use,
alongside those bucketed counts.
| Field | What it carries |
|-------|-----------------|
@ -807,7 +808,7 @@ figure:
| `models` | Up to 8 model names, each with its cost bucket, turn-count bucket, one-shot rate, and up to 6 task categories with a turn bucket and share of that model's turns |
| `categories` | Up to 12 task category names (Coding, Debugging, Planning, …) with a turn bucket, one-shot rate, and up to 3 model names |
| `providers` | Up to 8 provider names with a cost bucket each |
| `mcpServers`, `skills`, `tools` | Up to 12 names each with a call-count bucket: `1-10`, `10-100`, `100-1k`, `1k+` |
| `mcpServers`, `skills`, `tools` | Up to 12 names each with a call-count bucket: `0`, `1-10`, `10-100`, `100-1k`, `1k+` |
| `sessions` | Session count bucket, and median session length as a bucket: `<5`, `5-15`, `15-60`, `60-240`, `240+` minutes |
| `efficiency` | Cache hit rate and retry tax as shares of the total, to two decimals |
@ -828,9 +829,9 @@ The other events are name-only:
**Never collected:** prompts, code, file contents, file or folder names, project names, branch names,
working directories, session titles, PR links, API keys, exact dollar amounts, exact counts, clock
times, IP-based location beyond the country, or anything that could identify you or your employer.
The snapshot is built from bucket labels and configuration identifiers only, and a whitelist
sanitizer drops anything that is not a short string, a finite number or a boolean before it leaves
the machine.
The names of the models, tools, skills and MCP servers you use are collected, as the table above
sets out, and a whitelist sanitizer drops anything that is not a short string, a finite number or a
boolean before it leaves the machine.
**To turn it off:** decline on the consent screen, or open **Settings > Privacy & data** and switch
**Anonymous telemetry** off.

View file

@ -99,20 +99,39 @@ the version as an identifier for either.
`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.
staged file to the CLI's own Windows installer by spawning
`codeburn menubar --staged-msi <absolute path>`. The path is an argument of the
command rather than an environment variable, so nothing a variable in the
inherited environment happens to name can reach msiexec; the
`CODEBURN_MENUBAR_MSI` variable this replaced is now rejected by the CLI. 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.
The spawn itself is not made at every launch. `msiexec /passive` puts an admin
prompt in front of the user, so `companion.v1.json` records the version of the
tray app that is on disk and the version of any staged MSI whose install was
declined (exit 1602) or failed. A launch skips the probe when the recorded tray
app is the staged version and is still there, and skips it when this exact
staged version was already refused. A newer staged version is always offered,
and so is turning the "Menu bar" switch on by hand, which clears the refusal.
tray app that is on disk and the version of any staged MSI whose install the
user declined at that prompt (exit 1602). A launch skips the probe when the
recorded tray app is the staged version and is still there, and skips it when
this exact staged version was already refused. A newer staged version is always
offered, and so is turning the "Menu bar" switch on by hand, which clears the
refusal.
Only a refusal is remembered. A spawn that timed out, a CLI that was not there
and a run that died before printing its result are all transient, so nothing is
written down and the install is attempted again at the next launch; recording
them would turn one bad launch into a tray app that is never installed.
When msiexec exits 3010 the install is real but unfinished: Windows could not
replace a file that was in use and has deferred it to the next restart, so the
binary on disk is still the previous tray app while the uninstall registry
already reports the new version. The desktop app starts nothing in that state
and the sidebar corner says "Restart Windows to finish installing the menu bar
app." instead. `BundledInstallResult` carries no field for this, so the desktop
side reads the installer's own notice line; the optional `rebootRequired` in
`MenubarInstallResult` is believed first, for when the CLI starts sending it.
Two of those rules matter here:
@ -322,6 +341,29 @@ The x64 build is unaffected because its default filter is one the decoder knows.
per-architecture archives inside a universal installer get the same treatment, so an
installer built with both `--x64` and `--arm64` needs the variable too.
**The ARM64 installer carries the x64 tray app.** `package:win:arm64` runs the same
`npm run stage-menubar` as `package:win`, and there is no ARM64 tray app for it to stage:
- `tauri build` builds for the host, so an ARM64 Windows machine produces
`CodeBurn Menubar_<version>_arm64_en-US.msi`, and `stage-menubar.mjs` matches only
`CodeBurn[ .]Menubar_<version>_x64_en-US.msi`. Staging on an ARM64 host therefore fails
outright with `no CodeBurn.Menubar_<version>_x64_en-US.msi` rather than staging the arm64
bundle. What `package:win:arm64` actually ships is the x64 MSI, from an x64 host build or
from the `windows-v*` release assets passed with `--from`.
- Teaching the staging script the arm64 name would not help on its own. The CLI is what runs
the install, and `src/menubar-installer.ts` accepts only the x64 name: its release-asset
pattern, `parseWindowsMsiVersion` and the staged-install path all require
`_x64_en-US.msi`, and a file named anything else is rejected before msiexec sees it. An
ARM64 tray app would need a matching change there and in the tray's own updater, which is a
larger piece of work than the packaging script.
- What it means in practice: on ARM64 Windows the desktop app installs the x64 tray app, and
Windows runs it under x64 emulation. It works, and it is slower and heavier than a native
build would be. Nothing misreports its architecture, because the tray app's version and the
uninstall-registry entry are both the x64 MSI's own.
`build-windows-installer.yml` builds `package:win` on `windows-latest` only, so no released
artifact is affected: `package:win:arm64` is a local build today.
**Distribution policy.** The Microsoft Store build (Store ID `9P0R4ZL5XMB8`) is
the recommended Windows install. This NSIS setup `.exe` and the tray `.msi`
under the `windows-v*` releases are a developer preview: unsigned, SmartScreen

View file

@ -449,10 +449,12 @@ export function createBridgeHandlers(deps: Deps = { spawnCli, spawnCliAction, re
// The tray app's own settings, which live in the files it reads them from. Every setter
// answers with the whole set, so the panes render what landed rather than what was sent.
'codeburn:trayPrefs': async () => ({ ok: true, value: deps.companion ? await deps.companion.trayPrefs() : null }),
'codeburn:setTrayAppPref': async (patch?: Record<string, unknown>) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayAppPref(patch ?? {}) : null }),
'codeburn:setTrayDockPref': async (patch?: Record<string, unknown>) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayDockPref(patch ?? {}) : null }),
// A patch is whatever came over the channel, so it is typed as that and checked where it
// is read (tray-settings.ts, isPatchObject) rather than asserted into a shape here.
'codeburn:setTrayAppPref': async (patch?: unknown) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayAppPref(patch) : null }),
'codeburn:setTrayDockPref': async (patch?: unknown) =>
({ ok: true, value: deps.companion ? await deps.companion.setTrayDockPref(patch) : null }),
'codeburn:setLaunchAtLogin': async (enabled?: boolean) =>
({ ok: true, value: deps.companion ? await deps.companion.setLaunchAtLogin(Boolean(enabled)) : null }),
// Plugin management reads (all return parsed JSON)

View file

@ -4,9 +4,9 @@ import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
BUNDLED_MSI_ENV,
BUNDLED_RESULT_PREFIX,
MenubarCompanion,
STAGED_MSI_FLAG,
TRAY_CLI_ENV,
TRAY_CLI_PATH_KEY,
DEFAULT_COMPANION_SETTINGS,
@ -272,6 +272,9 @@ describe('MenubarCompanion', () => {
let launchEnvs: Array<NodeJS.ProcessEnv | undefined>
let regCalls: string[][]
let cliCalls: Array<{ args: string[]; env: NodeJS.ProcessEnv | undefined }>
/** Prose the installer prints before its machine-readable line, which is where the
* reboot-pending state is reported: `BundledInstallResult` has no field for it. */
let cliNotice: string
let installResult: MenubarInstallResult | null
let existingRunKey: string | null
let trayRunning: boolean
@ -296,9 +299,10 @@ describe('MenubarCompanion', () => {
exists: (path: string) => present.has(path),
runCli: async (args: string[], opts: { extraEnv?: NodeJS.ProcessEnv }) => {
cliCalls.push({ args, env: opts.extraEnv })
const prose = `Installing...\n${cliNotice}`
return {
ok: true,
stdout: installResult ? `Installing...\n${BUNDLED_RESULT_PREFIX}${JSON.stringify(installResult)}\n` : 'Installing...\n',
stdout: installResult ? `${prose}${BUNDLED_RESULT_PREFIX}${JSON.stringify(installResult)}\n` : prose,
stderr: '',
code: 0,
}
@ -333,6 +337,7 @@ describe('MenubarCompanion', () => {
launchEnvs = []
regCalls = []
cliCalls = []
cliNotice = ''
installResult = result()
existingRunKey = null
trayRunning = false
@ -350,13 +355,16 @@ describe('MenubarCompanion', () => {
expect(new MenubarCompanion(deps({ store: true })).status().supported).toBe(false)
})
it('installs through the CLI, naming the staged file in the environment', async () => {
// The path is an argument rather than an environment variable, so nothing a stray variable
// in the inherited environment names can reach msiexec. The CLI rejects the variable this
// flag replaced (src/menubar-installer.ts).
it('installs through the CLI, naming the staged file on the command line', async () => {
stageMsi()
await new MenubarCompanion(deps()).bootstrap()
expect(cliCalls).toEqual([{
args: ['menubar'],
env: { [BUNDLED_MSI_ENV]: join(resources, 'menubar', MSI_NAME) },
args: ['menubar', STAGED_MSI_FLAG, join(resources, 'menubar', MSI_NAME)],
env: undefined,
}])
expect(regCalls).toEqual([runKeyArgs(true, TRAY_EXE)])
expect(launches).toEqual([{ exe: TRAY_EXE, args: ['--reload-settings'] }])
@ -546,17 +554,20 @@ describe('MenubarCompanion', () => {
expect(launches).toEqual([])
})
it('remembers an install that failed outright the same way', async () => {
// A refusal is a decision; a run that said nothing is not. A spawn that timed out, a CLI
// that was not there and a run that died before printing its result are all things that
// come right on their own, so the next launch tries again rather than never installing.
it('tries again after an install that reported nothing at all', async () => {
stageMsi()
installResult = null
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
await new MenubarCompanion(deps()).bootstrap()
expect(readCompanionSettings(stateDir).installDeclinedVersion).toBe(VERSION)
expect(readCompanionSettings(stateDir).installDeclinedVersion).toBeNull()
cliCalls = []
await new MenubarCompanion(deps()).bootstrap()
expect(cliCalls).toEqual([])
expect(cliCalls).toHaveLength(1)
error.mockRestore()
})
@ -594,6 +605,95 @@ describe('MenubarCompanion', () => {
})
})
// msiexec exit 3010: the install is real but Windows could not replace a file that was in
// use, so the binary at that path is still the previous tray app until the next restart.
// Starting it would put the old one in the notification area under the new version's name.
describe('an install Windows can only finish at the next restart', () => {
const REBOOT_LINE = 'Windows wants a restart to finish the install.\n'
it('does not launch the old binary, and says a restart is needed', async () => {
stageMsi()
cliNotice = REBOOT_LINE
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const companion = new MenubarCompanion(deps())
await companion.bootstrap()
expect(cliCalls).toHaveLength(1)
expect(launches).toEqual([])
expect(companion.status().restartRequired).toBe(true)
log.mockRestore()
})
// The version is not the question; a field on the result is, so a CLI that grows one is
// believed without this side having to keep reading prose.
it('believes a rebootRequired field on the result', async () => {
stageMsi()
installResult = result({ rebootRequired: true })
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const companion = new MenubarCompanion(deps())
await companion.bootstrap()
expect(launches).toEqual([])
expect(companion.status().restartRequired).toBe(true)
log.mockRestore()
})
// The install did happen, so it is not offered again and the switch is not turned off:
// the only thing missing is the restart.
it('records the install and leaves the switch on', async () => {
stageMsi()
cliNotice = REBOOT_LINE
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const status = await new MenubarCompanion(deps()).setMenuBarEnabled(true)
expect(status.menuBar).toBe(true)
expect(readCompanionSettings(stateDir)).toMatchObject({
installDeclinedVersion: null,
trayExePath: TRAY_EXE,
trayExeVersion: VERSION,
})
log.mockRestore()
})
// Nothing was started, so there is nothing to nudge either: a settings write still lands
// in the file, and the tray app reads it when the restart finally starts it.
it('writes tray settings without nudging a tray app that is not running', async () => {
stageMsi()
cliNotice = REBOOT_LINE
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
const companion = new MenubarCompanion(deps())
await companion.bootstrap()
await companion.setTrayAppPref({ accent: 'blue' })
expect(launches).toEqual([])
expect(readTrayFile('app', home).accent).toBe('blue')
log.mockRestore()
})
// A launch after the restart has an ordinary install ahead of it: the recorded version is
// the staged one and the exe is there, so nothing is spawned and the tray app starts.
it('starts the tray app at the launch after the restart', async () => {
stageMsi()
cliNotice = REBOOT_LINE
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
await new MenubarCompanion(deps()).bootstrap()
cliCalls = []
cliNotice = ''
const companion = new MenubarCompanion(deps())
await companion.bootstrap()
expect(cliCalls).toEqual([])
expect(launches).toEqual([{ exe: TRAY_EXE, args: ['--reload-settings'] }])
expect(companion.status().restartRequired).toBe(false)
log.mockRestore()
})
})
// Launch at login on the Store route is the package manifest's own startup task
// (app/build/appx-extensions.xml), which only Windows can turn on and off.
describe('launch at login', () => {
@ -691,7 +791,7 @@ describe('MenubarCompanion', () => {
const status = await companion.setMenuBarEnabled(false)
expect(status).toEqual({ supported: true, menuBar: false, sidebar: false, store: false })
expect(status).toEqual({ supported: true, menuBar: false, sidebar: false, store: false, restartRequired: false })
expect(regCalls).toEqual([runKeyArgs(false, TRAY_EXE)])
expect(launches).toEqual([{ exe: TRAY_EXE, args: ['--quit'] }])
})
@ -736,7 +836,7 @@ describe('MenubarCompanion', () => {
const status = await companion.setMenuBarEnabled(false)
expect(status).toEqual({ supported: true, menuBar: false, sidebar: false, store: false })
expect(status).toEqual({ supported: true, menuBar: false, sidebar: false, store: false, restartRequired: false })
// Written before --quit, so what the tray app finds next time is what the switches show.
expect(JSON.parse(readFileSync(dockPrefsPath(home), 'utf8'))).toEqual({ enabled: false })
expect(launches).toEqual([{ exe: TRAY_EXE, args: ['--quit'] }])
@ -826,6 +926,22 @@ describe('MenubarCompanion', () => {
expect(readTrayFile('app', home)).toEqual({})
})
// Both setters take whatever the renderer sent over IPC. `'enabled' in patch` is the
// first thing setTrayDockPref reads, and it throws a TypeError on a primitive, so the
// shape is settled before anything looks inside it.
it('answers a patch that is not an object without writing or throwing', async () => {
const companion = running()
for (const patch of [null, undefined, 'enabled', 42, ['enabled'], true]) {
expect(await companion.setTrayDockPref(patch)).toEqual(await companion.trayPrefs())
expect(await companion.setTrayAppPref(patch)).toEqual(await companion.trayPrefs())
}
expect(launches).toEqual([])
expect(readTrayFile('app', home)).toEqual({})
expect(readTrayFile('dock', home)).toEqual({})
})
it('keeps the rail placement while writing a dock setting', async () => {
const companion = running()
mkdirSync(join(home, '.config', 'codeburn'), { recursive: true })

View file

@ -10,10 +10,10 @@
//
// NSIS the staged `CodeBurn.Menubar_<version>_x64_en-US.msi` is installed
// through the CLI's own installer (src/menubar-installer.ts, reached
// with CODEBURN_MENUBAR_MSI). The MSI carries a fixed WiX upgrade
// code, so a manual `codeburn menubar` install is upgraded in place
// rather than duplicated, and a newer one is left alone. Launch at
// login is an HKCU Run value.
// with `codeburn menubar --staged-msi <path>`). The MSI carries a fixed
// WiX upgrade code, so a manual `codeburn menubar` install is upgraded
// in place rather than duplicated, and a newer one is left alone.
// Launch at login is an HKCU Run value.
//
// AppX msiexec is not available to a packaged app and would fight the
// package manager anyway. The tray exe ships inside the package and
@ -33,14 +33,22 @@ import { join, win32 } from 'node:path'
import { spawnCliAction, type ActionResult } from './cli'
import {
DEFAULT_TRAY_APP_PREFS, DEFAULT_TRAY_DOCK_PREFS,
normalizedPreferred, parseTrayAppPrefs, parseTrayDockPrefs, patchTrayFile, readTrayFile,
sanitizeAppPatch, sanitizeDockPatch, writeFileAtomic, type TrayAppPrefs, type TrayDockPrefs,
isPatchObject, normalizedPreferred, parseTrayAppPrefs, parseTrayDockPrefs, patchTrayFile,
readTrayFile, sanitizeAppPatch, sanitizeDockPatch, writeFileAtomic,
type TrayAppPrefs, type TrayDockPrefs,
} from './tray-settings'
/** Env var the CLI's Windows installer reads; the source of truth is src/menubar-installer.ts. */
export const BUNDLED_MSI_ENV = 'CODEBURN_MENUBAR_MSI'
/** How the staged file is named to the CLI's Windows installer: an option on the command
* rather than an environment variable, so nothing a stray variable in the environment names
* ever reaches msiexec. The source of truth is src/menubar-installer.ts, which rejects the
* variable this flag replaced. */
export const STAGED_MSI_FLAG = '--staged-msi'
/** One machine-readable line the same installer prints, so this side need not read its prose. */
export const BUNDLED_RESULT_PREFIX = 'CODEBURN_MENUBAR_RESULT '
/** The one line the installer prints for msiexec exit 3010, which its machine-readable result
* does not carry a field for. A deferred file replacement leaves the old binary in place, so
* this is read from the prose until the CLI reports it in {@link MenubarInstallResult}. */
const REBOOT_NOTICE = /Windows wants a restart to finish the install/i
/** The release asset name, which is also the only version statement that cannot lie. */
const MSI_PATTERN = /^CodeBurn\.Menubar_(.+)_x64_en-US\.msi$/
/** What the Tauri bundle names its executable. */
@ -72,6 +80,10 @@ export type MenubarInstallResult = {
exePath: string
uninstallString: string | null
installedBy: 'desktop' | 'manual' | null
/** Whether msiexec exited 3010 and deferred the file replacement to the next restart. The
* CLI does not report this field today, so it is read from the installer's own notice
* line instead; when the CLI starts sending it, this is what is believed. */
rebootRequired?: boolean
}
/** The desktop app's own record of the two switches, in its userData directory. */
@ -451,12 +463,19 @@ export type CompanionStatus = {
sidebar: boolean
/** The Store route, where launch at login is the package's own startup task. */
store: boolean
/** True when this run installed a tray app that Windows can only finish putting in place
* at the next restart. Nothing is started until then, so the switches say why rather than
* looking on with nothing running. Absent on a preload that predates it. */
restartRequired?: boolean
}
export class MenubarCompanion {
private settings: CompanionSettings
/** When `--quit` was last sent, so a launch right after it can wait for the exit. */
private quitAskedAt = 0
/** Set by an install msiexec could only half apply; see {@link MenubarCompanion.launchTray}.
* Not saved: the restart it is waiting for also clears it. */
private restartRequired = false
/** The launcher written for the tray app this run, or null when there is no bundled CLI
* to point one at. Handed to every tray app this side starts. */
private cliLauncher: string | null = null
@ -479,6 +498,20 @@ export class MenubarCompanion {
return this.cliLauncher ? { [TRAY_CLI_ENV]: this.cliLauncher } : undefined
}
/**
* Start the tray app, or say nothing at all when a restart is owed.
*
* An install that could not replace a running binary is finished by Windows at the next
* restart, and until then the file at that path is the previous tray app. Starting it would
* put the old one in the notification area under the new version's name, which is the exact
* confusion the install path takes trouble to avoid elsewhere. Stopping one is still fine,
* so `--quit` does not come through here.
*/
private launchTray(exePath: string): void {
if (this.restartRequired) return
this.launch(exePath, ['--reload-settings'], this.launchEnv())
}
/**
* Rewrite the launcher and record where it is. Runs at every bootstrap, so a desktop app
* that has been updated points the tray app at its new copy of the CLI rather than at the
@ -534,6 +567,7 @@ export class MenubarCompanion {
menuBar: this.settings.menuBar,
sidebar: this.settings.sidebar,
store: this.deps.store,
restartRequired: this.restartRequired,
}
}
@ -585,7 +619,7 @@ export class MenubarCompanion {
// one's uninstall took it, and that was the installer's doing, not the person's choice.
await this.reconcileRunKey(firstRun || this.placedTray)
this.placedTray = false
this.launch(exePath, ['--reload-settings'], this.launchEnv())
this.launchTray(exePath)
}
/** True between an install that placed a tray app and the seeding that follows it. */
@ -663,23 +697,34 @@ export class MenubarCompanion {
* msiexec call and the never-downgrade rule, and this side only stages the file and
* records what came back. */
private async install(staged: { path: string; version: string }): Promise<string | null> {
const result = await this.runCli(['menubar'], {
const result = await this.runCli(['menubar', STAGED_MSI_FLAG, staged.path], {
timeoutMs: INSTALL_TIMEOUT_MS,
extraEnv: { [BUNDLED_MSI_ENV]: staged.path },
})
const parsed = parseInstallResult(result.stdout)
if (!parsed) {
console.error(`menubar install did not report a result: ${result.stderr.trim() || `exit ${String(result.code)}`}`)
// A failure is remembered the same way a refusal is: whatever went wrong will go wrong
// again with the same file, and retrying it at every launch only repeats the prompt.
this.save({ installDeclinedVersion: staged.version })
// Not remembered as a refusal. Nothing was put in front of anyone here: the spawn timed
// out, the CLI was not there, or it died before it could say anything, and all three are
// the kind of thing that comes right on its own. Writing the version down would turn one
// bad launch into a tray app that is never installed again.
// A failed install must not orphan a working tray app that was already there.
return this.settings.trayExePath
}
if (parsed.action === 'cancelled') {
// This one is a decision, and it was theirs. The same file put in front of the same
// person at the next launch is the same admin prompt, so it is not offered again until
// a newer version is staged or they turn the switch on themselves.
this.save({ installDeclinedVersion: staged.version })
return null
}
// Windows Installer does not fail on a file that is in use: it installs what it can and
// leaves the rest to the next restart. The uninstall registry then reports the new
// version while the binary on disk is still the old one, so launching it would start the
// old tray app under the new version's name. Nothing is launched until the restart.
if (parsed.rebootRequired === true || REBOOT_NOTICE.test(result.stdout)) {
this.restartRequired = true
console.log('CodeBurn Menubar was installed; Windows needs a restart to finish it.')
}
const exePath = parsed.exePath || this.settings.trayExePath
this.placedTray = parsed.action === 'installed'
this.save({
@ -729,7 +774,7 @@ export class MenubarCompanion {
this.save({ trayExePath: exePath })
await this.setRunKey(true)
await this.awaitQuit()
this.launch(exePath, ['--reload-settings'], this.launchEnv())
this.launchTray(exePath)
return this.status()
}
@ -759,7 +804,7 @@ export class MenubarCompanion {
const exePath = await this.resolveTrayExe()
if (exePath) {
this.save({ trayExePath: exePath })
this.launch(exePath, ['--reload-settings'], this.launchEnv())
this.launchTray(exePath)
}
return this.status()
}
@ -783,7 +828,7 @@ export class MenubarCompanion {
/** A setting the tray app keeps in `windows-settings.json`. Every other key in that file
* is left alone, and the running tray app is told to re-read it. */
async setTrayAppPref(patch: Record<string, unknown>): Promise<TrayPrefs> {
async setTrayAppPref(patch: unknown): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
const clean = sanitizeAppPatch(patch)
if (Object.keys(clean).length > 0) {
@ -802,8 +847,11 @@ export class MenubarCompanion {
* through the same path and keeps the rule that the rail cannot outlive the tray app.
* A provider set that no longer holds the resting provider moves it, as the rail would.
*/
async setTrayDockPref(patch: Record<string, unknown>): Promise<TrayPrefs> {
async setTrayDockPref(patch: unknown): Promise<TrayPrefs> {
if (!this.supported()) return unsupportedTrayPrefs()
// The `in` below is the first thing that reads the argument, and it reaches here straight
// off an IPC message, so what it is has to be settled before it is asked about a key.
if (!isPatchObject(patch)) return this.trayPrefs()
if ('enabled' in patch) {
await this.setSidebarEnabled(patch.enabled === true)
return this.trayPrefs()
@ -838,7 +886,7 @@ export class MenubarCompanion {
private async nudgeTray(): Promise<void> {
if (!this.settings.menuBar) return
const exePath = this.settings.trayExePath
if (exePath && this.exists(exePath)) this.launch(exePath, ['--reload-settings'], this.launchEnv())
if (exePath && this.exists(exePath)) this.launchTray(exePath)
}
private applyDockSetting(enabled: boolean): void {

View file

@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
DEFAULT_TRAY_APP_PREFS,
canDeselect,
isPatchObject,
normalizeScale,
normalizedPreferred,
parseTrayAppPrefs,
@ -188,6 +189,27 @@ describe('sanitizeDockPatch', () => {
})
})
// Both patches arrive over IPC, so the argument is whatever the renderer sent rather than the
// object the signature promises. `'metric' in patch` throws a TypeError on a string, a number
// and null, and on an array it answers about indices, so the shape is settled first.
describe('a patch that is not an object at all', () => {
const notObjects: unknown[] = [null, undefined, 'metric', 42, ['metric'], [], true]
it('is dropped whole by isPatchObject', () => {
for (const value of notObjects) expect(isPatchObject(value)).toBe(false)
expect(isPatchObject({})).toBe(true)
expect(isPatchObject({ metric: 'cost' })).toBe(true)
})
it('writes nothing through sanitizeAppPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeAppPatch(value)).toEqual({})
})
it('writes nothing through sanitizeDockPatch, and does not throw', () => {
for (const value of notObjects) expect(sanitizeDockPatch(value)).toEqual({})
})
})
describe('the rail always has something to show', () => {
it('moves the resting provider when it leaves the set', () => {
expect(normalizedPreferred('claude', ['claude', 'codex'])).toBe('claude')

View file

@ -167,13 +167,26 @@ export function parseTrayDockPrefs(raw: Record<string, unknown>): TrayDockPrefs
/** Provider ids are the CLI's own, and they end up in a file the tray app parses. */
const PROVIDER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
/**
* Whether a patch is a shape the key checks below can be run against at all.
*
* These patches arrive over IPC, so what turns up is whatever the renderer sent rather than
* what the type says. `'metric' in patch` throws a TypeError on a string, a number or null,
* and on an array it answers about indices, so the shape is settled before any key is read
* out of it. Nothing but a plain object can carry a setting, so everything else is dropped.
*/
export function isPatchObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/**
* Keep only the keys this pane is allowed to write, each checked against what the tray app
* can read back. Anything else in the patch is dropped rather than merged, so a renderer
* cannot reach the rail's placement or any other key the tray app owns.
*/
export function sanitizeAppPatch(patch: Record<string, unknown>): Record<string, unknown> {
export function sanitizeAppPatch(patch: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (!isPatchObject(patch)) return out
if ('metric' in patch) out.metric = oneOf(patch.metric, DISPLAY_METRICS, DEFAULT_TRAY_APP_PREFS.metric)
if ('menubarPeriod' in patch) out.menubarPeriod = oneOf(patch.menubarPeriod, MENUBAR_PERIODS, DEFAULT_TRAY_APP_PREFS.menubarPeriod)
if ('accent' in patch) out.accent = oneOf(patch.accent, ACCENTS, DEFAULT_TRAY_APP_PREFS.accent)
@ -184,8 +197,9 @@ export function sanitizeAppPatch(patch: Record<string, unknown>): Record<string,
return out
}
export function sanitizeDockPatch(patch: Record<string, unknown>): Record<string, unknown> {
export function sanitizeDockPatch(patch: unknown): Record<string, unknown> {
const out: Record<string, unknown> = {}
if (!isPatchObject(patch)) return out
if ('enabled' in patch) out.enabled = patch.enabled === true
if ('scale' in patch) out.scale = normalizeScale(patch.scale)
if ('theme' in patch) out.theme = patch.theme === 'glass' ? 'glass' : 'graphite'

View file

@ -51,6 +51,7 @@ function costBucket(usd: number): string {
// Bucket occurrence counts (MCP-server / skill invocations) the same way costBucket
// coarsens dollars: telemetry carries usage magnitude, never an exact tally.
function countBucket(n: number): string {
if (!(n > 0)) return '0'
if (n < 10) return '1-10'
if (n < 100) return '10-100'
if (n < 1000) return '100-1k'

View file

@ -59,6 +59,8 @@ export function Onboarding({ defaultEnabled, onDone }: { defaultEnabled: boolean
<h2 className="onboard-title">Help improve CodeBurn</h2>
<p className="onboard-body">
Share anonymous usage statistics: model and provider mix, task success rates, performance and errors.
The daily report includes the names of the models, tools, skills and MCP servers you use, alongside
bucketed counts of how often each one came up.
Never your prompts, code, project names or anything that identifies you.
</p>
<div className="onboard-consent">

View file

@ -183,6 +183,11 @@ function CompanionSwitches() {
<div className="companion">
{row('menuBar', 'Menu bar', 'Show CodeBurn in the Windows notification area')}
{row('sidebar', 'Sidebar', 'Show the Capacity Dock rail on the screen edge')}
{/* Windows finishes an install it could not complete at the next restart, and until
then the old tray app is what is on disk, so nothing was started. */}
{status.restartRequired ? (
<p className="companion-note">Restart Windows to finish installing the menu bar app.</p>
) : null}
</div>
)
}

View file

@ -690,6 +690,10 @@ export type CompanionStatus = {
sidebar: boolean
/** The Store route, where launch at login is the package's own startup task. */
store: boolean
/** True when this run installed a tray app that Windows can only finish putting in place at
* the next restart. Nothing is started until then, so the corner says so rather than
* showing two switches on with nothing running. */
restartRequired?: boolean
}
/** The tray app's own settings, from the two files it reads them from

View file

@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson } from '../lib/types'
import type { ActionResult, AliasRow, CombinedUsage, DeviceScanResult, Identity, MenubarPayload, PriceOverrideList, PriceRates, QuotaProvider, ShareStatus, StatusJson, TelemetryStatus } from '../lib/types'
import { Settings } from './Settings'
const mocks = vi.hoisted(() => ({
@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({
setTrayDockPref: vi.fn(),
setLaunchAtLogin: vi.fn(),
telemetryTrack: vi.fn<(name: string, props?: Record<string, unknown>) => Promise<boolean>>(),
telemetryStatus: vi.fn<() => Promise<TelemetryStatus | null>>(),
setTelemetryEnabled: vi.fn<(enabled: boolean) => Promise<TelemetryStatus | null>>(),
}))
vi.mock('../lib/ipc', async orig => {
const actual = await orig<typeof import('../lib/ipc')>()
@ -59,6 +61,9 @@ const trayPrefs = {
dock: { enabled: true, preferred: 'claude', scale: 0.6, theme: 'graphite', gaugeShape: 'circle', providers: ['claude'], manualSelection: true },
launchAtLogin: false,
}
const telemetryOff: TelemetryStatus = {
installId: '8f1c2b4d', country: 'DE', enabled: false, defaultEnabled: false, onboarded: true,
}
const stored = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => stored.get(key) ?? null,
@ -93,6 +98,8 @@ describe('Settings', () => {
mocks.chooseDirectory.mockResolvedValue('/Users/toruk/Exports')
mocks.exportData.mockResolvedValue(actionOk)
mocks.telemetryTrack.mockResolvedValue(true)
mocks.telemetryStatus.mockResolvedValue(telemetryOff)
mocks.setTelemetryEnabled.mockImplementation(async enabled => ({ ...telemetryOff, enabled }))
// No bundled tray app unless a test says otherwise, which is every platform but Windows.
mocks.companionStatus.mockResolvedValue({ supported: false, menuBar: false, sidebar: false, store: false })
mocks.trayPrefs.mockResolvedValue(trayPrefs)
@ -150,6 +157,45 @@ describe('Settings', () => {
expect(sent).not.toContain('/Users/toruk')
})
// The main process drops any event raised while telemetry is off, so an opt-in tracked
// before the write lands is one that can never be sent. It goes out after the toggle takes.
it('reports the telemetry opt-in only once the toggle has taken', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Privacy & data' }))
await user.click(await screen.findByRole('switch', { name: 'Anonymous telemetry' }))
expect(mocks.setTelemetryEnabled).toHaveBeenCalledWith(true)
await waitFor(() => {
expect(mocks.telemetryTrack).toHaveBeenCalledWith('settings_change', { setting: 'telemetry', value: true })
})
const tracked = mocks.telemetryTrack.mock.invocationCallOrder.at(-1)!
expect(tracked).toBeGreaterThan(mocks.setTelemetryEnabled.mock.invocationCallOrder[0]!)
})
it('sends nothing when the write does not take, and nothing on the way out', async () => {
const user = userEvent.setup()
// A settings write that failed leaves telemetry off, so there is no opt-in to report.
mocks.setTelemetryEnabled.mockResolvedValue(telemetryOff)
render(<Settings period="month" />)
await user.click(screen.getByRole('button', { name: 'Privacy & data' }))
await user.click(await screen.findByRole('switch', { name: 'Anonymous telemetry' }))
await waitFor(() => expect(mocks.setTelemetryEnabled).toHaveBeenCalled())
// Turning it off mints a fresh install id and drops the queue, so an opt-out event would
// never arrive anywhere either.
mocks.telemetryStatus.mockResolvedValue({ ...telemetryOff, enabled: true })
mocks.setTelemetryEnabled.mockResolvedValue(telemetryOff)
render(<Settings period="month" />)
await user.click(screen.getAllByRole('button', { name: 'Privacy & data' }).at(-1)!)
await user.click((await screen.findAllByRole('switch', { name: 'Anonymous telemetry' })).at(-1)!)
await waitFor(() => expect(mocks.setTelemetryEnabled).toHaveBeenCalledWith(false))
const telemetryEvents = mocks.telemetryTrack.mock.calls.filter(([, props]) => props?.setting === 'telemetry')
expect(telemetryEvents).toEqual([])
})
it('persists theme choices and applies forced themes to the root', async () => {
const user = userEvent.setup()
render(<Settings period="month" />)

View file

@ -542,10 +542,16 @@ function TelemetryClaim() {
if (typeof codeburn.setTelemetryEnabled !== 'function') return
// Only the opt-IN is reportable: turning telemetry off mints a fresh install
// id and drops the queue, so an opt-out event would never be sent anyway.
if (!status.enabled) trackEvent('settings_change', { setting: 'telemetry', value: true })
codeburn.setTelemetryEnabled(!status.enabled).then(value => setStatus(value)).catch(() => {})
// And it is tracked after the switch has taken, never before: telemetry that is
// still off drops the event on the floor, so an opt-in recorded ahead of the
// write was one that could never be sent.
const optingIn = !status.enabled
codeburn.setTelemetryEnabled(optingIn).then(value => {
setStatus(value)
if (optingIn && value?.enabled) trackEvent('settings_change', { setting: 'telemetry', value: true })
}).catch(() => {})
}
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19v-5M9 19V9M14 19v-8M19 19V5" /></svg><div style={{ flex: 1 }}><div className="set-claim-t">Anonymous telemetry</div><div className="set-claim-d">Optional usage statistics: model mix, task success, performance and errors. Never prompts, code or anything identifying.</div></div>
return <div className="set-claim"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19v-5M9 19V9M14 19v-8M19 19V5" /></svg><div style={{ flex: 1 }}><div className="set-claim-t">Anonymous telemetry</div><div className="set-claim-d">Optional usage statistics: model mix, task success, performance and errors. The daily report includes the names of the models, tools, skills and MCP servers you use, alongside bucketed counts of how often each one came up. Never prompts, code or anything identifying.</div></div>
<button type="button" role="switch" aria-checked={status.enabled} aria-label="Anonymous telemetry" className={status.enabled ? 'switch on' : 'switch'} onClick={toggle}><span className="switch-knob" /></button>
</div>
}

View file

@ -142,6 +142,9 @@ body { overflow: hidden; background: var(--bg); color: var(--ink); }
.companion-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; }
.companion-label { color: var(--mut); font-size: 12px; font-weight: 480; }
.companion-row .switch { margin-left: auto; }
/* The one line the corner ever says out loud: an install Windows can only finish at the next
restart, which is why neither switch has started anything. */
.companion-note { margin: 2px 0 4px; color: var(--mut); font-size: 11px; line-height: 1.35; }
/* Sidebar while the menu bar app is off: the rail is one of its windows, so there is nothing
to switch. Dimmed rather than hidden, so the row does not appear and vanish under a pointer
on its way to About. */