feat(windows): Capacity Dock, an edge-docked quota rail - #1228
Open
iamtoruk wants to merge 101 commits into
Open
feat(windows): Capacity Dock, an edge-docked quota rail#1228iamtoruk wants to merge 101 commits into
iamtoruk wants to merge 101 commits into
Conversation
Quit CodeBurn in the tray menu called app.exit(0), but ExitRequested was unconditionally prevented in the run loop, so the process never actually exited. Only prevent exit when code is None (the popover window closing on its own), letting an explicit app.exit(..) through. Launching the menubar app twice left two processes running, each registering its own flame + spend-badge tray icon pair, so the notification area showed four icons for one app. Add tauri-plugin-single-instance, registered first in the builder chain, so a second launch just refocuses the running instance's popover.
…t/windows-capacity-dock
A slim always-on-top rail on the right screen edge showing one capacity ring per signed-in provider, the Windows twin of the macOS Capacity Dock. The window is created on demand and closed when the dock is switched off, so a disabled dock leaves no webview resident. Hovering widens the same window to reveal a detail panel with each quota window, its used percent, plan and reset countdown; the panel unmounts on collapse rather than lingering hidden. Data comes from `codeburn quota --format json` through the existing cli.rs spawn plumbing. A CLI without that subcommand exits with `unknown command`, which the rail reports as a quiet update-needed state instead of an error. Metrics are the mac dock's whole-point values at its default 0.6 scale.
The invisible Capacity Dock was not a rendering bug. The debug exe had been
started through the QEMU guest agent's guest-exec, which runs as a service in
session 0, so the dock window was created on a window station that no desktop
shows. The stderr trace ("window created and shown") was accurate; the window
was simply never visible. Launched from the interactive session, the same
binary docks the rail at the right edge, shows the quiet "--" row without
providers, expands on hover, closes on toggle off and comes back on toggle on.
Debug builds on Windows now check the process window station for WSF_VISIBLE
at startup and print a warning when it is missing, so the next service session
launch explains itself. Verified: fires under a SYSTEM scheduled task, silent
from a terminal. Only a feature flag is added to the existing windows-sys
dependency; no versions change.
The Windows dock now mirrors CapacityDockView.swift at the mac's 0.6 default scale: the graphite gradient surface with the concave edge flare, a provider glyph inside a recessed ring, severity colours (green, yellow from 50, orange from 75, red from 90) on ring, percent and bars, warm off-white text, and the tailed detail bubble with the mac typography. This also settles the open severity-colour question in favour of the mac behaviour. Interaction follows CapacityDockController and CapacityDockInteractionState: the resting rail shows the preferred provider; hovering expands it after 80 ms and collapses 180 ms after leaving; hovering a row shows its bubble after 180 ms and dismisses it 240 ms after the row unless the pointer is on the bubble; clicking a row pins the rail and makes that provider the preferred one, persisted in windows-dock.json; Escape dismisses; right-click opens a native menu with Refresh and Hide Capacity Dock. One transparent window hosts both rail and bubble. dock.rs owns the geometry as a pure layout function with unit tests, applies size and position in a single SetWindowPos so the rail never flashes between two updates, and returns window-relative frames the page paints into after measuring the bubble's natural height. Every window metric stays a whole pixel. Out of scope for v1, as before: dragging, edge choice and scale.
… that never glitches on hover Hovering used to move and resize the dock window, and WebView2 presents one stale frame at every window change, which read as the rail drifting and glitching. One transparent window is now sized once per placement for the fully expanded rail plus the bubble's reach and never moves on hover. A 60 Hz cursor poll, the counterpart of the mac's global event monitor, makes the window click-through everywhere but the painted shapes, synthesizes hover for the page, and drives dragging. Drag follows CapacityDockController: the page owns the 3 px threshold and passes the press point, Rust keeps the rail under the pointer, morphs the flare away as it leaves an edge, and on release snaps to any edge within 44 px or leaves the rail floating, then hands the page the old rail rect so it glides into place with the mac's attach and detach curves. Placement (docked edge, orientation, normalized offsets) persists in windows-dock.json. Top and bottom give a horizontal rail with the bubble below or above; a floating rail keeps its orientation and puts the bubble where there is room. The context menu gains Dock to Edge with all four edges. The layout stays a pure function with unit tests, and every window metric stays a whole pixel. windows-sys gains one feature flag for the cursor and button reads; no versions change.
…atching edge Dropping the rail in the top-right corner left a horizontal rail whose ring sat at its far left: the rows container kept the stylesheet's left and right of 0, so an end-anchored horizontal rail only ever set its right and the row slid to the start. Both orientations now set all four offsets explicitly, and the stylesheet no longer pins the container. While dragging, the flare followed the attachment progress of whichever edge was nearest, so a vertical rail approaching the top wore a right-edge flare in open space. The flare now only follows an edge of the rail's own orientation; a perpendicular edge shows the loose pill until the drop, and the settle glide runs from centre to centre so a change of orientation does not jump. Adds a corner test and logs the layout inputs in debug builds.
The quota adapter only ever looked at ~/.config/github-copilot. Windows has no such directory: the editor plugins write hosts.json and apps.json under %LOCALAPPDATA%\github-copilot, so every signed-in Windows user was reported as not connected and the Capacity Dock had nothing to show for Copilot. The credential paths become an ordered list of directories resolved per platform, and per call rather than at import, so the environment the paths come from can still be changed by the caller. The XDG directory stays as a second candidate on Windows for a token that arrived through a POSIX-style shell home.
Ported from the menubar's CursorSubscriptionService so every surface that reads the CLI, the Windows Capacity Dock included, sees the same Cursor capacity the Mac shows. The adapter opens Cursor's own VS Code state database read-only for the `cursorAuth/accessToken` row, rebuilds the web session cookie from the user id inside that JWT, and asks cursor.com for the usage summary. It never refreshes the session and never writes: an expired token is terminal until the user signs in to Cursor again, and a database Cursor is busy writing is transient rather than a signed-out user. Windows layouts are covered too: the database follows the VS Code convention of each platform, so Windows reads it from AppData\Roaming.
Ported from the menubar's ZaiSubscriptionService, so the Windows Capacity Dock and the settings pane can read Z.ai capacity from the CLI the same way the Mac reads it natively. Credential order mirrors the menubar: a key the user supplied first (a Keychain entry there, ZAI_API_KEY here), then the Z.ai login the Pi CLI already keeps in ~/.pi/agent/auth.json. The key is used for one request and never written anywhere. Z.ai answers HTTP 200 with an authentication code in the body, so the decoder reports that as a rejection rather than as an unreadable response, and it keeps reading the older string-typed limit fields.
The strip was a fixed seven, so a machine running Gemini, Kimi or any of the other two dozen tools the CLI reports had no way to filter by them. It now mirrors AgentTabStrip.swift: All first, then every provider the payload says has activity, dearest first, with the provider's brand mark from the mac's ProviderFilter.color and the compact cost chip. Provider ids come straight from the payload, which is what --provider takes, so any tool the CLI grows appears without a change here. The known-tool list stays as the fallback for a machine the CLI has not seen activity on, dimmed and explaining itself on hover, because that copy is the first-run answer to "what is CodeBurn watching". Overflow chevrons select the neighbouring tab, as on the mac, rather than scrolling blindly; both measurements are taken from elements the chevrons do not resize, so showing them cannot feed back into the overflow decision.
Ported from the menubar's GrokBuildSubscriptionService so Grok capacity reaches every surface that reads the CLI, the Windows Capacity Dock included. The login the Grok CLI already owns in $GROK_HOME/auth.json is read only: the file maps a scope to its token, so the current OIDC login is preferred over the older sign-in entry and ties break by scope rather than by key order. CodeBurn never refreshes it, so an expired login is terminal until the user runs `grok login` again, and a file it cannot parse is reported as that rather than as a signed-out user. Grok bills one credit pool with no window label of its own, so the label comes from the distance to the period end, as on the Mac. The settings call that supplies the plan display name is best effort: losing it costs the label, not the reading.
Ported from the menubar's ClinePassSubscriptionService, completing the set of providers the Mac reads live so the Windows dock and settings can show the same capacity. ClinePass has no local login file: the key is one the user supplies, a Keychain entry on the Mac and CLINEPASS_API_KEY (or CLINE_API_KEY) here. The contract is published rather than internal, so a field that is present but wrong is treated as a broken response instead of being skipped past; only an unknown window type is ignored, so a fourth window would not break the reading.
The popover had no idea how much of a subscription was left; only the Capacity Dock read `codeburn quota`, and it polled on its own. A store in the page now owns that answer: one CLI spawn every two minutes, single-flight, with the mac's SubscriptionRefreshBackoff on failure (a failed refresh retries after 30s and doubles back out to the cadence, plus jitter), polling only while a surface is subscribed. The strip, the warning row and the Plan insight will all read it rather than each spawning a Node process. Each tab whose provider reports quota gains AgentTabQuotaBar: a 3px capsule in the four severity tiers (under 50 green, yellow, orange from 75, red from 90), solid red when the credential needs reconnecting. Hovering opens the mac's 260px QuotaDetailPopover after 250ms and closes it 150ms after leaving: plan pill, a row per window with bar, percent and time to reset, and the stale, retrying, disconnected and reconnect cards with the mac's copy. The CLI's quota JSON is flatter than the mac's adapters, collapsing the connection state to `available` plus an optional error, so the six-way Connection enum is rebuilt from those two fields plus what the store knows about the refresh in flight. The severity palette needed a second set of tokens: the mac only ever draws these colours on a dark material, and plain yellow is illegible on this warm light surface, so text and icons take a darkened ink in the light theme while the bars keep the system colours. A third set covers the hover card, whose surface is deliberately inverted against the theme.
…uota A capsule under a tab only speaks when you are looking at that tab. The mac puts a compact row under the wordmark listing every connected provider at or over the warning threshold with its worst window, so the answer to "am I about to hit a wall" is on screen the moment the popover opens. Ported from QuotaWarningRow in MenuBarContent.swift, including the wording that reads "Claude over limit (105%)" for a single provider past its cap rather than the awkward "105% of quota used", and the icon ladder from info circle through exclamation circle and triangle to the octagon.
Plan was Claude only, and its pill was gated on Claude being the single detected tool, so a machine running two tools lost the view entirely. It now follows the quota store: the pill appears when the selected provider reports quota, or when All is selected and at least one does, and All stacks a block per connected provider with Claude first. Claude keeps its own path. The tier and last cycle's final reading come from the credential file through plan_usage, and nothing else can supply them. Other providers render from the store with the same utilization rows and the same projection captions, plus the mac's no-credentials and reconnect states. The CLI reports a window's label and reset time but not its length, which the projection needs to know how far into the window we are, so the length is read off the label and an unrecognised one gets no caption rather than a guess.
What the tray says without being clicked, brought in line with the mac status
item. The flame takes the worst connected provider's quota severity (yellow,
orange, red; untinted while there is nothing to say), and falls back to yellow
when today's spend is over the daily budget, in that order. The logo is
recoloured in Rust from the embedded artwork, alpha untouched so only the
colour moves; no new dependency, since the tray feature already pulls in a PNG
decoder. The daily budget is read from the CLI's own config.json, where
package C's settings UI will write it; absent or zero means no budget, which
is the default today.
The badge figure and tooltip are written to %LOCALAPPDATA% on every refresh
and put back at startup while under ten minutes old, from the mac's
MenubarStatusCache, so a relaunch no longer blanks the tray for a CLI round
trip. The frontend stopped clearing the badge before its first payload, which
would otherwise wipe what was just restored.
The right-click menu gains the mac's disabled usage row at the top ("Today ·
$X · N calls"), Settings..., Capacity Dock Settings... and About CodeBurn.
The three open the popover on a panel for now; package C turns them into deep
links to the settings window.
About CodeBurn in the tray menu now lands on the About group rather than the top of the panel, and Capacity Dock Settings lands in General, as it does on the mac until the dock has a pane of its own. The panel takes the section it should show rather than guessing from what opened it.
The header warning row listed every connected provider from 50 percent, which fires on an ordinary working afternoon and trains the reader to ignore it. The mac's aggregate status uses 70, the point where the rest of the window is genuinely at risk. Match it.
Plan describes one subscription: a tier, its windows and when they reset. Under All it stacked a block per connected provider, which reads as a list of plans rather than an insight and pushed the real content off the first screen. The mac hides the pill unless a single provider with a live quota source is selected, so do the same and drop the stacking.
…cker The popover opened with a flat two-tone wordmark and "AI Coding Cost Tracker", which is a category, not a promise. The mac says "Your AI Bill, Itemized" under the site's animated flame wordmark, and lets the reader pick the accent the whole surface is drawn in. The wordmark is the website's .flame-text verbatim: a 300 percent wide flame gradient clipped to the letters, swept by flameShift 3s ease infinite. It pauses while the popover is hidden, the gate the mac added after measuring 5 to 7 percent idle CPU on an always-running sweep, and honours prefers-reduced-motion. The nine presets are ThemeState.swift's, each with its four shades, and they map one to one onto the accent tokens the stylesheet already declares on :root. Writing them as an inline style on the same element outranks both theme blocks, so a single write covers light and dark, and main.tsx applies the saved preset before the first paint so nothing renders in ember and then jumps. The dock window reads the same tokens.
Every chip led with a 5 px coloured dot, so a strip of five providers read as a legend before it read as a row of names, and the dot competed with the quota capsule underneath it for the same "how is this provider doing" glance. The name and the cost carry the chip on their own. The provider colour table stays: the capsule, the hover card and the settings panes can still ask for a brand colour.
…are behind it The card was drawn rounded but three square things sat behind it: the acrylic backdrop, which the window effect paints edge to edge and which DWM's corner preference does not clip; the DWM frame line; and the DWM drop shadow, whose inner edge is the window rectangle, so it met itself at a right angle exactly where the card curved away. The result was a rounded card with square corners showing through, worst at the top left and along the bottom. The window now paints nothing at all. The acrylic effect is gone (at 94 percent surface opacity it was contributing a tint, not a blur, and the surface is simply opaque now), DWM is told not to round and not to draw a border, and the OS shadow is off. In its place the window is 12 px larger than the card on every side and the page paints its own drop shadow into that gutter, so the shadow follows the card radius. The gutter doubles as the inset the card kept from the work-area edge, so the card lands where it always did. Verified at 10x on all four corners in both themes: one antialiased arc, the desktop behind it, and the shadow curving with it.
The period strip stopped at "All", which the CLI reads as six months, so there was no way to ask for the whole history and no way to ask about one particular day. The mac offers six segments plus a calendar button, and the CLI has taken --day and --days all along. The strip now carries the mac's labels (Today, 7D, 30D, Month, 6M, Life), which are short for a reason: six segments and the button share one 360 px row. The picker collects days into a pending set and only applies them on Done, so choosing five days costs one CLI run rather than five, and the caption reads "2 days (2026-08-31 .. 2026-09-01)" as it does on the mac. A picked day overrides the period without clearing it, so dismissing the picker returns to whatever segment was live. The payload cache is now keyed on the whole selection rather than on period and provider, since two selections that send different arguments to the CLI are different answers. Rust validates the dates itself: is_safe_arg would happily pass "2026" or a bare word to --day.
Trend always drew 19 days, so asking for 30 Days or a month showed the hero for that period above a chart of a different one, and Life showed 19 days of a lifetime. The mac sizes the window to the question: 19 for today and the week, 30, 31, and up to 90 for the long periods, with the "vs prior Nd" comparison moving with it. A picked day falls back to 19, because one day is not a trend. The bar columns now share the width instead of being pinned at 13 px, as the mac columns do: 19 land on the same 13 px they always had, 90 simply get thinner, and past 45 bars the gap drops to 2 px so the bars keep more of the chart than the gaps do.
Three numbers the payload already carried had nowhere to go.
Models gains the mac's Saved column: what a local model would have cost
at its paid baseline. It only appears when a local-model mapping actually
saved something, otherwise it is an unlabelled column of dashes. The bar
still tracks real cost, so a local model at zero leaves it empty and the
counterfactual is never summed into spend.
The hero gains the same figure as a period total ("Saved $X with local
models") and, on Today, the daily-budget alert the tray flame already
turns yellow for. Both are separate lines under the amount, deliberately,
so the reader cannot add avoided spend to real spend. The budget is read
from the CLI config through a new `daily_budget` command, and re-read
whenever the popover opens, since package C's settings window will be
what writes it.
An app whose only surface is a tray icon has no handle anyone else can take hold of. The single-instance plugin already hands the running process the argv of whatever tried to start beside it, so that is the control channel: --quit exits, --reload-settings re-reads what is on disk, and anything else is still the plain relaunch that shows the popover. The desktop app that is about to bundle this one needs both. Its Menu bar switch stops the tray process; its Sidebar switch writes enabled into windows-dock.json and then asks a running app to notice, which without this would wait for the next launch. --quit at an app that is not running would otherwise start one and leave it there, so it is answered before anything is built rather than in the plugin callback that a first launch never reaches.
The desktop app's Store build ships this tray app inside its own MSIX package, and a package updates as one thing. An .msi install underneath it would be refused at worst and undone by the next Store update at best, so the check does not run there at all: no request to GitHub, no `codeburn --version` spawn, no cache file on disk. GetCurrentPackageFullName is the documented ask, which is why the length is requested with no buffer to put it in: outside a package it answers APPMODEL_ERROR_NO_PACKAGE, inside one it complains about the buffer. storeManaged travels with the status so the surfaces go quiet rather than inventing a silence of their own: the header badge hides, About says where updates come from instead of offering two buttons that can do nothing, and the tray menu does not carry Check for Updates at all. The CLI banner needed no change, since it reads cliUpdateAvailable, which a store-managed status leaves false.
The upgrade code is what tells Windows Installer that two .msi files are the same product, and it is about to matter: the desktop app will ship this build and install it over whatever a manual `codeburn menubar` already put there. With one upgrade code that is an in-place upgrade; with two it is a second copy of the tray app beside the first. Tauri derives the code from a v5 UUID over "<productName>.exe.app.x64" when the config does not name one, so the value written here is the one every released .msi already carries (`tauri inspect wix-upgrade-code` reports the override and the derived default as the same GUID). Nothing about existing installs changes. What changes is that renaming the product can no longer move it by accident.
Installing the desktop app should give a person the tray icon and the Capacity Dock without their ever learning that `codeburn menubar` exists. So it carries the matching windows-v* build and puts it there itself, at first launch and at every launch after, which is also what covers a desktop-app update: the staged .msi travels with the app. The install itself is not reimplemented here. The CLI already owns the registry read, the checksum, the msiexec call and the never-downgrade rule, so this side stages the file, names it in CODEBURN_MENUBAR_MSI and reads the one JSON line that comes back. What it does own is the tray app's process and its Run value, because the two sidebar switches have to be able to stop and start it: the tray app is reached through its own argv, --quit to stop and --reload-settings to make it re-read the dock preference this module writes. The Store route shares almost none of that. msiexec is not available to a packaged app and would fight the package manager anyway, so the tray exe ships inside the package and is launched from there, and no Run value is written because the manifest's own startup task covers launch at login. Two rules worth naming. The defaults are seeded once, so a person who later turns the rail off in the tray app's own settings does not find it back on at the next launch of this one. And every setter answers with the whole status rather than an acknowledgement, so a switch shows what took: an install cancelled at the UAC prompt leaves it where it was.
The sidebar's bottom corner held About and a row of five brand glyphs. The glyphs were the least useful thing on that screen: they are visited once, and About already listed every one of them under Links. So they are only there now, and the corner carries the two things a Windows user does want at hand: whether CodeBurn sits in the notification area, and whether the Capacity Dock rail is on the screen edge. Both are on by default and neither renders at all until the main process says this build has a tray app staged, which is why there is no placeholder row and no greyed-out switch: on macOS, on Linux, and in a dev build with nothing staged, the corner is exactly what it always was. The switch is the shared one from Settings and onboarding, at 30x17 rather than 38x22 because a 186px sidebar has no room for the full size beside a label, and keyed off the theme tokens rather than the white overlays the bare shape uses on the dark onboarding sheet.
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.
The first run wrote enabled: true into windows-dock.json and wrote the tray app's Run value, whatever was already there. On a machine where somebody had installed the tray app by hand and deliberately turned the rail off, or turned launch at login off, installing the desktop app quietly undid both. A default is an answer for a question nobody has answered, not an override of one that has been. So the seed now looks first. An existing enabled value is left exactly as it is and mirrored into the Sidebar switch instead, so the corner opens saying what the rail is actually doing rather than what this app would have preferred; only an absent key is seeded. A file that exists but has never mentioned the rail still counts as no answer, which is the case for anyone who has only ever dragged the rail somewhere. The Run value follows the same rule and, like the rail, is only ever touched on the first run. After that it belongs to whoever changes it: the tray app's own launch-at-login toggle, or the Menu bar switch here, which still writes it on and deletes it off.
Windows has no shebang. CreateProcess starts executables, and both shapes a codeburn install takes here are scripts: dist/cli.js in dev, and the codeburn.cmd that a global npm install puts on PATH. Spawning either with shell: false is EFTYPE, so nothing the desktop app ran ever ran on this platform. The throw came out of startServe inside whenReady and took the rest of it with it, which is why `npm run dev` never even opened a window, and why 49 of this file's own tests failed here. Each script now gets a runner that can start it. A .js goes through this process's own binary as Node, the same shape the bundled CLI already used. A .cmd goes through cmd.exe as one command line, because that is the only way to start a batch file, and the line is built rather than handed to shell: true, which does not escape anything: two parsers read it, cmd first and then whatever splits a command line back into argv, and a .cmd re-expands its own arguments through %*, so the arguments are escaped for both readings. That is cross-spawn's rule, restated here rather than depended on, since the main process ships no npm dependencies. An .exe and a POSIX codeburn are spawned exactly as before. Two things follow from the cmd.exe route. The PATH lookup asks for codeburn.cmd before bare codeburn, because npm writes both into the same directory and the bare one is a shell script Windows cannot start. And killing such a child means killing the tree: the shim exits as soon as it has started node, so the handle we hold is the shim's, and taskkill /T is what reaches the generation below. Five tests do not run on Windows now rather than failing there. They assert catchable SIGTERM: a grace window, a cleanup handler, a child that ignores the first signal, a resident closing its own stdin. child.kill on Windows is TerminateProcess, so those are things the platform does not have rather than things this code gets wrong, and the POSIX expectations are untouched. Three assertions that hard-coded ":" as the PATH separator now use the platform's.
They start several real node children each, and the suite runs fifty files at once, so two of them intermittently hit the five-second default while the machine was busy rather than while anything was wrong. Nothing here asserts how fast a child answers, only which one does.
Tauri names the install directory after the product and the binary after the Cargo package, so what lands on disk is CodeBurn Menubar\codeburn-menubar.exe. The registry read joined the product name onto InstallLocation instead and produced CodeBurn Menubar\CodeBurn Menubar.exe, which has never existed. Everything downstream was built from that path. `codeburn menubar` printed "Launched CodeBurn Menubar." after spawning nothing, on both its paths: the one that finds the right version already installed, and the one that has just installed it. The desktop app stored it in companion.v1.json, so its Menu bar and Sidebar switches sent --quit and --reload-settings to a file that is not there, and the Run key it seeds pointed at the same missing file, which would have failed silently at every login. DisplayIcon comes first now, since an installer that writes one is naming the binary rather than leaving it to be guessed. Tauri leaves it empty, so the path that actually gets used is InstallLocation joined with the binary name, and the binary name is a constant beside the product name rather than the product name reused. Verified against this machine's registry: InstallLocation "C:\Program Files\CodeBurn Menubar\", DisplayIcon empty, and one executable in that directory called codeburn-menubar.exe.
The stored path is a memory, not a fact. It was written by a build that derived the binary name from the product name, so on this machine it named a file that has never existed, and the switches sent --quit and --reload-settings into it while the desktop app's own stderr said ENOENT. A tray app uninstalled after the path was stored gets to the same place. So nothing is launched, and no Run value is written, without checking the file first. A stale stored path is re-resolved through the CLI rather than trusted, which costs one `codeburn menubar`: it re-reads the uninstall registry and installs nothing when the version already matches. A path the installer reports but nothing is at is refused rather than saved, since saving it is what made the switches silently do nothing. Launch at login gets the same treatment. The rule is still that an existing Run value is not this app's to rewrite, with one exception: a value pointing at a file that is not there is not somebody's preference, it is the old wrong path, and it would fail silently at every login. That one is repaired. While here: a byte order mark no longer makes the settings file unreadable. Falling back to the defaults means seeding again, which reinstalls the tray app and re-enables the rail, and a BOM is what anything editing that file by hand on Windows leaves behind.
The rail is a window of the tray app, and every setting it renders from belongs to the tray app, so a Sidebar that is on while the menu bar app is off describes something that cannot exist. Turning Menu bar off now turns Sidebar off with it, and writes enabled: false into windows-dock.json before sending --quit, so what the tray app finds the next time it starts is the state the switches are showing rather than a rail that comes back on its own. Turning Sidebar on with Menu bar off turns Menu bar on first, installing and starting the tray app and writing the Run value, and only then enables the rail; if the tray app cannot be turned on there is nothing for a rail to live in, and both switches stay off. In the corner the Sidebar switch is disabled while Menu bar is off, with a title saying why. That is the primary guard, and the cascade above is what answers a request that arrives any other way: through the IPC channel, or from a settings file that already disagrees with itself.
Installing the .msi over an install of the same version left the old files exactly where they were, which makes `codeburn menubar --force` mean nothing: the one thing force is for is repairing an install that is already there. It is a WiX rule, and Tauri does expose it, just not under the name it goes by. Its main.wxs carries two MajorUpgrade elements and picks between them on allowDowngrades, which defaults to true. That branch is `AllowDowngrades="yes"` with no AllowSameVersionUpgrades; the other one is `DowngradeErrorMessage=... AllowSameVersionUpgrades="yes"`. So asking for no downgrades is what turns same-version upgrades on, and no custom template is needed. Refusing a downgrade at the installer is also the rule this codebase already follows a layer up: both the CLI's bundled install and the desktop app leave a newer menubar alone rather than replacing it. Now the .msi says the same thing by itself.
They start real children and then wait for the OS to report them gone. Under the whole suite, fifty files at once, that wait outran the five-second default often enough to fail on a busy machine while nothing was wrong. Same reason and same headroom as the resident-serve block.
The tray app keeps its preferences in two files it reads from Rust before any of its windows exist, so those files are the interface rather than any running process: windows-settings.json for what only it cares about, windows-dock.json for the Capacity Dock. The desktop app can now read and write both, which is what lets its Settings carry the tray app's own panes. Every write is a merge. Both files hold keys this side has no business touching, the rail's placement above all, and a whole-file write would take them with it. And every value is checked against the same closed set the tray app parses it with: a value it cannot read collapses to a default there, so sending one would silently lose the setting rather than store it. The check is also what keeps a renderer from reaching a key it was not offered. Two rules come with the dock file. Writing the provider set latches manualSelection, because editing the set by hand is what stops the tray app seeding it from whatever is connected. And a set that no longer holds the resting provider moves it, since the rail can only rest on a row it is showing. Showing the rail is the Sidebar switch under another name, so it goes through that path rather than straight to the file, and keeps the rule that the rail cannot outlive the tray app. Launch at login is the one setting that is not in either file: it is the Run value, which this app already owns. After every write the running tray app is nudged with --reload-settings, which makes it re-read both files and broadcast to its own windows. With the tray app off there is nothing to tell and the files are enough.
The tray app has a settings window of its own, and nobody should have to find it to change how the thing in their notification area behaves. Two panes join the Settings rail: Menu bar and Capacity Dock. Only the settings the desktop app does not already have are there. Currency, the period, the scope and the daily budget are shared through the CLI config and already live in General, and repeating them would be two controls for one value. So Menu bar carries the tray figure's metric and period, the accent, whether today's figure gets its own tray icon, the two refresh cadences, the terminal and launch at login; Capacity Dock carries show, resting provider, size, appearance, gauge shape and the per-provider switches. Each pane appears only while its switch in the sidebar corner is on: there is nothing to configure about a tray app that is not running, and the rail is one of its windows. A pane whose switch has just gone off cannot stay on screen either, so the rail falls back to General. Two things the rail's own rules are worth restating here. The last connected provider cannot be switched off, because the rail must never have nothing to show. And a provider stays in the list after its connection drops, so one that has stopped working can still be taken off. Every control renders what came back rather than what it sent: the main process is what decides the value that lands, and it repairs one it would not be able to read later.
The desktop app's stderr on the VM carried "Antigravity quota unavailable: Command failed: ps -ax -o pid=,command=" with "ps: unknown option -- x" on every quota poll. Windows has no ps, and asking for one is a spawn failure rather than an answer, so the provider reported a transient failure with a warning behind it instead of simply not being there. Win32_Process is the same listing by another name, and it is already how src/providers/antigravity.ts finds these servers, so the quota adapter asks the same way: PowerShell by absolute System32 path, argv only, no shell string, and the Where-Object is a prefilter rather than the decision, which classifyProcessLine still makes. There is no lsof either, so a pid's listening ports come from netstat -ano; its state column is localized, so it is not matched on, and a row that turns out not to be listening only costs one refused probe. The guard this replaces was `win32 && !deps.execFile`, which the desktop app defeated by injecting execFile, and which its own copy of the adapter never had at all. Both copies now carry the same code, and the platform is injected so either branch can be tested from either OS.
The plugin runtime has not shipped for Windows, so `codeburn plugin list` never answers there and the section sat on "Loading plugins..." for as long as the app was open. A spinner that will never stop is worse than a sentence. Windows now gets a panel instead: the section's own glyph, one line saying plugins are coming, and one saying they arrive in a later Windows release and work today elsewhere. It loads nothing, and the check happens in a dispatcher above the loader rather than inside it, so the loader's hooks never mount and there is no CLI call, no spinner and no timer to unwind. Every other platform renders exactly what it did.
The early return that answered --quit before the Tauri builder ran also skipped the thing that delivers it. Handing a second launch's argv to the instance that is already running is the single-instance plugin's job, and the plugin only gets to do it if the builder runs, so returning first meant the running app never heard the request: the desktop app's Menu bar switch dropped the Run value and sent --quit, and the tray app carried on. It is answered in setup now, where a launch that reached that point is by definition the only instance: with nothing running it is the one being asked to go, and it exits without showing anything. With something running the plugin delivers the argv and this process never reaches setup at all. Found by driving the switch against the installed app rather than by a test: the argv parsing this rests on was unit-tested and correct, and the delivery it depends on was not something a unit test could see.
Switching the dock on made a rail appear where there had been nothing, and switching it off made one vanish. Neither reads as a thing arriving or leaving, because neither has any direction: the window is created, painted, destroyed. Now the rail comes out of the edge it is docked to. It starts tucked its whole cross width behind that edge, so nothing of it shows, and the transform is released on the frame after the first layout, which is the first moment there is an edge and a size to be tucked behind. A floating rail has no edge to hide behind, so it fades and scales from 0.92 instead. Both use dockAttach, the same 280 ms curve the drag glide already settles on. Leaving is the same in reverse, and it needs a handshake because Rust owns the window: hide asks the page to retract, the bubble goes first, the rail slides back over dockDetach, and the page then calls dock_close. Rust keeps a 400 ms timer so a page that never answers costs a fifth of a second rather than a rail that will not go away, and a generation counter means switching the dock off and straight back on cannot have the old timer take the new window. Nothing here moves a window. It is a transform on the rail alone, so every window metric stays the whole pixel it was, and prefers-reduced-motion skips both plays: the rail is simply placed, and the dismiss closes without waiting.
The dock's hover bubble is about to draw the sessions running right now and today's totals, both of which live in the menubar payload. The dock is a window with no payload of its own, and the popover's fetch_payload already runs the CLI for the same answer, so a second spawn on the dock's own timer would double the cost of every refresh for one bubble nobody may be looking at. So the slice the bubble needs is kept in AppState as each payload goes past: the liveSessions block passed through untouched, and today's totals from the one request key whose current block really is today's (today, all providers, local scope, no day pick). A week's numbers under a heading that says Today would be a lie, which is why the key is checked rather than the period alone. Only a glance that actually moved is broadcast on codeburn://glance, so a poll that found the same sessions costs the dock no render, and a payload that carries no liveSessions block never blanks one already held: absence there means the CLI is older than the block, not that nothing is running. dock_glance answers with the snapshot, or None when nothing has been fetched yet, which is the dock's cue to ask once itself.
Port of 53c5c7b onto the Windows dock. The panel was a list of quota bars; it is now a glance, in the mac's blocks and in its order: a provider header, the sessions running right now as pills with their context gauges, a Today strip, and a row of quota-window percentages, separated by hairlines. The connection states, the Connect button and the footer lines the CLI can still answer honestly stay where they were. A percentage is its own gauge now rather than a bar beside a label: dim glyphs with the value's severity colour filling them left to right and wiped off on a slant. The mac solves that edge in points against a measured text box, so the run gets a box of its own here and the clip is written as a share of its width plus the slant in pixels; a run centred in a wider column would otherwise be wiped from the column's edge and read as fully coloured at half its value. The glance ramps at 70, 80 and 90 rather than the rail's 50, 75, 90, so a healthy row reads as text rather than as a wall of colour. Every block carries its own padding and an explicit content height, all whole pixels at any detail scale, because the window Rust sizes around the card is measured from what the page drew. Hairlines are overlays for the same reason: a rule must not add a pixel the measurement did not reserve. Whichever block ends the card carries the closing inset, which is the rule the mac's own windowsHeight bakes in. Three deliberate differences from the Swift, all noted where they are: the connection lines take the same side inset as every other block rather than sitting flush to the bubble edge; a disconnected provider keeps the sign-in guidance the existing bubble had, which the mac's glance drops; and the footer stays, since the mac reads freshness off its own adapters and `codeburn quota --format json` does not carry it.
…lers The single-instance plugin hands a second launch over with a synchronous SendMessage, so that launch sits inside it until the running app's callback returns. When the message landed while the first launch was still in setup, the callback showed the dock from inside a nested message pump, waited on an event loop that was not running yet, and never returned; every launch after it, the desktop app's --quit included, blocked behind the same message and piled up as processes with no windows. The callback now only records the request and carries it out from the event loop. The desktop companion also waits, briefly, for a quit it has just sent to finish before starting the tray app again, because a launch that arrives while the old process is still going hands its request to that process and exits with it.
Making the CLI suite honest on Windows turned up product bugs that POSIX never reaches: - plugins: a bare `tar` resolves to Git for Windows' GNU tar, which reads a drive letter as a remote host and fails on every absolute path. The installer now prefers the bsdtar Windows ships in System32. - antigravity statusline: the hook command was written with POSIX backslash escapes, which cmd.exe does not understand, so the installed path was broken for real Windows users. - act undo: renaming a directory over an existing one raises EPERM on Windows, not ENOTEMPTY, so the snapshotted-destination retry never ran. - parser: a worktree's main repository path was sliced from the slash-normalized copy of the gitdir, so it never matched session cwds. - dashboard: home-relative project labels compared a slash-normalized path against an unnormalized homedir(), so no Windows path shortened. - guard flags: a POSIX-shaped flag path from provider data never matched a subdirectory because the boundary check used the host separator only. - claude desktop sessions: the discovery memo omitted homedir() from its key and served the previous home's paths once home moved. - copilot: a JetBrains file:///C:/... URL kept its URL slash ahead of the drive letter, so the project's .git walk never resolved. - hermes: a state.db written on another platform records that platform's path shape, so both absolute forms now count on every host.
os.homedir() reads USERPROFILE on Windows and never HOME, so the isolation setup and every test that moved home were still resolving the developer's real home directory. Fixtures came back empty, real sessions leaked into counts, and the code under test wrote plans, receipts and a sync ledger into the real ~/.config/codeburn during a run. USERPROFILE now joins the redirected variables and a shared setHome() helper moves HOME and USERPROFILE together; every spawn env carries both. The remaining Windows-only failures were POSIX-shaped fixtures: chmod tricks that only toggle a read-only attribute on NTFS, pid 1 as an always-alive lock holder, splitting paths on '/', bare tar, mode-bit expectations, and per-file timeouts too short for the cost of a tsx spawn. Each gets a platform-aware equivalent so nothing is skipped on Windows and the POSIX branches stay byte-for-byte what they were. Full run on Windows: 267 files passed, 2 skipped, 3592 tests passed.
…latforms The DPAPI calls took `&mut input` for a blob they only read, the dock's payload command has one argument more than clippy's default limit, and the Linux leg compiles the Windows-only dock, session and tray-status code without calling it, which tripped 25 dead-code lints under `-D warnings`. Dead-code lints now apply on Windows only, where every function is reachable.
readSecureFile rejects a login file with group or world bits on POSIX, which is right for a real login. The Grok and Z.ai fixtures were written with default permissions, so on Linux the product refused them and six assertions saw transientFailure or disconnected. Windows has no mode bits to check, which is why the branch only ever passed there.
The NSIS and Store workflows stage the Tauri tray into the desktop package, but nothing in those workflows built it, so staging failed with "no source directory" on every run. Both now install the Rust toolchain and build the tray first: the installer needs the MSI bundle, the Store package only the exe.
Tauri names the bundle after productName, so `tauri build` leaves `CodeBurn Menubar_<version>_x64_en-US.msi` with a space. GitHub rewrites that space to a dot on release upload, which is the form the CLI installer, the Electron side and the tray updater all match. The staging script only knew the dot form, so the installer workflow built the tray and then failed with "no CodeBurn.Menubar_<version>_x64_en-US.msi". It now accepts both and stages the dot form, so a local build and a downloaded release produce the same shape.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #1226 (branched off
fix/windows-tray-quit-single-instance, whose single-instance and quit fixes this edits) and inclusive of #1227's branch:origin/feat/cli-quota-commandis merged in here so the dock and thecodeburn quotacommand it consumes land together. Review the second commit for the dock itself; the merge commit carries onlysrc/quota/,src/main.ts,README.mdandtests/, with no overlap underwindows/.What v1 does
dock: frameless, transparent, always on top, skip-taskbar, non-resizable,focused(false), docked flush to the right screen edge and vertically centred. Rounded corners are drawn by the page, not by Windows chrome.available: true. Providers reportingavailable: falseare hidden.dock_quotacommand spawningcodeburn quota --format jsonthrough the existingcli.rsplumbing (absolute program resolution,CREATE_NO_WINDOW, drained pipes, timeout). It refreshes every 5 minutes, and on demand by double-clicking the rail.quotasubcommand exits withunknown command, which the dock surfaces as a quiet "CLI update needed" line rather than an error storm.~/.config/codeburn/windows-dock.json. The dock is hidden by default.Design decisions
mouseenter/mouseleaveon one root element. The rail keeps its right edge flush and grows leftwards, so it never moves under the pointer.layout.activityBarWidthintokens.json), 50px rows, 7px gaps, 12px pads, a 32px ring. Every dimension is an integer, because fractional sizes are what made the mac hosting view re-lay itself out forever.windows/; it lives inapp/rendererand keys off model series, not providers. So provider colours come fromwindows/tokens.jsoncolor.categoricalfor claude, codex and copilot, and from the macOS Capacity Dock's own ring colours for gemini, kimi and antigravity. No colour is invented here. Everything else reuses existing tokens (--surface,--label*,--fill-12,--separator,--danger,--radius-bar,--menu-shadow,--ease-ui), so the dock follows the app's light and dark themes. Orange stays the brand accent; the one ring that carries it is Claude's, which is the valuetokens.jsonalready assigns Claude.main.tsxbranches on the window label, which avoids a multi-page Vite config and a second HTML entry.Deferred
Testing in a Windows VM
cd windows/src-tauri && cargo buildtarget/debug/codeburn-menubar.exe).quotasubcommand on PATH, the rail shows the quiet update-needed state instead of erroring.Verification on macOS host
cargo checkinwindows/src-tauri: passes, no warnings.npm run buildinwindows/(tsc --noEmit && vite build): passes.npx tsc --noEmitat repo root: passes.npm test: 3514 passed, 5 skipped across 261 files, including the 14 quota-provider tests the merge brought in.The Windows build itself has not been run; only the host-compilable path is verified here.
Where the branch stands (2026-09-02)
Everything under "Deferred" above has since landed on this branch, along with the parity work the Mac side asked for. The audit is in
C:\dev\PARITY.mdon the Windows VM and the per-package notes inC:\dev\NOTES.md.--quit) stuck as a windowless process; the single-instance callback now defers to the event loop, and the desktop app waits for a quit to finish before relaunching.Verified on the Windows 11 ARM64 VM with the installed release builds: fresh install from the desktop setup, both switches, dock drag and snap on all four edges, the glance bubble against the real CLI, and the launch burst that reproduced the deadlock.
Checks on the VM at 9e2f140: desktop app typecheck clean and 766 tests passing; tray frontend typecheck clean;
cargo test --lib63 passing; CLInpm testfails the same 60 files on this Windows VM as an origin/main worktree does (read-only chmod, sync paths, dashboard timeouts), so the branch adds no CLI failures. One clock flake in tests/menubar-report-parity.test.ts passes alone on both trees.Known gaps: multi-monitor and mixed DPI unverified on the one-display VM; the NSIS uninstall macro and the Store route have not been run end to end; the Mac side's fix/windows-menubar-exe-name branch touches the same installer lines and needs reconciling on merge.