mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
windows: import the Tauri tray app as the Windows menubar
Brings the Tauri 2.x tray popover from #1022 onto main as windows/, mirroring mac/. Product name, bundle identifier, and version line up with the macOS menubar (org.agentseal.codeburn-menubar, 0.9.20); the crate is renamed off "desktop" so it no longer collides with the Electron app in app/. Linux (ksni) stays compiled and dev-usable but is documented as experimental: gnome/ is the shipping Linux surface. The five src/ CLI commits on that branch are dropped - they re-implement a daily-bucketing fix main already carries.
This commit is contained in:
parent
a0ada25104
commit
b199af182e
68 changed files with 14791 additions and 0 deletions
10
windows/.gitignore
vendored
Normal file
10
windows/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
# dist/ is a Vite build output; we gitignore its contents but keep a tracked placeholder
|
||||
# index.html so Tauri's compile-time `frontendDist` validation passes on fresh clones before
|
||||
# anyone has run `npm run build`. Real build output replaces the placeholder locally.
|
||||
dist/*
|
||||
!dist/index.html
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
.DS_Store
|
||||
*.log
|
||||
169
windows/DEVELOPMENT.md
Normal file
169
windows/DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# CodeBurn Menubar (Windows)
|
||||
|
||||
Tauri 2.x tray app that surfaces CodeBurn in the Windows notification area. It is the Windows
|
||||
mirror of the native macOS menubar in `../mac/`, which stays the authoritative look and feel;
|
||||
this project mirrors its layout, colors, and data via the shared `tokens.json`.
|
||||
|
||||
Linux (ksni / AppIndicator) support is compiled and kept working for dev, but it is
|
||||
**experimental and unreleased** - Linux users should use the GNOME extension in `../gnome/`.
|
||||
The releases this repo cuts from here are Windows only.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
windows/
|
||||
├── src/ React + TypeScript popover UI (runs inside the Tauri webview)
|
||||
├── src-tauri/
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs binary entry
|
||||
│ │ ├── lib.rs tray, window lifecycle, state wiring
|
||||
│ │ ├── cli.rs argv-validated spawn of the codeburn CLI
|
||||
│ │ ├── config.rs ~/.config/codeburn/config.json read/write under a lock
|
||||
│ │ ├── plan.rs Claude OAuth quota (port of mac/.../ClaudeSubscriptionService.swift)
|
||||
│ │ └── fx.rs Frankfurter fetch + 24h disk cache + [0.0001, 1e6] clamp
|
||||
│ ├── capabilities/ Tauri v2 permission manifests
|
||||
│ └── icons/ tray + bundle icons
|
||||
└── tokens.json shared design tokens (also consumed by mac/ at build time)
|
||||
```
|
||||
|
||||
## Prerequisites (Windows)
|
||||
|
||||
```powershell
|
||||
# Rust
|
||||
winget install Rustlang.Rustup
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
|
||||
# WebView2 Runtime
|
||||
winget install Microsoft.EdgeWebView2Runtime
|
||||
|
||||
# Microsoft C++ Build Tools (ships with Visual Studio Installer; pick "Desktop development with C++")
|
||||
```
|
||||
|
||||
## Prerequisites (macOS / Linux, dev only)
|
||||
|
||||
Tauri builds on macOS and Linux for inner-loop UI iteration. The shipping macOS product is the
|
||||
Swift app in `../mac/`, so we don't cut a Tauri Mac release.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install rust node
|
||||
|
||||
# Ubuntu / Debian
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential curl wget file \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev
|
||||
```
|
||||
|
||||
## Run the dev server
|
||||
|
||||
```bash
|
||||
cd windows
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Under the hood this starts Vite on `localhost:1420`, builds `src-tauri/target/debug/codeburn-menubar`,
|
||||
and opens a window wired to the dev server with hot reload for the React code. The tray icon
|
||||
appears at the same time.
|
||||
|
||||
If the codeburn CLI isn't on PATH (dev builds from this monorepo), point the app at your local build:
|
||||
|
||||
```bash
|
||||
npm --prefix .. run build
|
||||
CODEBURN_BIN="node $(pwd)/../dist/cli.js" npm run tauri dev
|
||||
```
|
||||
|
||||
`CODEBURN_BIN` is validated against a strict allowlist (alphanumerics plus `._/-` and space;
|
||||
`\ : ( )` also allowed on Windows) before use; anything else falls back to auto-resolution.
|
||||
|
||||
Without `CODEBURN_BIN` the app looks for `codeburn` (`codeburn.cmd` / `codeburn.exe` on Windows)
|
||||
on the inherited `PATH`, then in the usual npm and node prefixes (`%APPDATA%\npm`,
|
||||
`%LOCALAPPDATA%\Programs\nodejs`, pnpm, Volta, scoop, `/opt/homebrew/bin`, `~/.npm-global/bin`),
|
||||
and finally on Windows in the live user and machine `PATH` read from the registry, so a CLI
|
||||
installed after the tray app was launched is still found. Only absolute directory entries are
|
||||
considered - empty or relative `PATH` entries are skipped so nothing is ever resolved out of the
|
||||
current working directory.
|
||||
|
||||
If nothing is found, or `codeburn --version` is older than `MIN_CLI_VERSION`
|
||||
(`src-tauri/src/cli.rs`), the popover shows a setup screen with the install command and a
|
||||
"Check again" button. That gate is probed once on mount, before the first payload fetch.
|
||||
|
||||
`MIN_CLI_VERSION` is **0.9.9**: the first release whose `codeburn status --format menubar-json`
|
||||
accepts `--no-optimize`, which the app's quiet background refreshes always pass. Every payload
|
||||
field the popover reads (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`)
|
||||
also exists at that version.
|
||||
|
||||
## Refresh policy
|
||||
|
||||
Mirrors `mac/Sources/CodeBurnMenubar/RefreshCadence.swift`: each CLI fetch is a full Node
|
||||
process, so the cadence follows popover visibility.
|
||||
|
||||
- popover visible: 60 s tick, full fetch (optimize findings included)
|
||||
- popover hidden: 120 s tick, `today`/`all` only, `--no-optimize`
|
||||
- on show: immediate refresh when the visible key is older than 60 s
|
||||
|
||||
## Plan / quota
|
||||
|
||||
The Plan pill (visible on the Claude tab, or when Claude is the only detected provider) reads
|
||||
Claude Code's OAuth credentials from `~/.claude/.credentials.json`, calls
|
||||
`https://api.anthropic.com/api/oauth/usage`, refreshes the token once on 401, and stores one
|
||||
snapshot per window under `~/.cache/codeburn/subscription-snapshots.json` (`CODEBURN_CACHE_DIR`
|
||||
override) so a freshly reset window can still show last cycle's final. This is the same file
|
||||
format the macOS app writes. Nothing is logged: the credential blob never leaves the Rust side.
|
||||
|
||||
## Build a production package
|
||||
|
||||
```bash
|
||||
# Windows (.msi + NSIS .exe): run from a Windows host
|
||||
npm run tauri build
|
||||
|
||||
# Linux (experimental): produces .deb, .rpm, .AppImage under src-tauri/target/release/bundle/
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- **Process spawn**: every call into the codeburn CLI goes through `CodeburnCli::fetch_menubar_payload`,
|
||||
which builds argv explicitly and runs the binary directly (no `sh -c`). `CODEBURN_BIN` is
|
||||
allowlisted before use. Windows system tools (`reg.exe`, `cmd.exe`) are invoked by absolute
|
||||
path under `%SystemRoot%\System32` so `CreateProcess`'s current-directory search can never
|
||||
pick up a planted binary; `claude` is resolved from absolute `PATH` directories the same way.
|
||||
- **Pipes**: stdout is capped at 20 MB, stderr at 256 KB, total wall time at 60 s. A hung CLI
|
||||
cannot pin file descriptors or memory.
|
||||
- **Config writes**: `~/.config/codeburn/config.json` writes run under a POSIX `flock` on
|
||||
`~/.config/codeburn/.config.lock`. On Windows the same path uses a create-new lock file. Note
|
||||
that this lock is advisory *between instances of this app only* - the codeburn CLI does not
|
||||
take it - so it narrows, but does not eliminate, a concurrent-write race. A lock left behind
|
||||
by a crash is never deleted by another process; it expires after 30 s of inactivity and the
|
||||
next writer retries.
|
||||
- **Snapshot writes**: `subscription-snapshots.json` refuses a symlinked target and is written
|
||||
0600 on unix, mirroring `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`.
|
||||
- **Credentials**: the Plan view reads `~/.claude/.credentials.json` with a 64 KB cap and refuses
|
||||
symlinks; tokens are only ever sent to the Anthropic usage and token endpoints over TLS.
|
||||
- **FX fetches**: Frankfurter response is parsed as JSON and the rate is clamped to
|
||||
`[0.0001, 1_000_000]` before it touches displayed numbers. Stale cache preferred over poisoned
|
||||
fresh data.
|
||||
- **CSP**: `connect-src` restricted to `self`, `ipc:`, and `https://api.frankfurter.app`. No
|
||||
inline scripts.
|
||||
|
||||
## CI and release tags
|
||||
|
||||
- `.github/workflows/windows-menubar-ci.yml` runs on any `windows/**` change: `tsc --noEmit`,
|
||||
`cargo clippy -D warnings` and `cargo test` on windows-latest + ubuntu-latest, plus a release
|
||||
build smoke on Windows.
|
||||
- `windows-v*` tag (e.g. `windows-v0.9.20`) triggers
|
||||
`.github/workflows/release-menubar-windows.yml`; publishes the `.msi` and NSIS `.exe` to a
|
||||
"Windows Menubar vX" release. Unsigned for now, so Windows SmartScreen prompts on first run
|
||||
until a signing cert is in place.
|
||||
|
||||
## Pending work
|
||||
|
||||
1. Code signing for the Windows `.msi` to remove the SmartScreen warning.
|
||||
2. Linux: decide whether to ship at all (the GNOME extension in `../gnome/` covers that
|
||||
surface today) or promote the ksni tray out of experimental.
|
||||
38
windows/Scripts/autoinstall/README.md
Normal file
38
windows/Scripts/autoinstall/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Unattended Ubuntu install for the CodeBurn dev VM
|
||||
|
||||
This directory contains a cloud-init `user-data` + `meta-data` pair that tells the Ubuntu 24.04 Server installer to configure itself without any user prompts. After it finishes, you reboot into GNOME and run the one-line provisioner.
|
||||
|
||||
Default credentials in `user-data`: **`codeburn` / `codeburn`**. Change them before using anywhere that matters.
|
||||
|
||||
## Build the CIDATA ISO (on your Mac)
|
||||
|
||||
```bash
|
||||
cd windows/Scripts/autoinstall
|
||||
hdiutil makehybrid -o codeburn-cidata.iso \
|
||||
-hfs -joliet -iso -default-volume-name CIDATA .
|
||||
```
|
||||
|
||||
That produces `codeburn-cidata.iso` (around 2 KB) with the two YAML files at the root, labelled `CIDATA`.
|
||||
|
||||
## Hook it into UTM
|
||||
|
||||
1. Create the VM as usual (Virtualize → Linux → Ubuntu Server arm64 ISO).
|
||||
2. Before first boot, open the VM's Settings → **Drives** → **New Drive** → pick **Removable** → **Import**, and select `codeburn-cidata.iso`.
|
||||
3. Boot. The Ubuntu installer auto-detects the CIDATA volume, reads the autoinstall config, and runs the install without prompts. Takes 15-20 minutes depending on disk speed.
|
||||
4. Reboot into the installed system, log in as `codeburn`, then:
|
||||
|
||||
```bash
|
||||
bash ~/provision.sh
|
||||
```
|
||||
|
||||
(The autoinstall drops the script to `~/provision.sh`. It installs Rust + Node + the codeburn CLI, clones the repo, and sets up the windows/ npm deps.)
|
||||
|
||||
5. `cd ~/codeburn/windows && npm run tauri dev`.
|
||||
|
||||
## Why not automate the provisioner run too
|
||||
|
||||
cloud-init's `late-commands` runs in the installer environment, which doesn't have a GNOME session for the tray icon to land in. We deliberately stop short of running `npm run tauri dev` from within autoinstall so the tray shows up on your first real login instead of a detached systemd unit.
|
||||
|
||||
## Skipping autoinstall
|
||||
|
||||
If you'd rather click through the Ubuntu installer normally, ignore this directory entirely. The `provision-linux.sh` script in the parent directory works the same way whether the OS was installed unattended or by hand.
|
||||
2
windows/Scripts/autoinstall/meta-data
Normal file
2
windows/Scripts/autoinstall/meta-data
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
instance-id: codeburn-linux-01
|
||||
local-hostname: codeburn-linux
|
||||
57
windows/Scripts/autoinstall/user-data
Normal file
57
windows/Scripts/autoinstall/user-data
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#cloud-config
|
||||
# Ubuntu 24.04 LTS Server autoinstall configuration for a CodeBurn desktop dev VM. Mount this
|
||||
# file as a second virtual disk (CIDATA volume) alongside the Ubuntu Server ISO in UTM and
|
||||
# the installer runs unattended end to end. Default login: codeburn / codeburn. Change the
|
||||
# identity block before running in any environment that matters.
|
||||
autoinstall:
|
||||
version: 1
|
||||
|
||||
# Accept the EULA-style prompts without user input.
|
||||
refresh-installer:
|
||||
update: yes
|
||||
|
||||
locale: en_US.UTF-8
|
||||
keyboard:
|
||||
layout: us
|
||||
|
||||
# Wire up a default user. Password is `codeburn`; hash generated with `openssl passwd -6`.
|
||||
# Regenerate the hash if you care about the credentials outside of a throwaway VM.
|
||||
identity:
|
||||
hostname: codeburn-linux
|
||||
username: codeburn
|
||||
password: "$6$rounds=4096$JrKVZcJ2$F93p8IWyTlZR5p1Trmno/qCnhYI1BnbUUYdf6HsiD.XW4T0I3JtvzH40nWNy9Z1CcJ2X5C6RuzK0bj9WM3x/n."
|
||||
|
||||
ssh:
|
||||
install-server: yes
|
||||
allow-pw: yes
|
||||
|
||||
# Install GNOME + the build dependencies Tauri needs so the first login is already ready
|
||||
# to run `npm run tauri dev` without another apt round trip.
|
||||
packages:
|
||||
- ubuntu-desktop-minimal
|
||||
- build-essential
|
||||
- curl
|
||||
- wget
|
||||
- file
|
||||
- git
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libayatana-appindicator3-dev
|
||||
- librsvg2-dev
|
||||
- libssl-dev
|
||||
- libxdo-dev
|
||||
- libgtk-3-dev
|
||||
- pkg-config
|
||||
|
||||
# Run the provisioner as the new user on first boot. It installs Node + Rust, pulls the
|
||||
# repo, and runs `npm install` for the desktop app. After the script finishes, logging in
|
||||
# to GNOME and running `cd ~/codeburn/windows && npm run tauri dev` brings the tray up.
|
||||
late-commands:
|
||||
- curtin in-target --target=/target -- bash -lc '
|
||||
sudo -iu codeburn bash -lc "
|
||||
curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh \
|
||||
-o /home/codeburn/provision.sh
|
||||
chmod +x /home/codeburn/provision.sh
|
||||
"
|
||||
'
|
||||
|
||||
shutdown: reboot
|
||||
89
windows/Scripts/provision-linux.sh
Executable file
89
windows/Scripts/provision-linux.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-shot Ubuntu provisioning for the CodeBurn desktop (Tauri) dev environment.
|
||||
#
|
||||
# Usage inside a fresh Ubuntu 24.04 LTS Server VM (after `sudo apt install
|
||||
# ubuntu-desktop-minimal && sudo reboot`, and logging into GNOME):
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh | bash
|
||||
#
|
||||
# Or if you cloned the repo manually: `bash windows/Scripts/provision-linux.sh`.
|
||||
#
|
||||
# Installs: build toolchain, webkit + appindicator headers, Node 20 LTS, Rust stable,
|
||||
# the codeburn npm CLI, and this repo. Leaves you one command away from `npm run tauri dev`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/getagentseal/codeburn.git"
|
||||
BRANCH="feat/tauri-menubar-win-linux"
|
||||
CHECKOUT="${HOME}/codeburn"
|
||||
|
||||
log() { printf '\033[1;34m▸\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 1. Platform sanity
|
||||
[[ "$(uname -s)" == "Linux" ]] || fail "Run me on Linux (detected: $(uname -s))."
|
||||
if ! command -v apt-get >/dev/null; then
|
||||
fail "Only apt-based distros supported by this provisioner (Ubuntu, Debian)."
|
||||
fi
|
||||
|
||||
log "apt update + system build deps"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y \
|
||||
build-essential curl wget file git \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev \
|
||||
pkg-config
|
||||
|
||||
# 2. Node 20 LTS via NodeSource if the distro version is too old. Tauri CLI needs >= 18.
|
||||
if ! command -v node >/dev/null || [[ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt 18 ]]; then
|
||||
log "installing Node 20 LTS"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# 3. Rust via rustup if not present
|
||||
if ! command -v cargo >/dev/null; then
|
||||
log "installing Rust via rustup"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
# 4. codeburn CLI (the Tauri app shells out to this for data)
|
||||
if ! command -v codeburn >/dev/null; then
|
||||
log "installing codeburn CLI from npm"
|
||||
sudo npm install -g codeburn
|
||||
fi
|
||||
|
||||
# 5. Repo
|
||||
if [[ -d "${CHECKOUT}/.git" ]]; then
|
||||
log "updating existing checkout at ${CHECKOUT}"
|
||||
git -C "${CHECKOUT}" fetch origin
|
||||
git -C "${CHECKOUT}" checkout "${BRANCH}"
|
||||
git -C "${CHECKOUT}" pull --ff-only origin "${BRANCH}"
|
||||
else
|
||||
log "cloning ${REPO_URL} into ${CHECKOUT}"
|
||||
git clone --branch "${BRANCH}" "${REPO_URL}" "${CHECKOUT}"
|
||||
fi
|
||||
|
||||
# 6. npm deps for the desktop app
|
||||
log "npm install for windows/"
|
||||
(cd "${CHECKOUT}/windows" && npm install --no-audit --no-fund)
|
||||
|
||||
# 7. Summary + next step
|
||||
cat <<EOF
|
||||
|
||||
\033[1;32m✓\033[0m Provisioning complete.
|
||||
|
||||
Next:
|
||||
|
||||
cd ${CHECKOUT}/windows
|
||||
npm run tauri dev
|
||||
|
||||
A flame tray icon should appear in your panel. Click it for the popover. Hot reload is
|
||||
wired for the React code; Rust changes need a rebuild.
|
||||
EOF
|
||||
4
windows/dist/index.html
vendored
Normal file
4
windows/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!doctype html><html><head><meta charset="utf-8"><title>CodeBurn</title></head><body>
|
||||
<noscript>This app requires JavaScript.</noscript>
|
||||
<p>Run <code>npm install && npm run tauri dev</code> from <code>windows/</code>.</p>
|
||||
</body></html>
|
||||
12
windows/index.html
Normal file
12
windows/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeBurn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2069
windows/package-lock.json
generated
Normal file
2069
windows/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
windows/package.json
Normal file
28
windows/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "codeburn-menubar",
|
||||
"private": true,
|
||||
"version": "0.9.20",
|
||||
"description": "CodeBurn menubar (tray) app for Windows, with experimental Linux support. Shares design tokens with the native macOS app under mac/.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
6066
windows/src-tauri/Cargo.lock
generated
Normal file
6066
windows/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
windows/src-tauri/Cargo.toml
Normal file
40
windows/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "codeburn-menubar"
|
||||
version = "0.9.20"
|
||||
description = "CodeBurn menubar (tray) app for Windows and Linux"
|
||||
authors = ["AgentSeal"]
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
|
||||
[lib]
|
||||
name = "codeburn_menubar_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "rt-multi-thread", "macros", "time", "sync"] }
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
dirs = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
fontdue = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ksni = "0.3"
|
||||
png = "0.17"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
# This feature is used for production builds or when a dev server is not specified. Don't
|
||||
# change it unless you know what you're doing.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
windows/src-tauri/build.rs
Normal file
3
windows/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
20
windows/src-tauri/capabilities/default.json
Normal file
20
windows/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the CodeBurn tray app window",
|
||||
"windows": ["popover"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:tray:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit",
|
||||
"opener:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
BIN
windows/src-tauri/icons/128x128.png
Normal file
BIN
windows/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
windows/src-tauri/icons/128x128@2x.png
Normal file
BIN
windows/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
windows/src-tauri/icons/32x32.png
Normal file
BIN
windows/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
windows/src-tauri/icons/icon.ico
Normal file
BIN
windows/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
windows/src-tauri/icons/icon.png
Normal file
BIN
windows/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
windows/src-tauri/icons/tray.png
Normal file
BIN
windows/src-tauri/icons/tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
88
windows/src-tauri/src/autostart.rs
Normal file
88
windows/src-tauri/src/autostart.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
//! Launch at login. Windows: a value under HKCU\...\CurrentVersion\Run pointing at this
|
||||
//! executable. Linux: an XDG autostart .desktop file. No extra crates; both are a few
|
||||
//! lines of `reg` / plain file IO.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
const APP_NAME: &str = "CodeBurn";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reg(args: &[&str]) -> Result<std::process::Output> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
std::process::Command::new("reg")
|
||||
.args(args)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.with_context(|| "failed to run reg.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn is_enabled() -> bool {
|
||||
reg(&["query", RUN_KEY, "/v", APP_NAME])
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn set_enabled(enabled: bool) -> Result<()> {
|
||||
if enabled {
|
||||
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
|
||||
let value = format!("\"{}\"", exe.display());
|
||||
let out = reg(&["add", RUN_KEY, "/v", APP_NAME, "/t", "REG_SZ", "/d", &value, "/f"])?;
|
||||
if !out.status.success() {
|
||||
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
} else {
|
||||
let out = reg(&["delete", RUN_KEY, "/v", APP_NAME, "/f"])?;
|
||||
// Deleting a value that does not exist is the state we want anyway.
|
||||
if !out.status.success() && is_enabled() {
|
||||
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_file() -> Option<std::path::PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("autostart").join("codeburn-menubar.desktop"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_enabled() -> bool {
|
||||
desktop_file().map(|p| p.is_file()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_enabled(enabled: bool) -> Result<()> {
|
||||
let path = desktop_file().ok_or_else(|| anyhow!("no config dir"))?;
|
||||
if enabled {
|
||||
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Desktop Entry]\nType=Application\nName={APP_NAME}\nExec=\"{}\"\nX-GNOME-Autostart-enabled=true\n",
|
||||
exe.display()
|
||||
),
|
||||
)?;
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn is_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set_enabled(_enabled: bool) -> Result<()> {
|
||||
Err(anyhow!("launch at login is handled by the native macOS app"))
|
||||
}
|
||||
499
windows/src-tauri/src/cli.rs
Normal file
499
windows/src-tauri/src/cli.rs
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::AppHandle;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
/// Hard bounds mirror the macOS CodeburnCLI / DataClient design. A malicious or stuck CLI
|
||||
/// cannot pin the Tauri process: stdout is capped, stderr is bounded, total wall time is
|
||||
/// 60s. A hostile CODEBURN_BIN is rejected before any shell-resembling path is taken.
|
||||
const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
|
||||
const MAX_STDERR_BYTES: usize = 256 * 1024;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 60;
|
||||
const VERSION_TIMEOUT_SECS: u64 = 20;
|
||||
|
||||
/// Oldest CLI that emits the `menubar-json` shape this app renders (history.daily with
|
||||
/// per-day model breakdown, providers map). Older CLIs get the setup screen instead of a
|
||||
/// half-rendered popover.
|
||||
pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 7, 0);
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_CLI_NAMES: [&str; 2] = ["codeburn.cmd", "codeburn.exe"];
|
||||
|
||||
/// Alphanumerics plus `._/-` and space, with `\`, `:`, `(`, `)` also allowed on Windows
|
||||
/// so a user-supplied `CODEBURN_BIN` path like `C:\Users\...\codeburn.cmd` is accepted.
|
||||
/// None of these are shell metacharacters in a direct-argv spawn (we never invoke `sh -c`).
|
||||
fn is_safe_arg(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '_' | '/' | '-' | ' ')
|
||||
|| (cfg!(windows) && matches!(c, '\\' | ':' | '(' | ')'))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CodeburnCli {
|
||||
program: String,
|
||||
extra_args: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the setup screen needs to know about the CLI on this machine.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CliStatus {
|
||||
pub found: bool,
|
||||
pub program: String,
|
||||
pub version: Option<String>,
|
||||
pub min_version: String,
|
||||
pub compatible: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CodeburnCli {
|
||||
/// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the
|
||||
/// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations.
|
||||
pub fn resolve() -> Self {
|
||||
let raw = env::var("CODEBURN_BIN").unwrap_or_default();
|
||||
if raw.is_empty() {
|
||||
return Self::default_program();
|
||||
}
|
||||
// A bare path (which may contain spaces, e.g. under Program Files) is used whole;
|
||||
// only otherwise is the value split into program + leading arguments.
|
||||
if is_safe_arg(&raw) && std::path::Path::new(&raw).is_file() {
|
||||
return CodeburnCli {
|
||||
program: raw,
|
||||
extra_args: vec![],
|
||||
};
|
||||
}
|
||||
let parts: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
||||
if parts.iter().all(|p| is_safe_arg(p)) {
|
||||
if let Some((first, rest)) = parts.split_first() {
|
||||
return CodeburnCli {
|
||||
program: first.clone(),
|
||||
extra_args: rest.to_vec(),
|
||||
};
|
||||
}
|
||||
}
|
||||
eprintln!("codeburn-menubar: refusing unsafe CODEBURN_BIN; falling back to `codeburn`");
|
||||
Self::default_program()
|
||||
}
|
||||
|
||||
fn default_program() -> Self {
|
||||
CodeburnCli {
|
||||
program: locate_cli().unwrap_or_else(default_program_name),
|
||||
extra_args: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn program(&self) -> &str {
|
||||
&self.program
|
||||
}
|
||||
|
||||
/// Runs `codeburn --version` and reports whether the CLI is present and new enough.
|
||||
pub async fn status(&self) -> CliStatus {
|
||||
let min_version = format!(
|
||||
"{}.{}.{}",
|
||||
MIN_CLI_VERSION.0, MIN_CLI_VERSION.1, MIN_CLI_VERSION.2
|
||||
);
|
||||
let mut status = CliStatus {
|
||||
found: false,
|
||||
program: self.program.clone(),
|
||||
version: None,
|
||||
min_version,
|
||||
compatible: false,
|
||||
error: None,
|
||||
};
|
||||
match self.run_capture(&["--version"], VERSION_TIMEOUT_SECS).await {
|
||||
Ok(out) => {
|
||||
let version = out.trim().to_string();
|
||||
status.found = true;
|
||||
status.compatible = parse_version(&version)
|
||||
.map(|v| v >= MIN_CLI_VERSION)
|
||||
.unwrap_or(false);
|
||||
status.version = Some(version);
|
||||
}
|
||||
Err(err) => {
|
||||
status.error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Spawns `codeburn status --format menubar-json --period X --provider Y` and decodes the
|
||||
/// output. Pipes are drained concurrently so a chatty stderr cannot deadlock stdout.
|
||||
pub async fn fetch_menubar_payload(
|
||||
&self,
|
||||
period: &str,
|
||||
provider: &str,
|
||||
include_optimize: bool,
|
||||
) -> Result<Value> {
|
||||
if !is_safe_arg(period) || !is_safe_arg(provider) {
|
||||
bail!("invalid period/provider argument");
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"status",
|
||||
"--format",
|
||||
"menubar-json",
|
||||
"--period",
|
||||
period,
|
||||
"--provider",
|
||||
provider,
|
||||
];
|
||||
if !include_optimize {
|
||||
args.push("--no-optimize");
|
||||
}
|
||||
|
||||
let stdout = self.run_capture(&args, FETCH_TIMEOUT_SECS).await?;
|
||||
let payload: Value =
|
||||
serde_json::from_str(&stdout).with_context(|| "CLI returned invalid JSON")?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result<String> {
|
||||
let mut full_args = self.extra_args.clone();
|
||||
full_args.extend(args.iter().map(|s| s.to_string()));
|
||||
|
||||
let mut cmd = Command::new(&self.program);
|
||||
cmd.args(&full_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|err| {
|
||||
anyhow!(
|
||||
"CodeBurn CLI not found ({}). Install it with `npm install -g codeburn`.",
|
||||
spawn_error_summary(&self.program, &err)
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
|
||||
let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
|
||||
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::with_capacity(64 * 1024);
|
||||
let mut limited = (&mut stdout).take(MAX_PAYLOAD_BYTES as u64);
|
||||
limited.read_to_end(&mut buf).await.ok();
|
||||
buf
|
||||
});
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::with_capacity(4 * 1024);
|
||||
let mut limited = (&mut stderr).take(MAX_STDERR_BYTES as u64);
|
||||
limited.read_to_end(&mut buf).await.ok();
|
||||
buf
|
||||
});
|
||||
|
||||
let status = timeout(Duration::from_secs(timeout_secs), child.wait())
|
||||
.await
|
||||
.map_err(|_| anyhow!("codeburn CLI timed out after {}s", timeout_secs))??;
|
||||
|
||||
let stdout_bytes = stdout_task.await.unwrap_or_default();
|
||||
let stderr_bytes = stderr_task.await.unwrap_or_default();
|
||||
|
||||
if !status.success() {
|
||||
let msg = String::from_utf8_lossy(&stderr_bytes);
|
||||
bail!("codeburn CLI exited {}: {}", status, msg.trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&stdout_bytes).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_error_summary(program: &str, err: &std::io::Error) -> String {
|
||||
match err.kind() {
|
||||
std::io::ErrorKind::NotFound => format!("{} is not on PATH", program),
|
||||
_ => format!("{}: {}", program, err),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_program_name() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"codeburn.cmd".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"codeburn".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses "0.7.3" or "codeburn 0.7.3" into a comparable tuple.
|
||||
pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
let token = text
|
||||
.split_whitespace()
|
||||
.find(|t| t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false))?;
|
||||
let mut parts = token.split('.').map(|p| {
|
||||
p.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
});
|
||||
Some((parts.next()??, parts.next()??, parts.next().flatten().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Locates the CLI without relying on the inherited PATH being fresh. A tray app is often
|
||||
/// launched from Explorer or at login, before (or long after) `npm install -g codeburn`
|
||||
/// changed the user's PATH, so we also read the live PATH from the registry on Windows and
|
||||
/// probe the standard npm / node install prefixes.
|
||||
fn locate_cli() -> Option<String> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(path) = env::var_os("PATH") {
|
||||
dirs.extend(env::split_paths(&path));
|
||||
}
|
||||
dirs.extend(extra_search_dirs());
|
||||
|
||||
for dir in dirs {
|
||||
for name in candidate_names() {
|
||||
let candidate = dir.join(name);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn candidate_names() -> Vec<&'static str> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
WINDOWS_CLI_NAMES.to_vec()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["codeburn"]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extra_search_dirs() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for var in ["APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(base) = env::var_os(var).map(PathBuf::from) {
|
||||
match var {
|
||||
"APPDATA" => out.push(base.join("npm")),
|
||||
"LOCALAPPDATA" => {
|
||||
out.push(base.join("Programs").join("nodejs"));
|
||||
out.push(base.join("pnpm"));
|
||||
out.push(base.join("Volta").join("bin"));
|
||||
out.push(base.join("fnm_multishells"));
|
||||
}
|
||||
_ => out.push(base.join("nodejs")),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
out.push(home.join("scoop").join("shims"));
|
||||
out.push(home.join(".bun").join("bin"));
|
||||
}
|
||||
out.extend(registry_path_dirs());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn extra_search_dirs() -> Vec<PathBuf> {
|
||||
let mut out = vec![
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
out.push(home.join(".npm-global").join("bin"));
|
||||
out.push(home.join(".local").join("bin"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Reads the user and machine PATH values from the registry via `reg.exe` so a PATH edit
|
||||
/// made after this process started (npm install adds `%APPDATA%\npm`) is still honoured.
|
||||
#[cfg(windows)]
|
||||
fn registry_path_dirs() -> Vec<PathBuf> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let mut out = Vec::new();
|
||||
let keys = [
|
||||
r"HKCU\Environment",
|
||||
r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
|
||||
];
|
||||
for key in keys {
|
||||
let output = std::process::Command::new("reg")
|
||||
.args(["query", key, "/v", "Path"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
let Ok(output) = output else { continue };
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("Path") {
|
||||
continue;
|
||||
}
|
||||
let Some(idx) = trimmed.find("REG_") else { continue };
|
||||
let rest = &trimmed[idx..];
|
||||
let Some(space) = rest.find(char::is_whitespace) else { continue };
|
||||
let value = rest[space..].trim();
|
||||
for part in value.split(';') {
|
||||
let expanded = expand_env(part.trim());
|
||||
if !expanded.is_empty() {
|
||||
out.push(PathBuf::from(expanded));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn expand_env(value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut rest = value;
|
||||
while let Some(start) = rest.find('%') {
|
||||
result.push_str(&rest[..start]);
|
||||
let after = &rest[start + 1..];
|
||||
match after.find('%') {
|
||||
Some(end) => {
|
||||
let name = &after[..end];
|
||||
match env::var(name) {
|
||||
Ok(v) => result.push_str(&v),
|
||||
Err(_) => {
|
||||
result.push('%');
|
||||
result.push_str(name);
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
None => {
|
||||
result.push_str(&rest[start..]);
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs a codeburn subcommand in the user's terminal emulator so they can see the output.
|
||||
/// Linux: tries `x-terminal-emulator`, `gnome-terminal`, `konsole`, then falls back to a
|
||||
/// detached headless spawn. Windows: opens a console via `cmd /C start`. Never
|
||||
/// interpolates through a shell -- argv throughout.
|
||||
pub fn spawn_in_terminal(app: &AppHandle, subcommand: &[&str]) -> Result<()> {
|
||||
let cli = CodeburnCli::resolve();
|
||||
spawn_program_in_terminal(app, &cli, subcommand)
|
||||
}
|
||||
|
||||
/// The Plan view's "Connect Claude" runs Claude Code's own login flow, not codeburn. The
|
||||
/// bare name is deliberate: on Windows the console shell resolves `claude.exe` (native
|
||||
/// installer) or `claude.cmd` (npm) through PATHEXT.
|
||||
pub fn spawn_claude_login(app: &AppHandle) -> Result<()> {
|
||||
let cli = CodeburnCli {
|
||||
program: "claude".to_string(),
|
||||
extra_args: vec![],
|
||||
};
|
||||
spawn_program_in_terminal(app, &cli, &["login"])
|
||||
}
|
||||
|
||||
fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[&str]) -> Result<()> {
|
||||
if !subcommand.iter().all(|s| is_safe_arg(s)) {
|
||||
bail!("unsafe subcommand argument");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let terminals: [&[&str]; 4] = [
|
||||
&["x-terminal-emulator", "-e"],
|
||||
&["gnome-terminal", "--", "bash", "-lc"],
|
||||
&["konsole", "-e"],
|
||||
&["xterm", "-e"],
|
||||
];
|
||||
for term in &terminals {
|
||||
let program = term[0];
|
||||
let extras = &term[1..];
|
||||
if which::which(program).is_ok() {
|
||||
let mut command_parts: Vec<String> = vec![cli.program.clone()];
|
||||
command_parts.extend(cli.extra_args.clone());
|
||||
command_parts.extend(subcommand.iter().map(|s| s.to_string()));
|
||||
// gnome-terminal wants the whole command as a single argv after `--`
|
||||
// followed by `bash -lc`. The allowlist guarantees no quoting is needed.
|
||||
let composite = command_parts.join(" ");
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
cmd.args(extras);
|
||||
cmd.arg(&composite);
|
||||
cmd.spawn().with_context(|| format!("failed to launch {}", program))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Fallback: run detached, output lost -- better than silently doing nothing.
|
||||
std::process::Command::new(&cli.program)
|
||||
.args(&cli.extra_args)
|
||||
.args(subcommand)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| "no terminal emulator found, detached spawn also failed")?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// `start` treats the first quoted argument as the window title, so we pass an
|
||||
// explicit empty title. `/K` keeps the console open for non-interactive commands
|
||||
// (export) so the user can read where the file went; the TUI (report/optimize)
|
||||
// owns the window until the user quits it either way.
|
||||
let is_codeburn = cli.program.starts_with("codeburn");
|
||||
let program = if std::path::Path::new(&cli.program).is_absolute() || !is_codeburn {
|
||||
cli.program.clone()
|
||||
} else {
|
||||
locate_cli().unwrap_or_else(|| cli.program.clone())
|
||||
};
|
||||
let mut cmd = std::process::Command::new("cmd");
|
||||
cmd.arg("/C").arg("start").arg("").arg("cmd").arg("/K").arg(&program);
|
||||
for a in &cli.extra_args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
for a in subcommand {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd.spawn().with_context(|| "failed to open cmd.exe")?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS isn't our target for this app (Swift handles Mac), but keep dev-on-Mac working.
|
||||
std::process::Command::new(&cli.program)
|
||||
.args(&cli.extra_args)
|
||||
.args(subcommand)
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn {}", cli.program))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal dependency: we only use `which` inside spawn_in_terminal on Linux. Vendored here
|
||||
/// so the crate graph stays tiny. Gated so the unused-function warning doesn't fire on Mac
|
||||
/// or Windows builds.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod which {
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn which(program: &str) -> Result<PathBuf, ()> {
|
||||
let path = env::var_os("PATH").ok_or(())?;
|
||||
for dir in env::split_paths(&path) {
|
||||
let candidate = dir.join(program);
|
||||
if candidate.is_file() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
161
windows/src-tauri/src/config.rs
Normal file
161
windows/src-tauri/src/config.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyConfig {
|
||||
#[serde(default, flatten)]
|
||||
extra: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn codeburn_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config/codeburn"))
|
||||
.unwrap_or_else(|| PathBuf::from(".codeburn"))
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
codeburn_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
fn lock_path() -> PathBuf {
|
||||
codeburn_config_dir().join(".config.lock")
|
||||
}
|
||||
|
||||
impl CurrencyConfig {
|
||||
pub fn load_or_default() -> Self {
|
||||
match fs::read(config_path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_currency(&mut self, code: &str, symbol: &str) -> Result<()> {
|
||||
fs::create_dir_all(codeburn_config_dir())
|
||||
.with_context(|| "failed to create ~/.config/codeburn")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let _lock = unix_lock::acquire()?;
|
||||
#[cfg(windows)]
|
||||
let _lock = windows_lock::acquire()?;
|
||||
|
||||
let mut disk: serde_json::Value = match fs::read(config_path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})),
|
||||
Err(_) => serde_json::json!({}),
|
||||
};
|
||||
|
||||
if code == "USD" {
|
||||
if let Some(obj) = disk.as_object_mut() {
|
||||
obj.remove("currency");
|
||||
}
|
||||
} else if let Some(obj) = disk.as_object_mut() {
|
||||
obj.insert(
|
||||
"currency".into(),
|
||||
serde_json::json!({ "code": code, "symbol": symbol }),
|
||||
);
|
||||
}
|
||||
|
||||
let serialized = serde_json::to_vec_pretty(&disk)?;
|
||||
let tmp = config_path().with_extension("tmp");
|
||||
{
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tmp)?;
|
||||
file.write_all(&serialized)?;
|
||||
file.flush()?;
|
||||
}
|
||||
fs::rename(&tmp, config_path())?;
|
||||
|
||||
*self = serde_json::from_value(disk).unwrap_or_default();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_lock {
|
||||
use std::fs;
|
||||
use std::os::fd::AsRawFd;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
pub struct Guard {
|
||||
_file: fs::File,
|
||||
}
|
||||
|
||||
pub fn acquire() -> Result<Guard> {
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(super::lock_path())
|
||||
.with_context(|| "failed to open config lock")?;
|
||||
|
||||
let fd = file.as_raw_fd();
|
||||
let ret = unsafe { flock(fd, 2) };
|
||||
if ret != 0 {
|
||||
return Err(anyhow!("flock failed: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
Ok(Guard { _file: file })
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no flock; a create-new lock file gives the same mutual exclusion against
|
||||
/// the CLI's own writer. Stale locks (crash mid-write) are ignored once older than
|
||||
/// STALE_LOCK_SECS so a single crash can never wedge currency changes forever.
|
||||
#[cfg(windows)]
|
||||
mod windows_lock {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
const RETRY_INTERVAL: Duration = Duration::from_millis(40);
|
||||
const MAX_RETRIES: u32 = 50;
|
||||
const STALE_LOCK_SECS: u64 = 30;
|
||||
|
||||
pub struct Guard {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire() -> Result<Guard> {
|
||||
let path = super::lock_path();
|
||||
for _ in 0..MAX_RETRIES {
|
||||
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(_) => return Ok(Guard { path }),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
if is_stale(&path) {
|
||||
let _ = fs::remove_file(&path);
|
||||
continue;
|
||||
}
|
||||
sleep(RETRY_INTERVAL);
|
||||
}
|
||||
Err(err) => return Err(anyhow!("failed to open config lock: {err}")),
|
||||
}
|
||||
}
|
||||
Err(anyhow!("config lock is held by another process"))
|
||||
}
|
||||
|
||||
fn is_stale(path: &PathBuf) -> bool {
|
||||
fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| SystemTime::now().duration_since(t).ok())
|
||||
.map(|age| age.as_secs() > STALE_LOCK_SECS)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
152
windows/src-tauri/src/fx.rs
Normal file
152
windows/src-tauri/src/fx.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const FRANKFURTER_URL: &str = "https://api.frankfurter.app/latest?from=USD&to=";
|
||||
const CACHE_TTL_SECS: u64 = 24 * 3600;
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Defensive bounds on any fetched FX rate. Outside [0.0001, 1_000_000] the rate is either
|
||||
/// a parser bug or a tampered response; we refuse it so the UI never multiplies a NaN or
|
||||
/// wild value into displayed costs.
|
||||
const MIN_VALID_FX_RATE: f64 = 0.0001;
|
||||
const MAX_VALID_FX_RATE: f64 = 1_000_000.0;
|
||||
|
||||
/// Currency metadata the frontend renders against. `rate` is USD -> target; the UI
|
||||
/// multiplies each raw USD number by `rate` and prefixes `symbol` for display.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyApplied {
|
||||
pub code: String,
|
||||
pub symbol: String,
|
||||
pub rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Entry {
|
||||
rate: f64,
|
||||
saved_at: u64,
|
||||
}
|
||||
|
||||
pub struct FxCache {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
impl FxCache {
|
||||
pub fn new() -> Self {
|
||||
let entries = load_from_disk().unwrap_or_default();
|
||||
FxCache {
|
||||
entries: Mutex::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a cached-or-fresh rate. Tries cache first, then Frankfurter if stale. Any
|
||||
/// response that fails the sanity bounds is dropped and the cached (possibly stale)
|
||||
/// value is returned instead.
|
||||
pub async fn rate_for(&self, code: &str) -> Option<f64> {
|
||||
if code == "USD" {
|
||||
return Some(1.0);
|
||||
}
|
||||
|
||||
{
|
||||
let guard = self.entries.lock().ok()?;
|
||||
if let Some(entry) = guard.get(code) {
|
||||
if now_secs().saturating_sub(entry.saved_at) < CACHE_TTL_SECS {
|
||||
return Some(entry.rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fetch_rate(code).await {
|
||||
Some(fresh) if is_valid(fresh) => {
|
||||
if let Ok(mut guard) = self.entries.lock() {
|
||||
guard.insert(
|
||||
code.to_string(),
|
||||
Entry {
|
||||
rate: fresh,
|
||||
saved_at: now_secs(),
|
||||
},
|
||||
);
|
||||
let _ = save_to_disk(&guard);
|
||||
}
|
||||
Some(fresh)
|
||||
}
|
||||
_ => {
|
||||
// Fetch failed or out-of-band; serve stale cached value if any.
|
||||
let guard = self.entries.lock().ok()?;
|
||||
guard.get(code).map(|e| e.rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid(rate: f64) -> bool {
|
||||
rate.is_finite() && (MIN_VALID_FX_RATE..=MAX_VALID_FX_RATE).contains(&rate)
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cache_path() -> PathBuf {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("codeburn-menubar")
|
||||
.join("fx-rates.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Option<HashMap<String, Entry>> {
|
||||
let bytes = fs::read(cache_path()).ok()?;
|
||||
let parsed: HashMap<String, Entry> = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(parsed.into_iter().filter(|(_, e)| is_valid(e.rate)).collect())
|
||||
}
|
||||
|
||||
fn save_to_disk(entries: &HashMap<String, Entry>) -> Option<()> {
|
||||
let path = cache_path();
|
||||
let parent = path.parent()?;
|
||||
fs::create_dir_all(parent).ok()?;
|
||||
let serialized = serde_json::to_vec(entries).ok()?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, serialized).ok()?;
|
||||
fs::rename(&tmp, path).ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
async fn fetch_rate(code: &str) -> Option<f64> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.https_only(true)
|
||||
.build()
|
||||
.ok()?;
|
||||
let url = format!("{}{}", FRANKFURTER_URL, code);
|
||||
let response = client.get(&url).send().await.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.ok()?;
|
||||
body.get("rates")?.get(code)?.as_f64()
|
||||
}
|
||||
|
||||
/// Prefers a handwritten glyph over whatever Intl returns for a given code, since some
|
||||
/// locales produce "US$" / "CA$" which reads as noise. Mirrors the Swift symbol override
|
||||
/// table so both apps display identical strings for the same code.
|
||||
pub fn symbol_for(code: &str) -> String {
|
||||
match code {
|
||||
"USD" | "CAD" | "AUD" | "NZD" | "HKD" | "SGD" | "MXN" => "$".into(),
|
||||
"EUR" => "\u{20AC}".into(),
|
||||
"GBP" => "\u{00A3}".into(),
|
||||
"JPY" | "CNY" => "\u{00A5}".into(),
|
||||
"KRW" => "\u{20A9}".into(),
|
||||
"INR" => "\u{20B9}".into(),
|
||||
"BRL" => "R$".into(),
|
||||
"CHF" => "CHF".into(),
|
||||
"SEK" | "DKK" => "kr".into(),
|
||||
"ZAR" => "R".into(),
|
||||
_ => code.into(),
|
||||
}
|
||||
}
|
||||
509
windows/src-tauri/src/lib.rs
Normal file
509
windows/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
mod autostart;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod fx;
|
||||
mod plan;
|
||||
mod tray_badge;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod tray_linux;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
static LAST_HIDDEN_MS: AtomicI64 = AtomicI64::new(0);
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
|
||||
#[cfg(target_os = "linux")]
|
||||
use tauri::Listener;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
|
||||
use crate::cli::CodeburnCli;
|
||||
use crate::config::CurrencyConfig;
|
||||
use crate::fx::FxCache;
|
||||
|
||||
const TRAY_ID: &str = "codeburn-tray";
|
||||
/// Second tray icon that carries today's spend as text, sitting next to the logo. The
|
||||
/// closest Windows and Linux panels get to the macOS menubar title.
|
||||
const BADGE_TRAY_ID: &str = "codeburn-badge";
|
||||
const POPOVER_LABEL: &str = "popover";
|
||||
|
||||
/// Shared application state. Wraps the CLI handle + currency config + FX cache so every
|
||||
/// Tauri command sees the same instances. Interior Mutex keeps things simple; the state is
|
||||
/// touched from the main thread (UI) and the Tokio runtime (CLI spawn, HTTP), both of
|
||||
/// which go through `#[tauri::command]` async functions that acquire the lock briefly.
|
||||
pub struct AppState {
|
||||
pub cli: Mutex<CodeburnCli>,
|
||||
pub config: Mutex<CurrencyConfig>,
|
||||
pub fx: FxCache,
|
||||
pub plan: plan::PlanClient,
|
||||
#[cfg(target_os = "linux")]
|
||||
pub linux_tray: tray_linux::LinuxTrayHandle,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_tray = tray_linux::LinuxTrayHandle::empty();
|
||||
|
||||
let state = AppState {
|
||||
cli: Mutex::new(CodeburnCli::resolve()),
|
||||
config: Mutex::new(CurrencyConfig::load_or_default()),
|
||||
fx: FxCache::new(),
|
||||
plan: plan::PlanClient::new(),
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_tray: linux_tray.clone(),
|
||||
};
|
||||
app.manage(state);
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
build_tray_tauri(app.handle())?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
init_tray_linux(app.handle().clone(), linux_tray);
|
||||
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.hide();
|
||||
#[cfg(target_os = "windows")]
|
||||
round_window_corners(&window);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
WindowEvent::Focused(false) => {
|
||||
LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed);
|
||||
let _ = window.hide();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::fetch_payload,
|
||||
commands::cli_status,
|
||||
commands::set_currency,
|
||||
commands::open_terminal_command,
|
||||
commands::open_claude_login,
|
||||
commands::quit_app,
|
||||
commands::hide_popover,
|
||||
commands::set_tray_tooltip,
|
||||
commands::set_tray_badge,
|
||||
commands::app_version,
|
||||
commands::plan_usage,
|
||||
commands::launch_at_login,
|
||||
commands::set_launch_at_login,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|_app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||
api.prevent_exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
|
||||
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
|
||||
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
|
||||
let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?;
|
||||
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open,
|
||||
&refresh,
|
||||
&theme,
|
||||
&report,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
tray.set_menu(Some(menu.clone()))?;
|
||||
tray.set_show_menu_on_left_click(false)?;
|
||||
let _ = tray.set_tooltip(Some("CodeBurn"));
|
||||
tray.on_menu_event(on_tray_menu_event);
|
||||
tray.on_tray_icon_event(on_tray_icon_event);
|
||||
|
||||
// The badge icon starts fully transparent and hidden; the frontend shows it once it has
|
||||
// today's spend. Registering it right after the logo puts it beside the logo in the tray.
|
||||
let blank = tauri::image::Image::new_owned(
|
||||
vec![0u8; (BLANK_ICON_SIZE * BLANK_ICON_SIZE * 4) as usize],
|
||||
BLANK_ICON_SIZE,
|
||||
BLANK_ICON_SIZE,
|
||||
);
|
||||
TrayIconBuilder::with_id(BADGE_TRAY_ID)
|
||||
.icon(blank)
|
||||
.tooltip("CodeBurn")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(on_tray_menu_event)
|
||||
.on_tray_icon_event(on_tray_icon_event)
|
||||
.build(app)?
|
||||
.set_visible(false)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const BLANK_ICON_SIZE: u32 = 16;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
||||
match event.id.as_ref() {
|
||||
"quit" => app.exit(0),
|
||||
"open" => show_popover(app, None),
|
||||
"refresh" => {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.emit("codeburn://refresh", ());
|
||||
}
|
||||
}
|
||||
"toggle_theme" => {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.emit("codeburn://toggle-theme", ());
|
||||
}
|
||||
}
|
||||
"report" => {
|
||||
let _ = cli::spawn_in_terminal(app, &["report"]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_tray_icon_event(tray: &tauri::tray::TrayIcon, event: TrayIconEvent) {
|
||||
match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
position,
|
||||
..
|
||||
}
|
||||
| TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
..
|
||||
} => {
|
||||
toggle_popover(tray.app_handle(), Some((position.x as i32, position.y as i32)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn init_tray_linux(app: AppHandle, handle: tray_linux::LinuxTrayHandle) {
|
||||
// Spawn the SNI tray on the Tokio runtime that Tauri already owns.
|
||||
let spawn_app = app.clone();
|
||||
let spawn_handle = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = tray_linux::spawn(spawn_app, spawn_handle).await {
|
||||
eprintln!("codeburn: failed to spawn Linux tray: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// Left-click on the tray: show popover anchored to the click coordinates.
|
||||
let activate_app = app.clone();
|
||||
app.listen_any("codeburn://tray-activate", move |event| {
|
||||
let anchor = parse_click(event.payload());
|
||||
toggle_popover(&activate_app, anchor);
|
||||
});
|
||||
|
||||
// Right-click / middle-click: same as left for now. Quit lives in the popover footer.
|
||||
let secondary_app = app.clone();
|
||||
app.listen_any("codeburn://tray-secondary", move |event| {
|
||||
let anchor = parse_click(event.payload());
|
||||
toggle_popover(&secondary_app, anchor);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_click(payload: &str) -> Option<(i32, i32)> {
|
||||
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
|
||||
let x = value.get("x")?.as_i64()? as i32;
|
||||
let y = value.get("y")?.as_i64()? as i32;
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
/// Undecorated windows are square by default; ask DWM for the Windows 11 rounded corner so
|
||||
/// the acrylic backdrop is clipped to the same shape as the popover card. Silently ignored
|
||||
/// on Windows 10, where the corners stay square.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn round_window_corners(window: &tauri::WebviewWindow) {
|
||||
use windows_sys::Win32::Graphics::Dwm::{
|
||||
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||
};
|
||||
let Ok(hwnd) = window.hwnd() else { return };
|
||||
let preference: u32 = DWMWCP_ROUND as u32;
|
||||
unsafe {
|
||||
DwmSetWindowAttribute(
|
||||
hwnd.0 as _,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE as u32,
|
||||
&preference as *const u32 as *const std::ffi::c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A blur immediately followed by the tray click that caused it would re-open the popover;
|
||||
/// ignore show requests inside this window after a hide.
|
||||
const TOGGLE_DEBOUNCE_MS: i64 = 300;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
|
||||
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
|
||||
return;
|
||||
};
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed);
|
||||
let _ = window.hide();
|
||||
return;
|
||||
}
|
||||
let last = LAST_HIDDEN_MS.load(Ordering::Relaxed);
|
||||
if now_ms() - last < TOGGLE_DEBOUNCE_MS {
|
||||
return;
|
||||
}
|
||||
show_popover(app, anchor);
|
||||
}
|
||||
|
||||
fn show_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
|
||||
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
|
||||
return;
|
||||
};
|
||||
// Position before showing so the first frame is already in place (no jump).
|
||||
position_popover(&window, anchor);
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
position_popover(&window, anchor);
|
||||
let _ = window.set_focus();
|
||||
let _ = window.emit("codeburn://shown", ());
|
||||
}
|
||||
|
||||
/// Places the popover against the taskbar / panel edge of the monitor that owns the click
|
||||
/// (or the cursor, when the request came from a menu). The work area already excludes the
|
||||
/// taskbar on Windows and panels on Linux, so we never need to guess their heights: the
|
||||
/// popover sits `MARGIN` inside the work area, horizontally centred on the anchor and
|
||||
/// clamped to the screen.
|
||||
fn position_popover(window: &tauri::WebviewWindow, anchor: Option<(i32, i32)>) {
|
||||
const POPOVER_WIDTH_LOGICAL: f64 = 360.0;
|
||||
const POPOVER_HEIGHT_LOGICAL: f64 = 660.0;
|
||||
const MARGIN_LOGICAL: f64 = 8.0;
|
||||
|
||||
let point = anchor
|
||||
.filter(|(x, y)| *x > 0 || *y > 0)
|
||||
.map(|(x, y)| (x as f64, y as f64))
|
||||
.or_else(|| window.cursor_position().ok().map(|p| (p.x, p.y)));
|
||||
|
||||
let monitor = point
|
||||
.and_then(|(x, y)| window.monitor_from_point(x, y).ok().flatten())
|
||||
.or_else(|| window.primary_monitor().ok().flatten());
|
||||
let Some(monitor) = monitor else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor();
|
||||
let pop_w = (POPOVER_WIDTH_LOGICAL * scale).round() as i32;
|
||||
let pop_h = (POPOVER_HEIGHT_LOGICAL * scale).round() as i32;
|
||||
let margin = (MARGIN_LOGICAL * scale).round() as i32;
|
||||
|
||||
let area = monitor.work_area();
|
||||
let area_x = area.position.x;
|
||||
let area_y = area.position.y;
|
||||
let area_w = area.size.width as i32;
|
||||
let area_h = area.size.height as i32;
|
||||
let screen = monitor.size();
|
||||
let screen_pos = monitor.position();
|
||||
|
||||
let (anchor_x, anchor_y) = point
|
||||
.map(|(x, y)| (x as i32, y as i32))
|
||||
.unwrap_or((area_x + area_w - pop_w / 2 - margin, area_y + area_h));
|
||||
|
||||
let min_x = area_x + margin;
|
||||
let max_x = (area_x + area_w - pop_w - margin).max(min_x);
|
||||
let x = (anchor_x - pop_w / 2).clamp(min_x, max_x);
|
||||
|
||||
// Which edge holds the taskbar? Whichever side the work area was trimmed on. If the
|
||||
// taskbar is at the top (or the anchor is in the top half with no bottom taskbar) the
|
||||
// popover drops down from the top edge; otherwise it rises from the bottom edge.
|
||||
let trimmed_top = area_y > screen_pos.y;
|
||||
let trimmed_bottom = (area_y + area_h) < (screen_pos.y + screen.height as i32);
|
||||
let anchor_in_top_half = anchor_y < screen_pos.y + (screen.height as i32) / 2;
|
||||
let open_downward = trimmed_top || (!trimmed_bottom && anchor_in_top_half);
|
||||
|
||||
let y = if open_downward {
|
||||
area_y + margin
|
||||
} else {
|
||||
(area_y + area_h - pop_h - margin).max(area_y + margin)
|
||||
};
|
||||
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
|
||||
mod commands {
|
||||
use super::{AppState, POPOVER_LABEL};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_payload(
|
||||
period: String,
|
||||
provider: String,
|
||||
include_optimize: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let cli = state.cli.lock().map_err(|e| e.to_string())?.clone();
|
||||
cli.fetch_menubar_payload(&period, &provider, include_optimize)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Re-resolves the CLI each call so a freshly installed `codeburn` is picked up
|
||||
/// without restarting the tray app.
|
||||
#[tauri::command]
|
||||
pub async fn cli_status(state: State<'_, AppState>) -> Result<crate::cli::CliStatus, String> {
|
||||
let fresh = crate::cli::CodeburnCli::resolve();
|
||||
let status = fresh.status().await;
|
||||
if status.found {
|
||||
if let Ok(mut guard) = state.cli.lock() {
|
||||
*guard = fresh;
|
||||
}
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_currency(
|
||||
code: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::fx::CurrencyApplied, String> {
|
||||
let symbol = crate::fx::symbol_for(&code);
|
||||
let rate = state
|
||||
.fx
|
||||
.rate_for(&code)
|
||||
.await
|
||||
.ok_or_else(|| format!("Exchange rate for {code} is unavailable right now"))?;
|
||||
state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.set_currency(&code, &symbol)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(crate::fx::CurrencyApplied { code, symbol, rate })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_terminal_command(app: AppHandle, args: Vec<String>) -> Result<(), String> {
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
crate::cli::spawn_in_terminal(&app, &args).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_claude_login(app: AppHandle) -> Result<(), String> {
|
||||
crate::cli::spawn_claude_login(&app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn quit_app(app: AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hide_popover(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
super::LAST_HIDDEN_MS.store(super::now_ms(), std::sync::atomic::Ordering::Relaxed);
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
|
||||
/// The tray cannot render text on Windows, so today's spend lives in the tooltip.
|
||||
#[tauri::command]
|
||||
pub fn set_tray_tooltip(app: AppHandle, text: String) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
for id in [super::TRAY_ID, super::BADGE_TRAY_ID] {
|
||||
if let Some(tray) = app.tray_by_id(id) {
|
||||
let _ = tray.set_tooltip(Some(text.as_str()));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = (app, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` is a short spend string ("$87", "142", "1.2K"); `None` hides the badge icon.
|
||||
#[tauri::command]
|
||||
pub fn set_tray_badge(app: AppHandle, text: Option<String>) -> Result<(), String> {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let Some(badge) = app.tray_by_id(super::BADGE_TRAY_ID) else {
|
||||
return Ok(());
|
||||
};
|
||||
match text.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) => {
|
||||
let icon = crate::tray_badge::render(
|
||||
t,
|
||||
crate::tray_badge::small_icon_size(),
|
||||
crate::tray_badge::taskbar_is_dark(),
|
||||
);
|
||||
// Windows can only modify an icon that is currently shown, so show
|
||||
// first (re-adds the previous bitmap) and then swap the bitmap.
|
||||
badge.set_visible(true).map_err(|e| e.to_string())?;
|
||||
badge.set_icon(Some(icon)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
None => {
|
||||
badge.set_visible(false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = (app, text);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_version(app: AppHandle) -> String {
|
||||
app.package_info().version.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn launch_at_login() -> bool {
|
||||
crate::autostart::is_enabled()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_launch_at_login(enabled: bool) -> Result<bool, String> {
|
||||
crate::autostart::set_enabled(enabled).map_err(|e| e.to_string())?;
|
||||
Ok(crate::autostart::is_enabled())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn plan_usage(state: State<'_, AppState>) -> Result<crate::plan::PlanUsage, String> {
|
||||
state.plan.fetch().await.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
6
windows/src-tauri/src/main.rs
Normal file
6
windows/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Stops an extra console window appearing on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
codeburn_menubar_lib::run()
|
||||
}
|
||||
484
windows/src-tauri/src/plan.rs
Normal file
484
windows/src-tauri/src/plan.rs
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
//! Claude subscription usage (the "Plan" insight). Mirrors the macOS SubscriptionClient:
|
||||
//! read Claude Code's OAuth credentials, call the usage endpoint, refresh once on 401, and
|
||||
//! keep a rolling snapshot file so a freshly reset window can still show last cycle's final.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const CREDENTIALS_RELATIVE_PATH: &str = ".claude/.credentials.json";
|
||||
const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const REFRESH_URL: &str = "https://platform.claude.com/v1/oauth/token";
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
const BETA_HEADER: &str = "oauth-2025-04-20";
|
||||
const USER_AGENT: &str = "claude-code/2.1.0";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
|
||||
const SNAPSHOT_FILENAME: &str = "subscription-snapshots.json";
|
||||
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(30 * 24 * 3600);
|
||||
const WINDOW_KEYS: [(&str, &str); 4] = [
|
||||
("five_hour", "5-hour window"),
|
||||
("seven_day", "7-day total"),
|
||||
("seven_day_opus", "7-day Opus"),
|
||||
("seven_day_sonnet", "7-day Sonnet"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlanWindow {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
/// 0..100
|
||||
pub percent: f64,
|
||||
/// RFC 3339 timestamp of the next reset, when the API supplied one.
|
||||
pub resets_at: Option<String>,
|
||||
/// Final percent reached in the immediately prior cycle, from the snapshot store.
|
||||
pub previous_final: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum PlanUsage {
|
||||
Ok {
|
||||
tier: String,
|
||||
raw_tier: Option<String>,
|
||||
windows: Vec<PlanWindow>,
|
||||
fetched_at: String,
|
||||
},
|
||||
NoCredentials,
|
||||
Failed {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct PlanClient {
|
||||
snapshot_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl PlanClient {
|
||||
pub fn new() -> Self {
|
||||
PlanClient {
|
||||
snapshot_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch(&self) -> Result<PlanUsage> {
|
||||
let creds = match load_credentials() {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return Ok(PlanUsage::NoCredentials),
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let response = match fetch_usage(&creds.access_token).await {
|
||||
Ok(r) => r,
|
||||
Err(FetchError::Unauthorized) => {
|
||||
let Some(refresh) = creds.refresh_token.as_deref().filter(|t| !t.is_empty()) else {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: "Claude session expired and no refresh token is available. Run `claude login`.".into(),
|
||||
});
|
||||
};
|
||||
match refresh_access_token(refresh).await {
|
||||
Ok(token) => match fetch_usage(&token).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: format!("Token refresh failed: {err}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let now = SystemTime::now();
|
||||
let mut windows = Vec::new();
|
||||
for (key, label) in WINDOW_KEYS {
|
||||
let Some(window) = response.window(key) else { continue };
|
||||
let Some(percent) = window.utilization else { continue };
|
||||
let resets_at = window.resets_at.clone().filter(|s| !s.is_empty());
|
||||
let previous_final = {
|
||||
let _guard = self.snapshot_lock.lock().await;
|
||||
if let Some(reset) = resets_at.as_deref() {
|
||||
record_snapshot(key, percent, reset, now);
|
||||
previous_window_final(key, reset)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
windows.push(PlanWindow {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
percent: percent.clamp(0.0, 100.0),
|
||||
resets_at,
|
||||
previous_final,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PlanUsage::Ok {
|
||||
tier: tier_display(creds.rate_limit_tier.as_deref()),
|
||||
raw_tier: creds.rate_limit_tier,
|
||||
windows,
|
||||
fetched_at: to_rfc3339(now),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- credentials -----------------------------------------------------------------------
|
||||
|
||||
struct StoredCredentials {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
rate_limit_tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CredentialsRoot {
|
||||
#[serde(rename = "claudeAiOauth")]
|
||||
claude_ai_oauth: Option<OAuthBlock>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthBlock {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: Option<String>,
|
||||
#[serde(rename = "refreshToken")]
|
||||
refresh_token: Option<String>,
|
||||
#[serde(rename = "rateLimitTier")]
|
||||
rate_limit_tier: Option<String>,
|
||||
}
|
||||
|
||||
fn credentials_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(CREDENTIALS_RELATIVE_PATH))
|
||||
}
|
||||
|
||||
/// Ok(None) when the file does not exist (user never logged in); Err for malformed data.
|
||||
fn load_credentials() -> Result<Option<StoredCredentials>> {
|
||||
let Some(path) = credentials_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
bail!("credentials file is a symlink; refusing to read it");
|
||||
}
|
||||
if meta.len() > MAX_CREDENTIAL_BYTES {
|
||||
bail!("credentials file is unexpectedly large");
|
||||
}
|
||||
let bytes = fs::read(&path).with_context(|| "failed to read Claude credentials")?;
|
||||
let root: CredentialsRoot =
|
||||
serde_json::from_slice(&bytes).with_context(|| "Claude credentials are malformed")?;
|
||||
let Some(oauth) = root.claude_ai_oauth else {
|
||||
return Ok(None);
|
||||
};
|
||||
let token = oauth.access_token.unwrap_or_default().trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(StoredCredentials {
|
||||
access_token: token,
|
||||
refresh_token: oauth.refresh_token,
|
||||
rate_limit_tier: oauth.rate_limit_tier,
|
||||
}))
|
||||
}
|
||||
|
||||
fn tier_display(raw: Option<&str>) -> String {
|
||||
let Some(raw) = raw.map(|r| r.to_lowercase()) else {
|
||||
return "Subscription".into();
|
||||
};
|
||||
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") {
|
||||
return "Max 20x".into();
|
||||
}
|
||||
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") {
|
||||
return "Max 5x".into();
|
||||
}
|
||||
if raw.contains("max") {
|
||||
return "Max 5x".into();
|
||||
}
|
||||
if raw.contains("pro") {
|
||||
return "Pro".into();
|
||||
}
|
||||
if raw.contains("team") {
|
||||
return "Team".into();
|
||||
}
|
||||
if raw.contains("enterprise") {
|
||||
return "Enterprise".into();
|
||||
}
|
||||
"Subscription".into()
|
||||
}
|
||||
|
||||
// ---- HTTP ------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageResponse {
|
||||
five_hour: Option<Window>,
|
||||
seven_day: Option<Window>,
|
||||
seven_day_opus: Option<Window>,
|
||||
seven_day_sonnet: Option<Window>,
|
||||
}
|
||||
|
||||
impl UsageResponse {
|
||||
fn window(&self, key: &str) -> Option<&Window> {
|
||||
match key {
|
||||
"five_hour" => self.five_hour.as_ref(),
|
||||
"seven_day" => self.seven_day.as_ref(),
|
||||
"seven_day_opus" => self.seven_day_opus.as_ref(),
|
||||
"seven_day_sonnet" => self.seven_day_sonnet.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Window {
|
||||
utilization: Option<f64>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TokenRefreshResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum FetchError {
|
||||
#[error("Claude session is no longer authorized")]
|
||||
Unauthorized,
|
||||
#[error("Usage fetch failed ({0}){1}")]
|
||||
Http(u16, String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, FetchError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.https_only(true)
|
||||
.build()
|
||||
.map_err(|e| FetchError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_usage(token: &str) -> Result<UsageResponse, FetchError> {
|
||||
let response = client()?
|
||||
.get(USAGE_URL)
|
||||
.bearer_auth(token)
|
||||
.header("Accept", "application/json")
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FetchError::Other(e.to_string()))?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(FetchError::Unauthorized);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = if body.is_empty() { String::new() } else { format!(": {}", truncate(&body, 200)) };
|
||||
return Err(FetchError::Http(status.as_u16(), detail));
|
||||
}
|
||||
response
|
||||
.json::<UsageResponse>()
|
||||
.await
|
||||
.map_err(|e| FetchError::Other(format!("Decode failed: {e}")))
|
||||
}
|
||||
|
||||
async fn refresh_access_token(refresh_token: &str) -> Result<String> {
|
||||
let response = client()
|
||||
.map_err(|e| anyhow!(e.to_string()))?
|
||||
.post(REFRESH_URL)
|
||||
.header("Accept", "application/json")
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("client_id", OAUTH_CLIENT_ID),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
bail!("{} {}", status.as_u16(), truncate(&body, 200));
|
||||
}
|
||||
let decoded: TokenRefreshResponse = response.json().await?;
|
||||
Ok(decoded.access_token)
|
||||
}
|
||||
|
||||
fn truncate(text: &str, max: usize) -> String {
|
||||
let mut out: String = text.chars().take(max).collect();
|
||||
if text.chars().count() > max {
|
||||
out.push_str("...");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- snapshots -------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Snapshot {
|
||||
#[serde(rename = "windowKey")]
|
||||
window_key: String,
|
||||
percent: f64,
|
||||
#[serde(rename = "resetsAt")]
|
||||
resets_at: String,
|
||||
#[serde(rename = "capturedAt")]
|
||||
captured_at: String,
|
||||
#[serde(rename = "effectiveTokens")]
|
||||
effective_tokens: Option<f64>,
|
||||
}
|
||||
|
||||
fn snapshots_path() -> PathBuf {
|
||||
let dir = std::env::var_os("CODEBURN_CACHE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".cache").join("codeburn")))
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
dir.join(SNAPSHOT_FILENAME)
|
||||
}
|
||||
|
||||
fn load_snapshots() -> Vec<Snapshot> {
|
||||
fs::read(snapshots_path())
|
||||
.ok()
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_snapshots(all: &[Snapshot]) {
|
||||
let path = snapshots_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(all) {
|
||||
let tmp = path.with_extension("tmp");
|
||||
if fs::write(&tmp, bytes).is_ok() {
|
||||
let _ = fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn record_snapshot(window_key: &str, percent: f64, resets_at: &str, now: SystemTime) {
|
||||
let mut all = load_snapshots();
|
||||
match all
|
||||
.iter_mut()
|
||||
.find(|s| s.window_key == window_key && s.resets_at == resets_at)
|
||||
{
|
||||
Some(existing) => {
|
||||
if percent > existing.percent {
|
||||
existing.percent = percent;
|
||||
existing.captured_at = to_rfc3339(now);
|
||||
}
|
||||
}
|
||||
None => all.push(Snapshot {
|
||||
window_key: window_key.to_string(),
|
||||
percent,
|
||||
resets_at: resets_at.to_string(),
|
||||
captured_at: to_rfc3339(now),
|
||||
effective_tokens: None,
|
||||
}),
|
||||
}
|
||||
let cutoff = now.checked_sub(SNAPSHOT_RETENTION).unwrap_or(UNIX_EPOCH);
|
||||
all.retain(|s| parse_rfc3339(&s.captured_at).map(|t| t >= cutoff).unwrap_or(true));
|
||||
save_snapshots(&all);
|
||||
}
|
||||
|
||||
fn previous_window_final(window_key: &str, current_resets_at: &str) -> Option<f64> {
|
||||
let current = parse_rfc3339(current_resets_at)?;
|
||||
let all = load_snapshots();
|
||||
let priors: Vec<(SystemTime, f64)> = all
|
||||
.iter()
|
||||
.filter(|s| s.window_key == window_key)
|
||||
.filter_map(|s| parse_rfc3339(&s.resets_at).map(|t| (t, s.percent)))
|
||||
.filter(|(t, _)| *t < current)
|
||||
.collect();
|
||||
let latest = priors.iter().map(|(t, _)| *t).max()?;
|
||||
priors
|
||||
.iter()
|
||||
.filter(|(t, _)| *t == latest)
|
||||
.map(|(_, p)| *p)
|
||||
.fold(None, |acc: Option<f64>, p| Some(acc.map_or(p, |a| a.max(p))))
|
||||
}
|
||||
|
||||
// ---- time helpers (RFC 3339 without pulling in chrono) --------------------------------
|
||||
|
||||
fn to_rfc3339(t: SystemTime) -> String {
|
||||
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
|
||||
let (y, m, d, hh, mm, ss) = civil_from_unix(secs);
|
||||
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
|
||||
}
|
||||
|
||||
/// Accepts `2026-08-18T10:00:00Z`, with optional fractional seconds and `+hh:mm` offsets.
|
||||
fn parse_rfc3339(text: &str) -> Option<SystemTime> {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let num = |a: usize, b: usize| text.get(a..b)?.parse::<i64>().ok();
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
let (h, mi, s) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
|
||||
let mut rest = &text[19..];
|
||||
if rest.starts_with('.') {
|
||||
let end = rest[1..]
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(rest.len());
|
||||
rest = &rest[end..];
|
||||
}
|
||||
let offset_secs = match rest {
|
||||
"" | "Z" | "z" => 0,
|
||||
_ => {
|
||||
let sign = if rest.starts_with('-') { -1 } else { 1 };
|
||||
let oh = rest.get(1..3)?.parse::<i64>().ok()?;
|
||||
let om = rest.get(4..6)?.parse::<i64>().ok()?;
|
||||
sign * (oh * 3600 + om * 60)
|
||||
}
|
||||
};
|
||||
let unix = unix_from_civil(y, mo, d) + h * 3600 + mi * 60 + s - offset_secs;
|
||||
if unix < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(unix as u64))
|
||||
}
|
||||
|
||||
fn unix_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
// Howard Hinnant's days_from_civil.
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
(era * 146_097 + doe - 719_468) * 86_400
|
||||
}
|
||||
|
||||
fn civil_from_unix(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d, rem / 3600, (rem % 3600) / 60, rem % 60)
|
||||
}
|
||||
246
windows/src-tauri/src/tray_badge.rs
Normal file
246
windows/src-tauri/src/tray_badge.rs
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
//! Renders today's spend into the tray icon. Windows and most Linux panels cannot place a
|
||||
//! title next to a tray icon the way the macOS menubar does, so the number becomes the icon:
|
||||
//! a 4x7 pixel font drawn at the panel's native small-icon size, so it stays crisp instead
|
||||
//! of being a scaled-down bitmap.
|
||||
|
||||
use tauri::image::Image;
|
||||
|
||||
const GLYPH_HEIGHT: usize = 7;
|
||||
const BASE_ICON_SIZE: u32 = 16;
|
||||
const GLYPH_GAP: usize = 1;
|
||||
/// Brand accent on a light taskbar, the lighter ember on a dark one.
|
||||
const ACCENT_LIGHT_TASKBAR: [u8; 3] = [0xC9, 0x52, 0x1D];
|
||||
const ACCENT_DARK_TASKBAR: [u8; 3] = [0xF0, 0x8A, 0x55];
|
||||
|
||||
struct Glyph {
|
||||
width: usize,
|
||||
rows: [&'static str; GLYPH_HEIGHT],
|
||||
}
|
||||
|
||||
fn glyph(c: char) -> Option<Glyph> {
|
||||
let g = |width: usize, rows: [&'static str; GLYPH_HEIGHT]| Some(Glyph { width, rows });
|
||||
match c {
|
||||
'0' => g(4, [".##.", "#..#", "#..#", "#..#", "#..#", "#..#", ".##."]),
|
||||
'1' => g(3, [".#.", "##.", ".#.", ".#.", ".#.", ".#.", "###"]),
|
||||
'2' => g(4, [".##.", "#..#", "...#", "..#.", ".#..", "#...", "####"]),
|
||||
'3' => g(4, ["###.", "...#", "...#", ".##.", "...#", "...#", "###."]),
|
||||
'4' => g(4, ["#..#", "#..#", "#..#", "####", "...#", "...#", "...#"]),
|
||||
'5' => g(4, ["####", "#...", "#...", "###.", "...#", "...#", "###."]),
|
||||
'6' => g(4, [".##.", "#...", "#...", "###.", "#..#", "#..#", ".##."]),
|
||||
'7' => g(4, ["####", "...#", "..#.", "..#.", ".#..", ".#..", ".#.."]),
|
||||
'8' => g(4, [".##.", "#..#", "#..#", ".##.", "#..#", "#..#", ".##."]),
|
||||
'9' => g(4, [".##.", "#..#", "#..#", ".###", "...#", "...#", ".##."]),
|
||||
'$' => g(4, ["..#.", ".###", "#.#.", ".##.", ".#.#", "###.", "..#."]),
|
||||
'K' => g(4, ["#..#", "#.#.", "##..", "#...", "##..", "#.#.", "#..#"]),
|
||||
'M' => g(4, ["#..#", "####", "####", "#..#", "#..#", "#..#", "#..#"]),
|
||||
'.' => g(1, [".", ".", ".", ".", ".", ".", "#"]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_width(text: &str) -> usize {
|
||||
let glyphs: Vec<Glyph> = text.chars().filter_map(glyph).collect();
|
||||
if glyphs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
glyphs.iter().map(|g| g.width).sum::<usize>() + GLYPH_GAP * (glyphs.len() - 1)
|
||||
}
|
||||
|
||||
/// Draws `text` centred in a `size` x `size` RGBA icon. Prefers anti-aliased bold system
|
||||
/// text (far more legible at 16px than 1px pixel strokes); falls back to the pixel font
|
||||
/// when no usable font file is present.
|
||||
pub fn render(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
|
||||
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
|
||||
if let Some(image) = render_with_font(text, size, color) {
|
||||
return image;
|
||||
}
|
||||
// The pixel font cannot shrink, so trim from the right until the string fits.
|
||||
let mut trimmed: String = text.to_string();
|
||||
while !fits(&trimmed) && trimmed.pop().is_some() {}
|
||||
render_pixel_font(&trimmed, size, dark_taskbar)
|
||||
}
|
||||
|
||||
/// Bold sans faces shipped with Windows, best first. Bahnschrift's condensed numerals fit
|
||||
/// four glyphs into 16px at a larger size than Segoe UI Bold does.
|
||||
#[cfg(target_os = "windows")]
|
||||
const FONT_CANDIDATES: [&str; 3] = [
|
||||
r"C:\Windows\Fonts\bahnschrift.ttf",
|
||||
r"C:\Windows\Fonts\segoeuib.ttf",
|
||||
r"C:\Windows\Fonts\arialbd.ttf",
|
||||
];
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FONT_CANDIDATES: [&str; 3] = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
];
|
||||
|
||||
/// Largest and smallest text sizes tried, as a fraction of the icon size.
|
||||
const FONT_MAX_FRACTION: f32 = 0.95;
|
||||
const FONT_MIN_FRACTION: f32 = 0.5;
|
||||
const FONT_STEP_PX: f32 = 0.5;
|
||||
|
||||
fn load_font() -> Option<fontdue::Font> {
|
||||
for path in FONT_CANDIDATES {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
if let Ok(font) = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) {
|
||||
return Some(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct Raster {
|
||||
metrics: fontdue::Metrics,
|
||||
bitmap: Vec<u8>,
|
||||
}
|
||||
|
||||
fn layout(font: &fontdue::Font, text: &str, px: f32) -> (Vec<Raster>, f32, i32, i32) {
|
||||
let glyphs: Vec<Raster> = text
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let (metrics, bitmap) = font.rasterize(c, px);
|
||||
Raster { metrics, bitmap }
|
||||
})
|
||||
.collect();
|
||||
let width: f32 = glyphs.iter().map(|g| g.metrics.advance_width).sum();
|
||||
let top = glyphs.iter().map(|g| g.metrics.height as i32 + g.metrics.ymin).max().unwrap_or(0);
|
||||
let bottom = glyphs.iter().map(|g| g.metrics.ymin).min().unwrap_or(0);
|
||||
(glyphs, width, top, bottom)
|
||||
}
|
||||
|
||||
fn render_with_font(text: &str, size: u32, color: [u8; 3]) -> Option<Image<'static>> {
|
||||
let font = load_font()?;
|
||||
let limit = size as f32;
|
||||
let mut px = limit * FONT_MAX_FRACTION;
|
||||
let mut chosen = None;
|
||||
while px >= limit * FONT_MIN_FRACTION {
|
||||
let (glyphs, width, top, bottom) = layout(&font, text, px);
|
||||
let height = (top - bottom) as f32;
|
||||
if width <= limit && height <= limit {
|
||||
chosen = Some((glyphs, width, top, bottom));
|
||||
break;
|
||||
}
|
||||
px -= FONT_STEP_PX;
|
||||
}
|
||||
let (glyphs, width, top, bottom) = chosen?;
|
||||
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
let height = top - bottom;
|
||||
let x0 = ((limit - width) / 2.0).round();
|
||||
let baseline = ((size as i32 - height) / 2) + top;
|
||||
let mut pen = x0;
|
||||
for g in &glyphs {
|
||||
let gx = pen.round() as i32 + g.metrics.xmin;
|
||||
let gy = baseline - g.metrics.height as i32 - g.metrics.ymin;
|
||||
for row in 0..g.metrics.height {
|
||||
for col in 0..g.metrics.width {
|
||||
let alpha = g.bitmap[row * g.metrics.width + col];
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let px_x = gx + col as i32;
|
||||
let px_y = gy + row as i32;
|
||||
if px_x < 0 || px_y < 0 || px_x >= size as i32 || px_y >= size as i32 {
|
||||
continue;
|
||||
}
|
||||
let i = ((px_y as u32 * size + px_x as u32) * 4) as usize;
|
||||
let existing = rgba[i + 3];
|
||||
let merged = existing.max(alpha);
|
||||
rgba[i] = color[0];
|
||||
rgba[i + 1] = color[1];
|
||||
rgba[i + 2] = color[2];
|
||||
rgba[i + 3] = merged;
|
||||
}
|
||||
}
|
||||
pen += g.metrics.advance_width;
|
||||
}
|
||||
Some(Image::new_owned(rgba, size, size))
|
||||
}
|
||||
|
||||
fn render_pixel_font(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
|
||||
let size = size.max(BASE_ICON_SIZE);
|
||||
let scale = (size / BASE_ICON_SIZE).max(1) as usize;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
|
||||
|
||||
let width = text_width(text) * scale;
|
||||
let height = GLYPH_HEIGHT * scale;
|
||||
let mut x = (size as usize).saturating_sub(width) / 2;
|
||||
let y0 = (size as usize).saturating_sub(height) / 2;
|
||||
|
||||
for g in text.chars().filter_map(glyph) {
|
||||
for (row, bits) in g.rows.iter().enumerate() {
|
||||
for (col, ch) in bits.chars().enumerate() {
|
||||
if ch != '#' {
|
||||
continue;
|
||||
}
|
||||
for dy in 0..scale {
|
||||
for dx in 0..scale {
|
||||
let px = x + col * scale + dx;
|
||||
let py = y0 + row * scale + dy;
|
||||
if px < size as usize && py < size as usize {
|
||||
let i = (py * size as usize + px) * 4;
|
||||
rgba[i] = color[0];
|
||||
rgba[i + 1] = color[1];
|
||||
rgba[i + 2] = color[2];
|
||||
rgba[i + 3] = 0xFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x += (g.width + GLYPH_GAP) * scale;
|
||||
}
|
||||
|
||||
Image::new_owned(rgba, size, size)
|
||||
}
|
||||
|
||||
/// Whether the text fits the 16px pixel-font grid (the font path scales itself to fit).
|
||||
fn fits(text: &str) -> bool {
|
||||
text_width(text) <= BASE_ICON_SIZE as usize
|
||||
}
|
||||
|
||||
/// The panel's small-icon size in physical pixels (16 at 100%, 20 at 125%, 24 at 150%).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn small_icon_size() -> u32 {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON};
|
||||
let px = unsafe { GetSystemMetrics(SM_CXSMICON) };
|
||||
if px > 0 { px as u32 } else { BASE_ICON_SIZE }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn small_icon_size() -> u32 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Windows keeps the taskbar theme separate from the app theme; the number must contrast
|
||||
/// with the taskbar, not the popover.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn taskbar_is_dark() -> bool {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let output = std::process::Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
||||
"/v",
|
||||
"SystemUsesLightTheme",
|
||||
])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// "0x0" means the system (taskbar) uses the dark theme.
|
||||
text.lines().any(|l| l.contains("SystemUsesLightTheme") && l.trim_end().ends_with("0x0"))
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn taskbar_is_dark() -> bool {
|
||||
true
|
||||
}
|
||||
139
windows/src-tauri/src/tray_linux.rs
Normal file
139
windows/src-tauri/src/tray_linux.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ksni::{Category, Icon, Status, ToolTip, Tray, TrayMethods};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// StatusNotifierItem-backed tray for Linux. Bypasses libappindicator so left-click
|
||||
/// fires `activate(x, y)` with real screen coordinates, which is what Tauri's Linux
|
||||
/// tray path cannot deliver. See tauri-apps/tauri#7283 for the upstream gap.
|
||||
///
|
||||
/// No menu() is exported. Exporting a menu causes most SNI hosts (notably
|
||||
/// gnome-shell-extension-appindicator) to swallow left-click as a menu-open and
|
||||
/// never fire Activate. Quit/Refresh/Open Full Report live in the popover footer.
|
||||
pub struct CodeburnTray {
|
||||
app: AppHandle,
|
||||
title: String,
|
||||
icon: Vec<Icon>,
|
||||
}
|
||||
|
||||
impl CodeburnTray {
|
||||
fn new(app: AppHandle, icon: Vec<Icon>) -> Self {
|
||||
Self {
|
||||
app,
|
||||
title: "CodeBurn".to_string(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tray for CodeburnTray {
|
||||
fn id(&self) -> String {
|
||||
"org.agentseal.codeburn".to_string()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn category(&self) -> Category {
|
||||
Category::ApplicationStatus
|
||||
}
|
||||
|
||||
fn status(&self) -> Status {
|
||||
Status::Active
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<Icon> {
|
||||
self.icon.clone()
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ToolTip {
|
||||
ToolTip {
|
||||
icon_name: String::new(),
|
||||
icon_pixmap: Vec::new(),
|
||||
title: "CodeBurn".to_string(),
|
||||
description: self.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self, x: i32, y: i32) {
|
||||
let _ = self
|
||||
.app
|
||||
.emit("codeburn://tray-activate", TrayClick { x, y });
|
||||
}
|
||||
|
||||
fn secondary_activate(&mut self, x: i32, y: i32) {
|
||||
let _ = self
|
||||
.app
|
||||
.emit("codeburn://tray-secondary", TrayClick { x, y });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct TrayClick {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
/// Type-erased handle for the Linux tray so callers can push title updates without
|
||||
/// naming the `ksni::Handle<CodeburnTray>` generic parameter across module boundaries.
|
||||
#[derive(Clone)]
|
||||
pub struct LinuxTrayHandle {
|
||||
inner: Arc<Mutex<Option<ksni::Handle<CodeburnTray>>>>,
|
||||
}
|
||||
|
||||
impl LinuxTrayHandle {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, handle: ksni::Handle<CodeburnTray>) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Decode the bundled tray.png into ARGB32 pixels that the SNI spec expects.
|
||||
/// Falls back to an empty icon list (host shows a broken-icon placeholder) if the
|
||||
/// asset can't be decoded. We'd rather render a blank icon than crash the tray.
|
||||
fn load_icon() -> Vec<Icon> {
|
||||
// Embedded at build time so the binary is self-contained.
|
||||
let bytes = include_bytes!("../icons/tray.png");
|
||||
let Ok(decoder) = png::Decoder::new(bytes.as_slice()).read_info().map_err(|_| ()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
decode_png(decoder)
|
||||
}
|
||||
|
||||
fn decode_png(mut reader: png::Reader<&[u8]>) -> Vec<Icon> {
|
||||
let info = reader.info().clone();
|
||||
let width = info.width as i32;
|
||||
let height = info.height as i32;
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()];
|
||||
if reader.next_frame(&mut buf).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
// SNI expects ARGB32 in network byte order. PNG decoder gives RGBA8.
|
||||
let pixel_count = (width as usize) * (height as usize);
|
||||
let mut argb = Vec::with_capacity(pixel_count * 4);
|
||||
for chunk in buf.chunks_exact(4) {
|
||||
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
|
||||
argb.extend_from_slice(&[a, r, g, b]);
|
||||
}
|
||||
vec![Icon {
|
||||
width,
|
||||
height,
|
||||
data: argb,
|
||||
}]
|
||||
}
|
||||
|
||||
pub async fn spawn(app: AppHandle, handle_out: LinuxTrayHandle) -> anyhow::Result<()> {
|
||||
let tray = CodeburnTray::new(app, load_icon());
|
||||
let handle = tray.spawn().await?;
|
||||
handle_out.set(handle);
|
||||
Ok(())
|
||||
}
|
||||
64
windows/src-tauri/tauri.conf.json
Normal file
64
windows/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeBurn Menubar",
|
||||
"version": "0.9.20",
|
||||
"identifier": "org.agentseal.codeburn-menubar",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "popover",
|
||||
"title": "CodeBurn",
|
||||
"width": 360,
|
||||
"height": 660,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"resizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"visible": false,
|
||||
"focus": false,
|
||||
"shadow": true,
|
||||
"windowEffects": {
|
||||
"effects": ["acrylic"],
|
||||
"state": "active"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: https://api.frankfurter.app"
|
||||
},
|
||||
"trayIcon": {
|
||||
"id": "codeburn-tray",
|
||||
"iconPath": "icons/tray.png",
|
||||
"iconAsTemplate": false,
|
||||
"tooltip": "CodeBurn"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "Utility",
|
||||
"shortDescription": "AI coding cost tracker",
|
||||
"longDescription": "Shows today's AI coding spend in your system tray. Popover breaks down cost by activity, model, and provider across Claude Code, Cursor, Codex, and more.",
|
||||
"publisher": "AgentSeal",
|
||||
"homepage": "https://github.com/getagentseal/codeburn",
|
||||
"targets": ["deb", "rpm", "appimage", "msi"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
352
windows/src/App.tsx
Normal file
352
windows/src/App.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
import type { MenubarPayload } from './lib/payload'
|
||||
import type { CurrencyState } from './lib/currency'
|
||||
import { USD, formatCurrency, trayBadgeText } from './lib/currency'
|
||||
import { PayloadCache } from './lib/cache'
|
||||
import { relativePast } from './lib/dates'
|
||||
import { applyTheme, currentTheme, readSetting, writeSetting } from './lib/settings'
|
||||
import { AgentTabStrip, detectedProviders } from './components/AgentTabStrip'
|
||||
import type { Provider } from './components/AgentTabStrip'
|
||||
import { ModelsSection } from './components/ModelsSection'
|
||||
import { InsightPills, INSIGHT_ORDER, isInsightMode, type InsightMode } from './components/InsightPills'
|
||||
import { TrendInsight } from './components/TrendInsight'
|
||||
import { ForecastInsight } from './components/ForecastInsight'
|
||||
import { PulseInsight } from './components/PulseInsight'
|
||||
import { StatsInsight } from './components/StatsInsight'
|
||||
import { PlanInsight } from './components/PlanInsight'
|
||||
import { FindingsSection } from './components/FindingsSection'
|
||||
import { ActivitySection } from './components/ActivitySection'
|
||||
import { LoadingOverlay } from './components/LoadingOverlay'
|
||||
import { EmptyProviderState } from './components/EmptyProviderState'
|
||||
import { NoDataState } from './components/NoDataState'
|
||||
import { SetupState, type CliStatus } from './components/SetupState'
|
||||
import { StarBanner } from './components/StarBanner'
|
||||
import { HeroSection } from './components/HeroSection'
|
||||
import { PeriodTabs, PERIOD_LABELS } from './components/PeriodTabs'
|
||||
import type { Period } from './components/PeriodTabs'
|
||||
import { FooterBar } from './components/FooterBar'
|
||||
import { ErrorToast } from './components/ErrorToast'
|
||||
import { SettingsPanel, type ThemeChoice } from './components/SettingsPanel'
|
||||
|
||||
const payloadCache = new PayloadCache<MenubarPayload>()
|
||||
|
||||
/// Background cadence. Every tick refreshes today/all (tray tooltip, provider badges) and
|
||||
/// the visible period/provider; entries younger than STALE_MS are left alone when the
|
||||
/// popover is re-opened.
|
||||
const REFRESH_INTERVAL_MS = 60_000
|
||||
const STALE_MS = 60_000
|
||||
|
||||
type FetchOptions = {
|
||||
includeOptimize: boolean
|
||||
showOverlay: boolean
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState<Provider>('all')
|
||||
const [payload, setPayload] = useState<MenubarPayload | null>(null)
|
||||
const [todayPayload, setTodayPayload] = useState<MenubarPayload | null>(null)
|
||||
const [currency, setCurrency] = useState<CurrencyState>(USD)
|
||||
const [overlay, setOverlay] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [insight, setInsight] = useState<InsightMode>(() => {
|
||||
const saved = readSetting('insight')
|
||||
return isInsightMode(saved) ? saved : 'trend'
|
||||
})
|
||||
const [cliStatus, setCliStatus] = useState<CliStatus | null>(null)
|
||||
const [cliChecking, setCliChecking] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const [theme, setTheme] = useState(() => currentTheme())
|
||||
const [trayBadge, setTrayBadge] = useState(() => readSetting('trayBadge') !== 'off')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [themeChoice, setThemeChoice] = useState<ThemeChoice>(() => {
|
||||
const saved = readSetting('theme')
|
||||
return saved === 'dark' || saved === 'light' ? saved : 'system'
|
||||
})
|
||||
|
||||
const selection = useRef({ period, provider })
|
||||
selection.current = { period, provider }
|
||||
|
||||
const fetchKey = useCallback(async (p: Period, prov: Provider, opts: FetchOptions) => {
|
||||
if (payloadCache.isInFlight(p, prov)) return
|
||||
payloadCache.markInFlight(p, prov)
|
||||
const isSelected = () => selection.current.period === p && selection.current.provider === prov
|
||||
if (opts.showOverlay && isSelected()) setOverlay(true)
|
||||
try {
|
||||
const json = await invoke<MenubarPayload>('fetch_payload', {
|
||||
period: p,
|
||||
provider: prov,
|
||||
includeOptimize: opts.includeOptimize,
|
||||
})
|
||||
// A quiet (no-optimize) refresh must not wipe findings a previous full fetch had.
|
||||
if (!opts.includeOptimize) {
|
||||
const previous = payloadCache.get(p, prov)
|
||||
if (previous) json.optimize = previous.optimize
|
||||
}
|
||||
payloadCache.set(p, prov, json)
|
||||
if (isSelected()) setPayload(json)
|
||||
if (p === 'today' && prov === 'all') setTodayPayload(json)
|
||||
setLastUpdated(new Date())
|
||||
setCliStatus(prev => (prev && !(prev.found && prev.compatible) ? { ...prev, found: true, compatible: true, error: null } : prev))
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('CLI not found')) {
|
||||
const status = await invoke<CliStatus>('cli_status').catch(() => null)
|
||||
if (status) setCliStatus(status)
|
||||
} else if (isSelected()) {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
payloadCache.clearInFlight(p, prov)
|
||||
if (isSelected()) setOverlay(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshAll = useCallback(async (opts: FetchOptions) => {
|
||||
const { period: p, provider: prov } = selection.current
|
||||
if (!(p === 'today' && prov === 'all')) {
|
||||
fetchKey('today', 'all', { includeOptimize: false, showOverlay: false })
|
||||
}
|
||||
await fetchKey(p, prov, opts)
|
||||
}, [fetchKey])
|
||||
|
||||
const probeCli = useCallback(async () => {
|
||||
setCliChecking(true)
|
||||
try {
|
||||
setCliStatus(await invoke<CliStatus>('cli_status'))
|
||||
} catch {
|
||||
// Leave the status unknown; the data views already tell the user if fetches fail.
|
||||
} finally {
|
||||
setCliChecking(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkCli = useCallback(async () => {
|
||||
setCliChecking(true)
|
||||
try {
|
||||
const status = await invoke<CliStatus>('cli_status')
|
||||
setCliStatus(status)
|
||||
if (status.found && status.compatible) {
|
||||
refreshAll({ includeOptimize: true, showOverlay: true })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setCliChecking(false)
|
||||
}
|
||||
}, [refreshAll])
|
||||
|
||||
useEffect(() => {
|
||||
invoke<string>('app_version').then(setVersion).catch(() => {})
|
||||
refreshAll({ includeOptimize: true, showOverlay: true })
|
||||
const id = setInterval(() => refreshAll({ includeOptimize: true, showOverlay: false }), REFRESH_INTERVAL_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [refreshAll])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = payloadCache.get(period, provider)
|
||||
setPayload(cached)
|
||||
if (!cached) {
|
||||
fetchKey(period, provider, { includeOptimize: true, showOverlay: true })
|
||||
} else if (payloadCache.age(period, provider) > STALE_MS) {
|
||||
fetchKey(period, provider, { includeOptimize: true, showOverlay: false })
|
||||
}
|
||||
}, [period, provider, fetchKey])
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenRefresh = listen('codeburn://refresh', () => refreshAll({ includeOptimize: true, showOverlay: true }))
|
||||
const unlistenShown = listen('codeburn://shown', () => {
|
||||
const { period: p, provider: prov } = selection.current
|
||||
if (payloadCache.age(p, prov) > STALE_MS) refreshAll({ includeOptimize: true, showOverlay: false })
|
||||
})
|
||||
const unlistenTheme = listen('codeburn://toggle-theme', () => toggleTheme())
|
||||
return () => {
|
||||
unlistenRefresh.then(fn => fn())
|
||||
unlistenShown.then(fn => fn())
|
||||
unlistenTheme.then(fn => fn())
|
||||
}
|
||||
}, [refreshAll])
|
||||
|
||||
useEffect(() => {
|
||||
const saved = readSetting('theme')
|
||||
if (saved === 'dark' || saved === 'light') applyTheme(saved)
|
||||
setTheme(currentTheme())
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => setTheme(currentTheme())
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') invoke('hide_popover').catch(() => {})
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!todayPayload) return
|
||||
const text = `CodeBurn · ${formatCurrency(todayPayload.current.cost, currency)} today`
|
||||
invoke('set_tray_tooltip', { text }).catch(() => {})
|
||||
}, [todayPayload, currency])
|
||||
|
||||
useEffect(() => {
|
||||
const text = trayBadge && todayPayload ? trayBadgeText(todayPayload.current.cost, currency) : null
|
||||
invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`))
|
||||
}, [todayPayload, currency, trayBadge])
|
||||
|
||||
|
||||
const chooseTheme = (choice: ThemeChoice) => {
|
||||
applyTheme(choice === 'system' ? null : choice)
|
||||
setThemeChoice(choice)
|
||||
setTheme(currentTheme())
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
chooseTheme(currentTheme() === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const setTrayBadgePref = (on: boolean) => {
|
||||
setTrayBadge(on)
|
||||
writeSetting('trayBadge', on ? 'on' : 'off')
|
||||
}
|
||||
|
||||
const applyCurrency = async (code: string) => {
|
||||
try {
|
||||
setCurrency(await invoke<CurrencyState>('set_currency', { code }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const openTerminal = (args: string[]) => {
|
||||
invoke('open_terminal_command', { args }).catch(err => setError(String(err)))
|
||||
}
|
||||
const connectClaude = () => {
|
||||
invoke('open_claude_login').catch(err => setError(String(err)))
|
||||
}
|
||||
|
||||
const selectInsight = (mode: InsightMode) => {
|
||||
setInsight(mode)
|
||||
writeSetting('insight', mode)
|
||||
}
|
||||
|
||||
const providers = detectedProviders(todayPayload)
|
||||
const planVisible = provider === 'claude' || (provider === 'all' && providers.length === 1 && providers[0] === 'claude')
|
||||
const visibleModes = useMemo(
|
||||
() => INSIGHT_ORDER.filter(m => m !== 'plan' || planVisible),
|
||||
[planVisible],
|
||||
)
|
||||
const activeInsight = visibleModes.includes(insight) ? insight : 'trend'
|
||||
|
||||
const cliBlocked = cliStatus !== null && (!cliStatus.found || !cliStatus.compatible)
|
||||
const isFilteredEmpty = payload !== null && provider !== 'all' && payload.current.cost <= 0 && payload.current.calls === 0
|
||||
const neverAnyData = payload !== null && provider === 'all'
|
||||
&& payload.current.calls === 0 && payload.current.sessions === 0 && payload.history.daily.length === 0
|
||||
|
||||
const footnote = [version ? `CodeBurn v${version}` : 'CodeBurn', lastUpdated ? `updated ${relativePast(lastUpdated)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<div className="popover">
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<span className="brand-primary">Code</span>
|
||||
<span className="brand-accent">Burn</span>
|
||||
</div>
|
||||
<div className="subhead">AI Coding Cost Tracker</div>
|
||||
</header>
|
||||
|
||||
{!cliBlocked && !showSettings && (
|
||||
<AgentTabStrip selected={provider} onSelect={setProvider} payload={todayPayload} currency={currency} />
|
||||
)}
|
||||
|
||||
<div className="main-content">
|
||||
{showSettings ? (
|
||||
<SettingsPanel
|
||||
onBack={() => setShowSettings(false)}
|
||||
version={version}
|
||||
currency={currency}
|
||||
onCurrency={applyCurrency}
|
||||
themeChoice={themeChoice}
|
||||
onThemeChoice={chooseTheme}
|
||||
trayBadge={trayBadge}
|
||||
onTrayBadge={setTrayBadgePref}
|
||||
cliStatus={cliStatus}
|
||||
onCheckCli={checkCli}
|
||||
onProbeCli={probeCli}
|
||||
cliChecking={cliChecking}
|
||||
onQuit={() => invoke('quit_app').catch(() => {})}
|
||||
/>
|
||||
) : cliBlocked && cliStatus ? (
|
||||
<SetupState status={cliStatus} checking={cliChecking} onCheckAgain={checkCli} />
|
||||
) : (
|
||||
<>
|
||||
<HeroSection payload={payload} currency={currency} periodLabel={PERIOD_LABELS[period]} isToday={period === 'today'} />
|
||||
<PeriodTabs selected={period} onSelect={setPeriod} />
|
||||
|
||||
{isFilteredEmpty ? (
|
||||
<EmptyProviderState provider={provider} period={period} />
|
||||
) : neverAnyData ? (
|
||||
<NoDataState onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })} />
|
||||
) : (
|
||||
<>
|
||||
<div className="insight-area">
|
||||
<InsightPills selected={activeInsight} onSelect={selectInsight} modes={visibleModes} />
|
||||
{activeInsight === 'plan' && (
|
||||
<PlanInsight payload={payload} currency={currency} onOpenTerminal={openTerminal} onConnectClaude={connectClaude} />
|
||||
)}
|
||||
{activeInsight === 'trend' && <TrendInsight days={payload?.history.daily ?? []} currency={currency} />}
|
||||
{activeInsight === 'forecast' && <ForecastInsight days={payload?.history.daily ?? []} currency={currency} />}
|
||||
{activeInsight === 'pulse' && payload && <PulseInsight payload={payload} currency={currency} />}
|
||||
{activeInsight === 'stats' && payload && <StatsInsight payload={payload} currency={currency} period={period} />}
|
||||
</div>
|
||||
{payload && (
|
||||
<>
|
||||
<ActivitySection payload={payload} currency={currency} />
|
||||
<ModelsSection
|
||||
models={payload.current.topModels}
|
||||
inputTokens={payload.current.inputTokens}
|
||||
outputTokens={payload.current.outputTokens}
|
||||
cacheHitPercent={payload.current.cacheHitPercent}
|
||||
currency={currency}
|
||||
/>
|
||||
<FindingsSection payload={payload} currency={currency} onOpenTerminal={openTerminal} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overlay && <LoadingOverlay periodLabel={PERIOD_LABELS[period]} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FooterBar
|
||||
currency={currency}
|
||||
onCurrency={applyCurrency}
|
||||
loading={overlay}
|
||||
onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })}
|
||||
onExport={format => openTerminal(['export', '-f', format])}
|
||||
onOpenReport={() => openTerminal(['report'])}
|
||||
onToggleTheme={toggleTheme}
|
||||
onQuit={() => invoke('quit_app').catch(() => {})}
|
||||
themeLabel={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
trayBadge={trayBadge}
|
||||
onToggleTrayBadge={() => setTrayBadgePref(!trayBadge)}
|
||||
onOpenSettings={() => setShowSettings(s => !s)}
|
||||
settingsOpen={showSettings}
|
||||
footnote={footnote}
|
||||
/>
|
||||
|
||||
<StarBanner />
|
||||
|
||||
{error && <ErrorToast message={error} onDismiss={() => setError(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
windows/src/components/ActivitySection.tsx
Normal file
52
windows/src/components/ActivitySection.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency } from '../lib/currency'
|
||||
import { CollapsibleSection } from './CollapsibleSection'
|
||||
|
||||
/// Column widths shared with the header captions (mac: Cost 54 / Turns 52 / 1-shot 44).
|
||||
export const COL_COST = 54
|
||||
export const COL_COUNT = 52
|
||||
export const COL_ONESHOT = 44
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function ActivitySection({ payload, currency }: Props) {
|
||||
const activities = payload.current.topActivities
|
||||
if (activities.length === 0) return null
|
||||
const maxCost = Math.max(...activities.map(a => a.cost), 0.01)
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
caption="Activity"
|
||||
columns={[
|
||||
{ label: 'Cost', width: COL_COST },
|
||||
{ label: 'Turns', width: COL_COUNT },
|
||||
{ label: '1-shot', width: COL_ONESHOT },
|
||||
]}
|
||||
>
|
||||
{activities.map(a => (
|
||||
<div key={a.name} className="data-row">
|
||||
<FixedBar fraction={a.cost / maxCost} />
|
||||
<span className="row-name">{a.name}</span>
|
||||
<span className="row-cost" style={{ minWidth: COL_COST }}>{formatCompactCurrency(a.cost, currency)}</span>
|
||||
<span className="row-count" style={{ minWidth: COL_COUNT }}>{a.turns}</span>
|
||||
<span className="row-oneshot" style={{ minWidth: COL_ONESHOT }}>
|
||||
{a.oneShotRate == null ? '-' : `${Math.round(a.oneShotRate * 100)}%`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)
|
||||
}
|
||||
|
||||
export function FixedBar({ fraction }: { fraction: number }) {
|
||||
const pct = Math.min(Math.max(fraction, 0), 1) * 100
|
||||
return (
|
||||
<span className="fixed-bar" aria-hidden="true">
|
||||
<span className="fixed-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
116
windows/src/components/AgentTabStrip.tsx
Normal file
116
windows/src/components/AgentTabStrip.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useRef, useState, type WheelEvent } from 'react'
|
||||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency, formatCurrency, plural } from '../lib/currency'
|
||||
import { homePath } from '../lib/platform'
|
||||
|
||||
export type Provider = 'all' | 'claude' | 'codex' | 'cursor' | 'copilot' | 'opencode' | 'pi'
|
||||
|
||||
/// Same order as the macOS ProviderFilter.allCases.
|
||||
export const ALL_PROVIDERS: Array<{ id: Provider; label: string; source: string }> = [
|
||||
{ id: 'all', label: 'All', source: 'every detected tool' },
|
||||
{ id: 'claude', label: 'Claude', source: `Claude Code sessions in ${homePath('.claude', 'projects')}` },
|
||||
{ id: 'codex', label: 'Codex', source: `Codex CLI sessions in ${homePath('.codex', 'sessions')}` },
|
||||
{ id: 'cursor', label: 'Cursor', source: 'the Cursor IDE local database' },
|
||||
{ id: 'copilot', label: 'Copilot', source: 'GitHub Copilot session events' },
|
||||
{ id: 'opencode', label: 'OpenCode', source: 'OpenCode session storage' },
|
||||
{ id: 'pi', label: 'Pi', source: 'Pi session logs' },
|
||||
]
|
||||
|
||||
export const PROVIDER_LABELS: Record<Provider, string> = Object.fromEntries(
|
||||
ALL_PROVIDERS.map(p => [p.id, p.label]),
|
||||
) as Record<Provider, string>
|
||||
|
||||
/// Providers the CLI detected on this machine (installed, even with zero spend today).
|
||||
export function detectedProviders(payload: MenubarPayload | null): Provider[] {
|
||||
if (!payload) return []
|
||||
const detected = payload.current.providers
|
||||
return ALL_PROVIDERS.map(p => p.id).filter(id => id !== 'all' && id in detected)
|
||||
}
|
||||
|
||||
type Props = {
|
||||
selected: Provider
|
||||
onSelect: (p: Provider) => void
|
||||
payload: MenubarPayload | null
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
/// Every supported tool is listed so the reader can see at a glance which ones CodeBurn
|
||||
/// is watching. Tools that are not installed are dimmed and explain themselves on hover;
|
||||
/// detected tools show today's spend and a hover preview with their share.
|
||||
export function AgentTabStrip({ selected, onSelect, payload, currency }: Props) {
|
||||
const [hovered, setHovered] = useState<Provider | null>(null)
|
||||
const scroller = useRef<HTMLDivElement>(null)
|
||||
const providers = detectedProviders(payload)
|
||||
const costs = payload?.current.providers ?? {}
|
||||
const total = providers.reduce((s, id) => s + (costs[id] ?? 0), 0)
|
||||
|
||||
const onWheel = (e: WheelEvent<HTMLDivElement>) => {
|
||||
if (scroller.current && e.deltaY !== 0 && e.deltaX === 0) {
|
||||
scroller.current.scrollLeft += e.deltaY
|
||||
}
|
||||
}
|
||||
|
||||
const preview = hovered ? previewFor(hovered, providers, costs, total, currency) : null
|
||||
|
||||
return (
|
||||
<div className="agent-tabs-wrap" onMouseLeave={() => setHovered(null)}>
|
||||
<nav className="agent-tabs" aria-label="Provider" ref={scroller} onWheel={onWheel}>
|
||||
{ALL_PROVIDERS.map(p => {
|
||||
const detected = p.id === 'all' ? providers.length > 0 : providers.includes(p.id)
|
||||
const cost = p.id === 'all' ? total : (costs[p.id] ?? 0)
|
||||
const active = selected === p.id
|
||||
return (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className={`tab ${active ? 'tab-active' : ''} ${detected ? '' : 'tab-muted'}`}
|
||||
aria-pressed={active}
|
||||
aria-disabled={!detected}
|
||||
onMouseEnter={() => setHovered(p.id)}
|
||||
onFocus={() => setHovered(p.id)}
|
||||
onClick={() => { if (detected) onSelect(p.id) }}
|
||||
>
|
||||
<span className="tab-label">{p.label}</span>
|
||||
{detected && cost > 0 && (
|
||||
<span className="tab-cost">{formatCompactCurrency(cost, currency)}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{preview && (
|
||||
<div className="tab-preview" role="tooltip">
|
||||
<div className="tab-preview-title">{preview.title}</div>
|
||||
<div className="tab-preview-body">{preview.body}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function previewFor(
|
||||
id: Provider,
|
||||
providers: Provider[],
|
||||
costs: Record<string, number>,
|
||||
total: number,
|
||||
currency: CurrencyState,
|
||||
): { title: string; body: string } {
|
||||
const meta = ALL_PROVIDERS.find(p => p.id === id)!
|
||||
if (id === 'all') {
|
||||
if (providers.length === 0) return { title: 'No tools detected yet', body: 'Run one of the supported tools once, then refresh.' }
|
||||
return {
|
||||
title: `${formatCurrency(total, currency)} today across ${plural(providers.length, 'tool')}`,
|
||||
body: providers.map(p => `${PROVIDER_LABELS[p]} ${formatCompactCurrency(costs[p] ?? 0, currency)}`).join(' · '),
|
||||
}
|
||||
}
|
||||
if (!providers.includes(id)) {
|
||||
return { title: `${meta.label} not detected on this machine`, body: `CodeBurn watches ${meta.source}.` }
|
||||
}
|
||||
const cost = costs[id] ?? 0
|
||||
const share = total > 0 ? Math.round((cost / total) * 100) : 0
|
||||
return {
|
||||
title: `${meta.label} · ${formatCurrency(cost, currency)} today`,
|
||||
body: cost > 0 ? `${share}% of today's spend · click to filter every view` : 'No spend yet today · click to filter every view',
|
||||
}
|
||||
}
|
||||
44
windows/src/components/CollapsibleSection.tsx
Normal file
44
windows/src/components/CollapsibleSection.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { useState, type ReactNode } from 'react'
|
||||
import { ChevronRight } from './Icons'
|
||||
|
||||
/// The macOS CollapsibleSection shell: 3px brand dot + caption, trailing column headers,
|
||||
/// a chevron that rotates 90 degrees when open. Same component for Activity and Models so
|
||||
/// the two headers can never drift apart again.
|
||||
|
||||
type Props = {
|
||||
caption: string
|
||||
columns?: Array<{ label: string; width: number }>
|
||||
defaultExpanded?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleSection({ caption, columns = [], defaultExpanded = true, children }: Props) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
return (
|
||||
<section className="collapsible">
|
||||
<button
|
||||
type="button"
|
||||
className="collapsible-header"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
>
|
||||
<SectionCaption text={caption} />
|
||||
<span className="collapsible-spacer" />
|
||||
{expanded && columns.map(c => (
|
||||
<span key={c.label} className="col-header" style={{ minWidth: c.width }}>{c.label}</span>
|
||||
))}
|
||||
<ChevronRight size={9} className={`chevron ${expanded ? 'chevron-open' : ''}`} />
|
||||
</button>
|
||||
{expanded && <div className="collapsible-body">{children}</div>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function SectionCaption({ text, muted = true }: { text: string; muted?: boolean }) {
|
||||
return (
|
||||
<span className={`section-caption ${muted ? '' : 'section-caption-strong'}`}>
|
||||
<span className="section-dot" />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
93
windows/src/components/DropMenu.tsx
Normal file
93
windows/src/components/DropMenu.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { CheckIcon } from './Icons'
|
||||
|
||||
/// A bordered footer button that opens a small menu above itself, standing in for the
|
||||
/// macOS `Menu` with `.bordered` style. Closes on outside click, Escape, or selection.
|
||||
|
||||
export type MenuItem = {
|
||||
id: string
|
||||
label: string
|
||||
checked?: boolean
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
separatorBefore?: boolean
|
||||
}
|
||||
|
||||
type Props = {
|
||||
label: ReactNode
|
||||
title?: string
|
||||
items: MenuItem[]
|
||||
onSelect: (id: string) => void
|
||||
align?: 'left' | 'right'
|
||||
className?: string
|
||||
/// Optional read-only footer line under the items (version, last update).
|
||||
footnote?: string
|
||||
/// Lay the items out in a grid (used for the 17-currency picker) instead of one column.
|
||||
columns?: number
|
||||
}
|
||||
|
||||
export function DropMenu({ label, title, items, onSelect, align = 'left', className = '', footnote, columns = 1 }: Props) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation()
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
document.addEventListener('keydown', onKey, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDown)
|
||||
document.removeEventListener('keydown', onKey, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className={`dropmenu ${className}`} ref={rootRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn ${open ? 'btn-pressed' : ''}`}
|
||||
title={title}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{open && (
|
||||
<div
|
||||
className={`dropmenu-panel dropmenu-${align} ${columns > 1 ? 'dropmenu-grid' : ''}`}
|
||||
role="menu"
|
||||
style={columns > 1 ? { gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` } : undefined}
|
||||
>
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="dropmenu-item-wrap">
|
||||
{item.separatorBefore && <div className="dropmenu-sep" />}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`dropmenu-item ${item.danger ? 'dropmenu-danger' : ''}`}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
onSelect(item.id)
|
||||
}}
|
||||
>
|
||||
<span className="dropmenu-check">{item.checked && <CheckIcon size={11} />}</span>
|
||||
<span className="dropmenu-label">{item.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{footnote && <div className="dropmenu-footnote">{footnote}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
19
windows/src/components/EmptyProviderState.tsx
Normal file
19
windows/src/components/EmptyProviderState.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import type { Provider } from './AgentTabStrip'
|
||||
import { PROVIDER_LABELS } from './AgentTabStrip'
|
||||
import type { Period } from './PeriodTabs'
|
||||
import { PERIOD_PHRASES } from './PeriodTabs'
|
||||
import { TrayIcon } from './Icons'
|
||||
|
||||
type Props = {
|
||||
provider: Provider
|
||||
period: Period
|
||||
}
|
||||
|
||||
export function EmptyProviderState({ provider, period }: Props) {
|
||||
return (
|
||||
<div className="empty-provider">
|
||||
<TrayIcon size={26} className="empty-provider-icon" />
|
||||
<div className="empty-provider-text">No {PROVIDER_LABELS[provider]} data for {PERIOD_PHRASES[period]}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
windows/src/components/ErrorToast.tsx
Normal file
29
windows/src/components/ErrorToast.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import { XIcon } from './Icons'
|
||||
|
||||
const AUTO_DISMISS_MS = 8_000
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export function ErrorToast({ message, onDismiss }: Props) {
|
||||
// Keep the latest handler in a ref so re-renders of the parent do not restart the timer.
|
||||
const dismissRef = useRef(onDismiss)
|
||||
dismissRef.current = onDismiss
|
||||
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => dismissRef.current(), AUTO_DISMISS_MS)
|
||||
return () => clearTimeout(id)
|
||||
}, [message])
|
||||
|
||||
return (
|
||||
<div className="error-toast" role="alert">
|
||||
<span className="error-toast-text">{message}</span>
|
||||
<button type="button" className="error-toast-close" onClick={onDismiss} aria-label="Dismiss">
|
||||
<XIcon size={9} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
windows/src/components/FindingsSection.tsx
Normal file
72
windows/src/components/FindingsSection.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { useState } from 'react'
|
||||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { computeTipGroups, type TipGroup } from '../lib/tips'
|
||||
import { plural } from '../lib/currency'
|
||||
import { ArrowForward, ArrowUpRightCircleIcon, BulbIcon, CheckCircleIcon, ChevronRight, WarningIcon } from './Icons'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
onOpenTerminal: (args: string[]) => void
|
||||
}
|
||||
|
||||
export function FindingsSection({ payload, currency, onOpenTerminal }: Props) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const groups = computeTipGroups(payload, currency)
|
||||
const totalSignals = groups.reduce((s, g) => s + g.items.length, 0)
|
||||
if (totalSignals === 0) return null
|
||||
|
||||
return (
|
||||
<section className="findings-wrap">
|
||||
<div className="findings-card">
|
||||
<button type="button" className="findings-header" aria-expanded={expanded} onClick={() => setExpanded(e => !e)}>
|
||||
<span className="findings-header-left">
|
||||
<BulbIcon size={11} className="findings-icon" />
|
||||
<span className="findings-title">Tips for you</span>
|
||||
</span>
|
||||
<span className="findings-header-right">
|
||||
<span className="findings-count">{plural(totalSignals, 'signal')}</span>
|
||||
<ChevronRight size={9} className={`chevron ${expanded ? 'chevron-open' : ''}`} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="findings-body">
|
||||
{groups.map(g => g.items.length > 0 && <TipsGroupView key={g.label} group={g} />)}
|
||||
{payload.optimize.findingCount > 0 && (
|
||||
<button type="button" className="findings-open-optimize" onClick={() => onOpenTerminal(['optimize'])}>
|
||||
<span>Open Full Optimize</span>
|
||||
<ArrowForward size={9} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function GroupIcon({ icon }: { icon: string }) {
|
||||
if (icon === 'check') return <CheckCircleIcon size={10} />
|
||||
if (icon === 'up') return <ArrowUpRightCircleIcon size={10} />
|
||||
return <WarningIcon size={10} />
|
||||
}
|
||||
|
||||
function TipsGroupView({ group }: { group: TipGroup }) {
|
||||
return (
|
||||
<div className="tips-group">
|
||||
<div className="tips-group-header">
|
||||
<GroupIcon icon={group.icon} />
|
||||
<span>{group.label}</span>
|
||||
</div>
|
||||
{group.items.map((item, i) => (
|
||||
<div key={i} className="tips-item">
|
||||
<span className="tips-bullet" />
|
||||
<span className="tips-text">{item.text}</span>
|
||||
{item.trailing && <span className="tips-trailing">{item.trailing}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
81
windows/src/components/FooterBar.tsx
Normal file
81
windows/src/components/FooterBar.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import type { CurrencyState } from '../lib/currency'
|
||||
import { CURRENCY_CODES } from '../lib/currency'
|
||||
import { DropMenu } from './DropMenu'
|
||||
import { CoinIcon, DownloadIcon, EllipsisIcon, RefreshIcon, TerminalIcon } from './Icons'
|
||||
|
||||
type Props = {
|
||||
currency: CurrencyState
|
||||
onCurrency: (code: string) => void
|
||||
loading: boolean
|
||||
onRefresh: () => void
|
||||
onExport: (format: 'csv' | 'json') => void
|
||||
onOpenReport: () => void
|
||||
onToggleTheme: () => void
|
||||
onQuit: () => void
|
||||
themeLabel: string
|
||||
footnote: string
|
||||
trayBadge: boolean
|
||||
onToggleTrayBadge: () => void
|
||||
onOpenSettings: () => void
|
||||
settingsOpen: boolean
|
||||
}
|
||||
|
||||
export function FooterBar({
|
||||
currency, onCurrency, loading, onRefresh, onExport, onOpenReport, onToggleTheme, onQuit, themeLabel, footnote,
|
||||
trayBadge, onToggleTrayBadge, onOpenSettings, settingsOpen,
|
||||
}: Props) {
|
||||
return (
|
||||
<footer className="footer">
|
||||
<DropMenu
|
||||
title="Currency"
|
||||
label={<><CoinIcon size={12} /><span>{currency.code}</span></>}
|
||||
items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))}
|
||||
columns={3}
|
||||
onSelect={onCurrency}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn btn-icon ${loading ? 'btn-spinning' : ''}`}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshIcon size={12} />
|
||||
</button>
|
||||
<DropMenu
|
||||
title="Export"
|
||||
label={<><DownloadIcon size={12} /><span>Export</span></>}
|
||||
items={[
|
||||
{ id: 'csv', label: 'CSV (folder)' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
]}
|
||||
onSelect={id => onExport(id as 'csv' | 'json')}
|
||||
/>
|
||||
<span className="footer-spacer" />
|
||||
<button type="button" className="btn btn-prominent" onClick={onOpenReport}>
|
||||
<TerminalIcon size={12} />
|
||||
<span>Open Full Report</span>
|
||||
</button>
|
||||
<DropMenu
|
||||
title="More"
|
||||
align="right"
|
||||
label={<EllipsisIcon size={12} />}
|
||||
className="dropmenu-more"
|
||||
items={[
|
||||
{ id: 'settings', label: settingsOpen ? 'Back to overview' : 'Settings…' },
|
||||
{ id: 'badge', label: "Show today's cost in tray", checked: trayBadge, separatorBefore: true },
|
||||
{ id: 'theme', label: themeLabel },
|
||||
{ id: 'quit', label: 'Quit CodeBurn', separatorBefore: true },
|
||||
]}
|
||||
footnote={footnote}
|
||||
onSelect={id => {
|
||||
if (id === 'settings') onOpenSettings()
|
||||
if (id === 'badge') onToggleTrayBadge()
|
||||
if (id === 'theme') onToggleTheme()
|
||||
if (id === 'quit') onQuit()
|
||||
}}
|
||||
/>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
59
windows/src/components/ForecastInsight.tsx
Normal file
59
windows/src/components/ForecastInsight.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import type { DailyEntry } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, formatCompactCurrency } from '../lib/currency'
|
||||
import { computeHistoryStats } from '../lib/history'
|
||||
import { ArrowUpRight, ArrowDownRight } from './Icons'
|
||||
|
||||
const WEEK_DAYS = 7
|
||||
|
||||
type Props = {
|
||||
days: DailyEntry[]
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function ForecastInsight({ days, currency }: Props) {
|
||||
const s = computeHistoryStats(days)
|
||||
const prevDelta = s.previousMonthTotal && s.previousMonthTotal > 0
|
||||
? ((s.monthProjection - s.previousMonthTotal) / s.previousMonthTotal) * 100
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="forecast-insight">
|
||||
<div className="insight-header">
|
||||
<div>
|
||||
<div className="insight-sublabel">Month-to-date</div>
|
||||
<div className="forecast-mtd">{formatCurrency(s.monthToDate, currency)}</div>
|
||||
</div>
|
||||
<div className="forecast-right">
|
||||
<div className="insight-sublabel">On pace for</div>
|
||||
<div className="forecast-projection">{formatCurrency(s.monthProjection, currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mini-stats">
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Avg/day (this wk)</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal / WEEK_DAYS, currency)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Yesterday</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.yesterday, currency)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Last 7d</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal, currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prevDelta !== null && s.previousMonthTotal !== null && (
|
||||
<div className="delta-badge delta-badge-block">
|
||||
{prevDelta >= 0 ? <ArrowUpRight size={9} /> : <ArrowDownRight size={9} />}
|
||||
<span>
|
||||
{prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month
|
||||
({formatCompactCurrency(s.previousMonthTotal, currency)})
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
windows/src/components/HeroSection.tsx
Normal file
43
windows/src/components/HeroSection.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, plural } from '../lib/currency'
|
||||
import { prettyDate, todayKey } from '../lib/dates'
|
||||
import { SectionCaption } from './CollapsibleSection'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload | null
|
||||
currency: CurrencyState
|
||||
periodLabel: string
|
||||
isToday: boolean
|
||||
}
|
||||
|
||||
export function HeroSection({ payload, currency, periodLabel, isToday }: Props) {
|
||||
const todayLabel = prettyDate(todayKey())
|
||||
const caption = isToday ? `Today · ${todayLabel}` : (payload?.current.label || periodLabel)
|
||||
|
||||
return (
|
||||
<section className="hero">
|
||||
<SectionCaption text={caption} />
|
||||
<div className="hero-row">
|
||||
{payload ? (
|
||||
<div className="hero-amount">{formatCurrency(payload.current.cost, currency)}</div>
|
||||
) : (
|
||||
<div className="hero-amount hero-skeleton" aria-label="Loading" />
|
||||
)}
|
||||
<div className="hero-meta">
|
||||
{payload ? (
|
||||
<>
|
||||
<span className="hero-calls">{payload.current.calls.toLocaleString()} {payload.current.calls === 1 ? 'call' : 'calls'}</span>
|
||||
<span className="hero-sessions">{plural(payload.current.sessions, 'session')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="hero-skeleton-line" />
|
||||
<span className="hero-skeleton-line short" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
195
windows/src/components/Icons.tsx
Normal file
195
windows/src/components/Icons.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import type { SVGProps } from 'react'
|
||||
|
||||
/// Small stroke icons standing in for the SF Symbols the macOS app uses. All are drawn on
|
||||
/// a 16x16 grid at 1.5px stroke so they sit on the same optical baseline as 11px text.
|
||||
|
||||
type IconProps = SVGProps<SVGSVGElement> & { size?: number }
|
||||
|
||||
function Svg({ size = 12, children, ...rest }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const FLAME_PATH = 'M8 1.5c.4 2.2 1.9 3.3 3.1 4.6C12.4 7.5 13 8.9 13 10.3 13 13 10.8 15 8 15s-5-2-5-4.7c0-1.6.7-2.8 1.5-3.7.2 1 .8 1.8 1.6 2.2C6 7 6.6 4.5 8 1.5z'
|
||||
|
||||
export function FlameIcon({ filled = false, ...p }: IconProps & { filled?: boolean }) {
|
||||
return <Svg {...p}><path d={FLAME_PATH} fill={filled ? 'currentColor' : 'none'} /></Svg>
|
||||
}
|
||||
|
||||
export function ChevronRight(p: IconProps) {
|
||||
return <Svg {...p}><path d="M6 3.5 10.5 8 6 12.5" /></Svg>
|
||||
}
|
||||
|
||||
export function ChevronDown(p: IconProps) {
|
||||
return <Svg {...p}><path d="M3.5 6 8 10.5 12.5 6" /></Svg>
|
||||
}
|
||||
|
||||
export function RefreshIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9" />
|
||||
<path d="M13.5 2.5v3h-3" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function DownloadIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M8 2.5v8M5 7.5l3 3 3-3" />
|
||||
<path d="M3 11.5v1.5a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5v-1.5" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<rect x="2" y="3" width="12" height="10" rx="1.5" />
|
||||
<path d="M4.5 6.5 6.5 8l-2 1.5M8 10h3" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CoinIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="6" />
|
||||
<path d="M8 4.5v7M10 6.2c-.3-.7-1-1-2-1s-2 .5-2 1.3c0 1.9 4 .9 4 2.9 0 .8-1 1.3-2 1.3s-1.8-.4-2.1-1" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StarIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="m8 1.8 1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z" fill="currentColor" stroke="none" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function XIcon(p: IconProps) {
|
||||
return <Svg {...p}><path d="M4 4l8 8M12 4l-8 8" /></Svg>
|
||||
}
|
||||
|
||||
export function BulbIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M5.5 11.5a4.5 4.5 0 1 1 5 0v1.5h-5z" fill="currentColor" stroke="none" />
|
||||
<path d="M6.5 14.5h3" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckCircleIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="6" fill="currentColor" stroke="none" />
|
||||
<path d="M5.3 8.2 7.2 10l3.5-4" stroke="var(--icon-contrast, #fff)" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArrowUpRightCircleIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="6" fill="currentColor" stroke="none" />
|
||||
<path d="M6 10l4-4M6.5 6H10v3.5" stroke="var(--icon-contrast, #fff)" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function WarningIcon({ filled = true, ...p }: IconProps & { filled?: boolean }) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M8 2.2 14.3 13H1.7z" fill={filled ? 'currentColor' : 'none'} />
|
||||
<path d="M8 6.2v3.3M8 11.3v.2" stroke={filled ? 'var(--icon-contrast, #fff)' : 'currentColor'} />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ArrowUpRight(p: IconProps) {
|
||||
return <Svg {...p}><path d="M4.5 11.5 11.5 4.5M6 4.5h5.5V10" /></Svg>
|
||||
}
|
||||
|
||||
export function ArrowDownRight(p: IconProps) {
|
||||
return <Svg {...p}><path d="M4.5 4.5 11.5 11.5M6 11.5h5.5V6" /></Svg>
|
||||
}
|
||||
|
||||
export function ArrowForward(p: IconProps) {
|
||||
return <Svg {...p}><path d="M3 8h10M9 4l4 4-4 4" /></Svg>
|
||||
}
|
||||
|
||||
export function KeySlashIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="5.5" cy="6.5" r="3" />
|
||||
<path d="M8 8.5 13.5 14M11 11.5l1.5-1.5M2 14 14 2" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PersonDashedIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="6.5" strokeDasharray="2.2 2" />
|
||||
<circle cx="8" cy="6.5" r="2" />
|
||||
<path d="M4.8 12.2c.6-1.6 1.8-2.4 3.2-2.4s2.6.8 3.2 2.4" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function TrayIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M2.5 9.5h3.2l.8 1.5h3l.8-1.5h3.2" />
|
||||
<path d="M2.5 9.5 4 4.5h8l1.5 5v3a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1z" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function EllipsisIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="3.5" cy="8" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="8" cy="8" r="1.2" fill="currentColor" stroke="none" />
|
||||
<circle cx="12.5" cy="8" r="1.2" fill="currentColor" stroke="none" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function CheckIcon(p: IconProps) {
|
||||
return <Svg {...p}><path d="M3.5 8.5 6.5 11.5 12.5 5" /></Svg>
|
||||
}
|
||||
|
||||
export function SunMoonIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<circle cx="8" cy="8" r="4" />
|
||||
<path d="M8 4V1.5M8 14.5V12M4 8H1.5M14.5 8H12M5.2 5.2 3.4 3.4M12.6 12.6l-1.8-1.8M5.2 10.8l-1.8 1.8M12.6 3.4l-1.8 1.8" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function PowerIcon(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M8 2v6" />
|
||||
<path d="M4.6 4.6a5 5 0 1 0 6.8 0" />
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
41
windows/src/components/InsightPills.tsx
Normal file
41
windows/src/components/InsightPills.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export type InsightMode = 'plan' | 'trend' | 'forecast' | 'pulse' | 'stats'
|
||||
|
||||
export const INSIGHT_LABELS: Record<InsightMode, string> = {
|
||||
plan: 'Plan',
|
||||
trend: 'Trend',
|
||||
forecast: 'Forecast',
|
||||
pulse: 'Pulse',
|
||||
stats: 'Stats',
|
||||
}
|
||||
|
||||
/// Same order as the macOS InsightMode enum: Plan first when it is visible.
|
||||
export const INSIGHT_ORDER: InsightMode[] = ['plan', 'trend', 'forecast', 'pulse', 'stats']
|
||||
|
||||
export function isInsightMode(value: string | null): value is InsightMode {
|
||||
return value !== null && value in INSIGHT_LABELS
|
||||
}
|
||||
|
||||
type Props = {
|
||||
selected: InsightMode
|
||||
onSelect: (m: InsightMode) => void
|
||||
modes: InsightMode[]
|
||||
}
|
||||
|
||||
export function InsightPills({ selected, onSelect, modes }: Props) {
|
||||
return (
|
||||
<div className="insight-pills" role="tablist">
|
||||
{modes.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected === m}
|
||||
className={`insight-pill ${selected === m ? 'insight-pill-active' : ''}`}
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
{INSIGHT_LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
windows/src/components/LoadingOverlay.tsx
Normal file
44
windows/src/components/LoadingOverlay.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { FLAME_PATH } from './Icons'
|
||||
|
||||
/// The macOS BurnLoadingOverlay: a blurred sheet over the scroll area with a flame that
|
||||
/// fills bottom-to-top on a 1.4s loop while a soft glow pulses behind it.
|
||||
|
||||
type Props = { periodLabel: string }
|
||||
|
||||
export function LoadingOverlay({ periodLabel }: Props) {
|
||||
return (
|
||||
<div className="loading-overlay" role="status" aria-live="polite">
|
||||
<div className="loading-content">
|
||||
<BurnFlame />
|
||||
<div className="loading-text">Loading {periodLabel}…</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BurnFlame({ size = 64 }: { size?: number }) {
|
||||
return (
|
||||
<div className="burn-flame" style={{ width: size, height: size }}>
|
||||
<svg className="burn-flame-glow" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
|
||||
<path d={FLAME_PATH} />
|
||||
</svg>
|
||||
<svg className="burn-flame-outline" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
|
||||
<path d={FLAME_PATH} />
|
||||
</svg>
|
||||
<svg className="burn-flame-fill" viewBox="0 0 16 16" width={size} height={size} aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="burn-gradient" x1="0" y1="1" x2="0" y2="0">
|
||||
<stop offset="0%" stopColor="#F0A070" />
|
||||
<stop offset="33.33%" stopColor="#E8774A" />
|
||||
<stop offset="66.66%" stopColor="#C9521D" />
|
||||
<stop offset="100%" stopColor="#8B3E13" />
|
||||
</linearGradient>
|
||||
<clipPath id="burn-clip">
|
||||
<rect className="burn-clip-rect" x="0" y="0" width="16" height="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
<path d={FLAME_PATH} fill="url(#burn-gradient)" clipPath="url(#burn-clip)" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
windows/src/components/ModelsSection.tsx
Normal file
47
windows/src/components/ModelsSection.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import type { Model } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency, formatTokens } from '../lib/currency'
|
||||
import { CollapsibleSection } from './CollapsibleSection'
|
||||
import { FixedBar, COL_COST, COL_COUNT } from './ActivitySection'
|
||||
|
||||
type Props = {
|
||||
models: Model[]
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPercent: number
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function ModelsSection({ models, inputTokens, outputTokens, cacheHitPercent, currency }: Props) {
|
||||
if (models.length === 0) return null
|
||||
const maxCost = Math.max(...models.map(m => m.cost), 0.01)
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
caption="Models"
|
||||
columns={[
|
||||
{ label: 'Cost', width: COL_COST },
|
||||
{ label: 'Calls', width: COL_COUNT },
|
||||
]}
|
||||
>
|
||||
{models.map(m => (
|
||||
<div key={m.name} className="data-row">
|
||||
<FixedBar fraction={m.cost / maxCost} />
|
||||
<span className="row-name">{m.name}</span>
|
||||
<span className="row-cost" style={{ minWidth: COL_COST }}>{formatCompactCurrency(m.cost, currency)}</span>
|
||||
<span className="row-count" style={{ minWidth: COL_COUNT }}>{m.calls}</span>
|
||||
</div>
|
||||
))}
|
||||
{(inputTokens > 0 || outputTokens > 0) && (
|
||||
<div className="tokens-line">
|
||||
<span className="tokens-label">Tokens</span>
|
||||
<span className="tokens-value">{formatTokens(inputTokens)} in</span>
|
||||
<span className="tokens-sep">·</span>
|
||||
<span className="tokens-value">{formatTokens(outputTokens)} out</span>
|
||||
<span className="tokens-sep">·</span>
|
||||
<span className="tokens-value">{Math.round(cacheHitPercent)}% cache hit</span>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleSection>
|
||||
)
|
||||
}
|
||||
35
windows/src/components/NoDataState.tsx
Normal file
35
windows/src/components/NoDataState.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/// First-run copy for a machine where the CLI ran fine but found no sessions. Paths are
|
||||
/// shown the way the reader's own OS spells them.
|
||||
|
||||
import { homePath } from '../lib/platform'
|
||||
|
||||
const SOURCES: Array<{ path: string | null; tool: string }> = [
|
||||
{ path: homePath('.claude', 'projects'), tool: 'Claude Code' },
|
||||
{ path: homePath('.codex', 'sessions'), tool: 'Codex CLI' },
|
||||
{ path: null, tool: 'Cursor local database' },
|
||||
{ path: null, tool: 'GitHub Copilot session events' },
|
||||
{ path: homePath('.local', 'share', 'opencode'), tool: 'OpenCode' },
|
||||
{ path: homePath('.pi'), tool: 'Pi' },
|
||||
]
|
||||
|
||||
export function NoDataState({ onRefresh }: { onRefresh: () => void }) {
|
||||
return (
|
||||
<section className="no-data">
|
||||
<h2 className="no-data-title">No session data yet</h2>
|
||||
<p>
|
||||
CodeBurn reads local session logs written by your AI coding tools. None of the
|
||||
supported tools have recorded a session on this machine yet.
|
||||
</p>
|
||||
<p className="no-data-sub">Watched locations</p>
|
||||
<ul>
|
||||
{SOURCES.map(s => (
|
||||
<li key={s.tool}>
|
||||
{s.path ? <><code>{s.path}</code> <span className="no-data-tool">{s.tool}</span></> : s.tool}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>Run one of those tools for a session, then refresh.</p>
|
||||
<button type="button" className="btn" onClick={onRefresh}>Refresh now</button>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
41
windows/src/components/PeriodTabs.tsx
Normal file
41
windows/src/components/PeriodTabs.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
export const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today', week: '7 Days', '30days': '30 Days', month: 'Month', all: 'All',
|
||||
}
|
||||
|
||||
/// Short phrase used in sentences ("Sessions (7 days)", "No Claude data for this month").
|
||||
export const PERIOD_PHRASES: Record<Period, string> = {
|
||||
today: 'today',
|
||||
week: 'the last 7 days',
|
||||
'30days': 'the last 30 days',
|
||||
month: 'this month',
|
||||
all: 'all time',
|
||||
}
|
||||
|
||||
const PERIODS = Object.keys(PERIOD_LABELS) as Period[]
|
||||
|
||||
type Props = {
|
||||
selected: Period
|
||||
onSelect: (p: Period) => void
|
||||
}
|
||||
|
||||
export function PeriodTabs({ selected, onSelect }: Props) {
|
||||
return (
|
||||
<div className="period-wrap">
|
||||
<nav className="period-tabs" aria-label="Period">
|
||||
{PERIODS.map(p => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
className={`period ${selected === p ? 'period-active' : ''}`}
|
||||
aria-pressed={selected === p}
|
||||
onClick={() => onSelect(p)}
|
||||
>
|
||||
{PERIOD_LABELS[p]}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
147
windows/src/components/PlanInsight.tsx
Normal file
147
windows/src/components/PlanInsight.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency, formatTokens, plural } from '../lib/currency'
|
||||
import { relativeFuture } from '../lib/dates'
|
||||
import type { PlanUsage, PlanWindow } from '../lib/plan'
|
||||
import { projectWindow, earliestReset } from '../lib/plan'
|
||||
import { BulbIcon, ChevronRight, KeySlashIcon, PersonDashedIcon, WarningIcon, ArrowUpRight } from './Icons'
|
||||
|
||||
/// Sonnet-weighted approximation the mac app uses to turn a dollar saving into tokens.
|
||||
const USD_PER_MILLION_EFFECTIVE_TOKENS = 9
|
||||
const MILLION = 1_000_000
|
||||
const PLAN_REFRESH_MS = 5 * 60_000
|
||||
|
||||
type LoadState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; usage: Extract<PlanUsage, { state: 'ok' }> }
|
||||
| { kind: 'no_credentials' }
|
||||
| { kind: 'failed'; message: string }
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload | null
|
||||
currency: CurrencyState
|
||||
onOpenTerminal: (args: string[]) => void
|
||||
onConnectClaude: () => void
|
||||
}
|
||||
|
||||
export function PlanInsight({ payload, currency, onOpenTerminal, onConnectClaude }: Props) {
|
||||
const [state, setState] = useState<LoadState>({ kind: 'idle' })
|
||||
const [now, setNow] = useState(() => new Date())
|
||||
|
||||
const load = async () => {
|
||||
setState(prev => (prev.kind === 'loaded' ? prev : { kind: 'loading' }))
|
||||
try {
|
||||
const usage = await invoke<PlanUsage>('plan_usage')
|
||||
if (usage.state === 'ok') setState({ kind: 'loaded', usage })
|
||||
else if (usage.state === 'no_credentials') setState({ kind: 'no_credentials' })
|
||||
else setState({ kind: 'failed', message: usage.message })
|
||||
} catch (err) {
|
||||
setState({ kind: 'failed', message: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
setNow(new Date())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(load, PLAN_REFRESH_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
switch (state.kind) {
|
||||
case 'idle':
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="plan-state">
|
||||
<PersonDashedIcon size={22} className="plan-state-icon" />
|
||||
<div className="plan-state-title-muted">Loading your plan...</div>
|
||||
<div className="plan-state-note">Reading Claude Code credentials from this machine.</div>
|
||||
</div>
|
||||
)
|
||||
case 'no_credentials':
|
||||
return (
|
||||
<div className="plan-state">
|
||||
<KeySlashIcon size={20} className="plan-state-icon" />
|
||||
<div className="plan-state-title">No Claude subscription connected</div>
|
||||
<div className="plan-state-note">Click Connect to sign in with Claude in a terminal, then return here.</div>
|
||||
<div className="plan-actions">
|
||||
<button type="button" className="btn btn-prominent" onClick={() => onConnectClaude()}>Connect Claude</button>
|
||||
<button type="button" className="btn" onClick={load}>Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'failed':
|
||||
return (
|
||||
<div className="plan-state">
|
||||
<WarningIcon size={18} filled={false} className="plan-state-icon plan-state-icon-accent" />
|
||||
<div className="plan-state-title">Couldn't load plan data</div>
|
||||
<div className="plan-state-error">{state.message}</div>
|
||||
<div className="plan-actions">
|
||||
<button type="button" className="btn btn-prominent" onClick={() => onConnectClaude()}>Reconnect Claude</button>
|
||||
<button type="button" className="btn" onClick={load}>Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
case 'loaded': {
|
||||
const { usage } = state
|
||||
const reset = earliestReset(usage.windows)
|
||||
return (
|
||||
<div className="plan-insight">
|
||||
<div className="plan-header">
|
||||
<span className="plan-tier">{usage.tier}</span>
|
||||
{reset && <span className="plan-reset">Resets {relativeFuture(reset, now)}</span>}
|
||||
</div>
|
||||
<div className="plan-rows">
|
||||
{usage.windows.map(w => <UtilizationRow key={w.key} window={w} now={now} />)}
|
||||
</div>
|
||||
{payload && payload.optimize.findingCount > 0 && payload.optimize.savingsUSD > 0 && (
|
||||
<button type="button" className="savings-badge" onClick={() => onOpenTerminal(['optimize'])}>
|
||||
<BulbIcon size={10} className="savings-badge-icon" />
|
||||
<span>
|
||||
Save ~{formatCompactCurrency(payload.optimize.savingsUSD, currency)} / ~
|
||||
{formatTokens((payload.optimize.savingsUSD / USD_PER_MILLION_EFFECTIVE_TOKENS) * MILLION)} tokens
|
||||
{' · '}{plural(payload.optimize.findingCount, 'finding')}
|
||||
</span>
|
||||
<ChevronRight size={8} className="savings-badge-chevron" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function UtilizationRow({ window, now }: { window: PlanWindow; now: Date }) {
|
||||
const projection = projectWindow(window, now)
|
||||
const clamped = Math.min(Math.max(window.percent, 0), 100)
|
||||
const marker = projection ? Math.min(Math.max(projection.percent, 0), 100) : null
|
||||
|
||||
let caption: string | null = null
|
||||
if (projection) {
|
||||
const pct = Math.round(projection.percent)
|
||||
if (projection.source === 'historical') caption = `Based on last cycle: ${pct}%`
|
||||
else if (projection.willOverflow && projection.hitsLimitAt) caption = `On pace: ${pct}% at reset · hits 100% ${relativeFuture(projection.hitsLimitAt, now)}`
|
||||
else caption = `On pace: ${pct}% at reset`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="util-row">
|
||||
<div className="util-row-head">
|
||||
<span className="util-label">{window.label}</span>
|
||||
<span className="util-percent">{Math.round(clamped)}%</span>
|
||||
</div>
|
||||
<div className="util-bar">
|
||||
<div className="util-bar-fill" style={{ width: `${clamped}%` }} />
|
||||
{marker !== null && <div className="util-bar-marker" style={{ left: `calc(${marker}% - 0.75px)` }} />}
|
||||
</div>
|
||||
{caption && (
|
||||
<div className={`util-caption ${projection?.willOverflow ? 'util-caption-warn' : ''}`}>
|
||||
{projection?.willOverflow ? <WarningIcon size={8} /> : <ArrowUpRight size={8} />}
|
||||
<span>{caption}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
windows/src/components/PulseInsight.tsx
Normal file
32
windows/src/components/PulseInsight.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency } from '../lib/currency'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function PulseInsight({ payload, currency }: Props) {
|
||||
const { cacheHitPercent, oneShotRate, cost, sessions } = payload.current
|
||||
const cacheText = cacheHitPercent <= 0 ? '-' : `${Math.round(cacheHitPercent)}%`
|
||||
const oneShotText = oneShotRate == null ? '-' : `${Math.round(oneShotRate * 100)}%`
|
||||
const costPerSession = sessions > 0 ? formatCompactCurrency(cost / sessions, currency) : '-'
|
||||
|
||||
return (
|
||||
<div className="pulse-tiles">
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">Cache hit</div>
|
||||
<div className="pulse-value pulse-value-accent">{cacheText}</div>
|
||||
</div>
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">1-shot</div>
|
||||
<div className={`pulse-value ${oneShotRate == null ? '' : 'pulse-value-accent'}`}>{oneShotText}</div>
|
||||
</div>
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">Cost / session</div>
|
||||
<div className="pulse-value">{costPerSession}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
windows/src/components/SettingsPanel.tsx
Normal file
156
windows/src/components/SettingsPanel.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { CURRENCY_CODES } from '../lib/currency'
|
||||
import { homePath } from '../lib/platform'
|
||||
import type { CliStatus } from './SetupState'
|
||||
import { DropMenu } from './DropMenu'
|
||||
import { ChevronDown, ChevronRight } from './Icons'
|
||||
|
||||
/// Preferences that have no home in the popover proper. Deliberately small: the mac app has
|
||||
/// no settings window at all, so everything here is a Windows/Linux need (login item, tray
|
||||
/// text) or a convenience the footer already offers in a smaller form.
|
||||
|
||||
export type ThemeChoice = 'system' | 'light' | 'dark'
|
||||
|
||||
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
|
||||
|
||||
type Props = {
|
||||
onBack: () => void
|
||||
version: string
|
||||
currency: CurrencyState
|
||||
onCurrency: (code: string) => void
|
||||
themeChoice: ThemeChoice
|
||||
onThemeChoice: (t: ThemeChoice) => void
|
||||
trayBadge: boolean
|
||||
onTrayBadge: (on: boolean) => void
|
||||
cliStatus: CliStatus | null
|
||||
onCheckCli: () => void
|
||||
onProbeCli: () => void
|
||||
cliChecking: boolean
|
||||
onQuit: () => void
|
||||
}
|
||||
|
||||
export function SettingsPanel({
|
||||
onBack, version, currency, onCurrency, themeChoice, onThemeChoice, trayBadge, onTrayBadge,
|
||||
cliStatus, onCheckCli, onProbeCli, cliChecking, onQuit,
|
||||
}: Props) {
|
||||
const [loginItem, setLoginItem] = useState<boolean | null>(null)
|
||||
const [loginError, setLoginError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
invoke<boolean>('launch_at_login').then(setLoginItem).catch(() => setLoginItem(false))
|
||||
if (!cliStatus) onProbeCli()
|
||||
// Probe once when the panel opens; cliStatus arriving later must not re-trigger it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const toggleLogin = async () => {
|
||||
if (loginItem === null) return
|
||||
setLoginError(null)
|
||||
try {
|
||||
setLoginItem(await invoke<boolean>('set_launch_at_login', { enabled: !loginItem }))
|
||||
} catch (err) {
|
||||
setLoginError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings">
|
||||
<div className="settings-head">
|
||||
<button type="button" className="btn btn-icon" onClick={onBack} aria-label="Back">
|
||||
<ChevronRight size={11} style={{ transform: 'rotate(180deg)' }} />
|
||||
</button>
|
||||
<span className="settings-title">Settings</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-label">General</div>
|
||||
<Row label="Launch at login" hint="Start CodeBurn in the tray when you sign in.">
|
||||
<Toggle on={loginItem === true} disabled={loginItem === null} onToggle={toggleLogin} />
|
||||
</Row>
|
||||
{loginError && <div className="settings-error">{loginError}</div>}
|
||||
<Row label="Show today's cost in the tray" hint="A second tray icon carrying the number, next to the logo.">
|
||||
<Toggle on={trayBadge} onToggle={() => onTrayBadge(!trayBadge)} />
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-label">Appearance</div>
|
||||
<Row label="Theme">
|
||||
<div className="segmented">
|
||||
{(['system', 'light', 'dark'] as ThemeChoice[]).map(t => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`segment ${themeChoice === t ? 'segment-active' : ''}`}
|
||||
aria-pressed={themeChoice === t}
|
||||
onClick={() => onThemeChoice(t)}
|
||||
>
|
||||
{t === 'system' ? 'System' : t === 'light' ? 'Light' : 'Dark'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="Currency" hint={`Shared with the CLI via ${homePath('.config', 'codeburn', 'config.json')}.`}>
|
||||
<DropMenu
|
||||
label={<><span>{currency.code}</span><ChevronDown size={10} /></>}
|
||||
items={CURRENCY_CODES.map(c => ({ id: c, label: c, checked: c === currency.code }))}
|
||||
columns={3}
|
||||
align="right"
|
||||
onSelect={onCurrency}
|
||||
/>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-label">Data source</div>
|
||||
<Row
|
||||
label="CodeBurn CLI"
|
||||
hint={cliStatus?.found ? `Version ${cliStatus.version ?? '?'} · ${cliStatus.program}` : 'Not found on this machine.'}
|
||||
>
|
||||
<button type="button" className="btn" onClick={onCheckCli} disabled={cliChecking}>
|
||||
{cliChecking ? 'Checking…' : 'Check again'}
|
||||
</button>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<div className="settings-group">
|
||||
<div className="settings-group-label">About</div>
|
||||
<Row label={`CodeBurn Desktop ${version ? `v${version}` : ''}`} hint="Tracks AI coding spend from local session logs. Nothing leaves this machine except the Claude usage check.">
|
||||
<a className="btn" href={GITHUB_URL} target="_blank" rel="noopener noreferrer">GitHub</a>
|
||||
</Row>
|
||||
<Row label="Quit CodeBurn" hint="Removes the tray icon until you launch it again.">
|
||||
<button type="button" className="btn" onClick={onQuit}>Quit</button>
|
||||
</Row>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="settings-row">
|
||||
<div className="settings-row-text">
|
||||
<div className="settings-row-label">{label}</div>
|
||||
{hint && <div className="settings-row-hint">{hint}</div>}
|
||||
</div>
|
||||
<div className="settings-row-control">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ on, disabled = false, onToggle }: { on: boolean; disabled?: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
className={`toggle ${on ? 'toggle-on' : ''}`}
|
||||
disabled={disabled}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="toggle-knob" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
71
windows/src/components/SetupState.tsx
Normal file
71
windows/src/components/SetupState.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { useState } from 'react'
|
||||
import { BurnFlame } from './LoadingOverlay'
|
||||
import { WarningIcon } from './Icons'
|
||||
|
||||
/// Shown instead of the data views when the CLI is missing or too old. This is what a
|
||||
/// brand-new Windows user sees, so it has to explain the one thing they need to do.
|
||||
|
||||
export type CliStatus = {
|
||||
found: boolean
|
||||
program: string
|
||||
version: string | null
|
||||
min_version: string
|
||||
compatible: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const INSTALL_COMMAND = 'npm install -g codeburn'
|
||||
|
||||
type Props = {
|
||||
status: CliStatus
|
||||
checking: boolean
|
||||
onCheckAgain: () => void
|
||||
}
|
||||
|
||||
export function SetupState({ status, checking, onCheckAgain }: Props) {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const outdated = status.found && !status.compatible
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(INSTALL_COMMAND)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="setup">
|
||||
<BurnFlame size={44} />
|
||||
<h2 className="setup-title">
|
||||
{outdated ? 'Update the CodeBurn CLI' : 'Install the CodeBurn CLI'}
|
||||
</h2>
|
||||
<p className="setup-copy">
|
||||
{outdated
|
||||
? `This app needs codeburn ${status.min_version} or newer; version ${status.version} was found.`
|
||||
: 'The tray app reads everything through the codeburn command line tool, which is not installed on this machine yet.'}
|
||||
</p>
|
||||
<div className="setup-command">
|
||||
<code>{INSTALL_COMMAND}</code>
|
||||
<button type="button" className="btn" onClick={copy}>{copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
<p className="setup-copy setup-copy-muted">
|
||||
Requires Node.js 22 or newer. After installing, click Check again; no restart needed.
|
||||
</p>
|
||||
<div className="setup-actions">
|
||||
<button type="button" className="btn btn-prominent" onClick={onCheckAgain} disabled={checking}>
|
||||
{checking ? 'Checking…' : 'Check again'}
|
||||
</button>
|
||||
</div>
|
||||
{status.error && (
|
||||
<details className="setup-details">
|
||||
<summary><WarningIcon size={9} filled={false} /> Details</summary>
|
||||
<div className="setup-error">{status.error}</div>
|
||||
<div className="setup-error-muted">Looked for: {status.program}</div>
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
29
windows/src/components/StarBanner.tsx
Normal file
29
windows/src/components/StarBanner.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useState } from 'react'
|
||||
import { readSetting, writeSetting } from '../lib/settings'
|
||||
import { StarIcon, XIcon } from './Icons'
|
||||
|
||||
const GITHUB_URL = 'https://github.com/getagentseal/codeburn'
|
||||
|
||||
export function StarBanner() {
|
||||
const [dismissed, setDismissed] = useState(() => readSetting('starBannerDismissed') === 'true')
|
||||
if (dismissed) return null
|
||||
|
||||
const dismiss = () => {
|
||||
writeSetting('starBannerDismissed', 'true')
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="star-banner">
|
||||
<StarIcon size={10} className="star-banner-icon" />
|
||||
<a className="star-banner-link" href={GITHUB_URL} target="_blank" rel="noopener noreferrer">
|
||||
<span>Enjoying CodeBurn?</span>{' '}
|
||||
<span className="star-banner-cta">Star us on GitHub</span>
|
||||
</a>
|
||||
<span className="star-banner-spacer" />
|
||||
<button type="button" className="star-banner-close" onClick={dismiss} title="Hide this banner" aria-label="Hide this banner">
|
||||
<XIcon size={9} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
63
windows/src/components/StatsInsight.tsx
Normal file
63
windows/src/components/StatsInsight.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, formatCompactCurrency, plural } from '../lib/currency'
|
||||
import { daysInMonth, monthDay } from '../lib/dates'
|
||||
import { computeHistoryStats } from '../lib/history'
|
||||
import type { Period } from './PeriodTabs'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
period: Period
|
||||
}
|
||||
|
||||
const PERIOD_SUFFIX: Record<Period, string> = {
|
||||
today: 'today',
|
||||
week: '(7 days)',
|
||||
'30days': '(30 days)',
|
||||
month: '(month)',
|
||||
all: '(all time)',
|
||||
}
|
||||
|
||||
export function StatsInsight({ payload, currency, period }: Props) {
|
||||
const s = computeHistoryStats(payload.history.daily)
|
||||
const suffix = PERIOD_SUFFIX[period]
|
||||
|
||||
return (
|
||||
<div className="stats-insight">
|
||||
<div className="stats-grid">
|
||||
<div className="stats-col">
|
||||
<StatRow label="Favorite model" value={payload.current.topModels[0]?.name ?? '-'} />
|
||||
<StatRow label="Active days (month)" value={`${s.activeDaysThisMonth}/${daysInMonth(new Date())}`} />
|
||||
<StatRow label="Most active day" value={s.peak ? monthDay(s.peak.date) : '-'} />
|
||||
<StatRow label="Peak day spend" value={s.peak ? formatCompactCurrency(s.peak.cost, currency) : '-'} />
|
||||
</div>
|
||||
<div className="stats-col">
|
||||
<StatRow label={`Sessions ${suffix}`} value={payload.current.sessions.toLocaleString()} />
|
||||
<StatRow label={`Calls ${suffix}`} value={payload.current.calls.toLocaleString()} />
|
||||
<StatRow label="Current streak" value={s.currentStreak > 0 ? plural(s.currentStreak, 'day') : '-'} />
|
||||
<StatRow label="Longest streak" value={s.longestStreak > 0 ? plural(s.longestStreak, 'day') : '-'} />
|
||||
</div>
|
||||
</div>
|
||||
{s.trackedDays > 0 && (
|
||||
<div className="stats-lifetime">
|
||||
<span className="stats-lifetime-label">
|
||||
Tracked spend (last {plural(s.trackedDays, 'day')})
|
||||
</span>
|
||||
<span className="stats-lifetime-value">
|
||||
{formatCurrency(s.trackedTotal, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="stat-row">
|
||||
<div className="stat-row-label">{label}</div>
|
||||
<div className="stat-row-value">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
153
windows/src/components/TrendInsight.tsx
Normal file
153
windows/src/components/TrendInsight.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useState } from 'react'
|
||||
import type { DailyEntry, DailyModel } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency, formatCurrency, formatTokens } from '../lib/currency'
|
||||
import { todayKey, formatDateKey, addDays, startOfDay, prettyDate, shortDate } from '../lib/dates'
|
||||
import { ArrowUpRight, ArrowDownRight } from './Icons'
|
||||
|
||||
/// 19 columns of 13px bars with 4px gaps = 319px, the widest chart that fits the 332px
|
||||
/// content width of a 360px popover (mirrors mac trendDays / trendBarWidth / trendBarGap).
|
||||
export const TREND_DAYS = 19
|
||||
const MAX_TOOLTIP_MODELS = 4
|
||||
const MIN_BAR_PCT = 2
|
||||
|
||||
type TrendBar = {
|
||||
date: string
|
||||
cost: number
|
||||
tokens: number
|
||||
isToday: boolean
|
||||
topModels: DailyModel[]
|
||||
}
|
||||
|
||||
function buildBars(days: DailyEntry[]): TrendBar[] {
|
||||
const byDate = new Map(days.map(d => [d.date, d]))
|
||||
const today = startOfDay(new Date())
|
||||
const tk = todayKey()
|
||||
const bars: TrendBar[] = []
|
||||
for (let i = TREND_DAYS - 1; i >= 0; i--) {
|
||||
const key = formatDateKey(addDays(today, -i))
|
||||
const entry = byDate.get(key)
|
||||
bars.push({
|
||||
date: key,
|
||||
cost: entry?.cost ?? 0,
|
||||
tokens: (entry?.inputTokens ?? 0) + (entry?.outputTokens ?? 0),
|
||||
isToday: key === tk,
|
||||
topModels: entry?.topModels ?? [],
|
||||
})
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
function computeDelta(bars: TrendBar[], allDays: DailyEntry[]): number | null {
|
||||
const thisTotal = bars.reduce((s, b) => s + b.cost, 0)
|
||||
const today = startOfDay(new Date())
|
||||
const priorStart = formatDateKey(addDays(today, -(2 * TREND_DAYS - 1)))
|
||||
const thisStart = formatDateKey(addDays(today, -(TREND_DAYS - 1)))
|
||||
const priorTotal = allDays
|
||||
.filter(d => d.date >= priorStart && d.date < thisStart)
|
||||
.reduce((s, d) => s + d.cost, 0)
|
||||
if (priorTotal <= 0) return null
|
||||
return ((thisTotal - priorTotal) / priorTotal) * 100
|
||||
}
|
||||
|
||||
type Props = {
|
||||
days: DailyEntry[]
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function TrendInsight({ days, currency }: Props) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
|
||||
const bars = buildBars(days)
|
||||
const totalTokens = bars.reduce((s, b) => s + b.tokens, 0)
|
||||
const useTokens = totalTokens > 0
|
||||
const metric = (b: TrendBar) => useTokens ? b.tokens : b.cost
|
||||
const maxVal = Math.max(...bars.map(metric), 0.01)
|
||||
const avgVal = bars.reduce((s, b) => s + metric(b), 0) / bars.length
|
||||
const totalCost = bars.reduce((s, b) => s + b.cost, 0)
|
||||
const peak = bars.filter(b => metric(b) > 0).sort((a, b) => metric(b) - metric(a))[0]
|
||||
const yd = formatDateKey(addDays(startOfDay(new Date()), -1))
|
||||
const yesterday = bars.find(b => b.date === yd)
|
||||
const delta = computeDelta(bars, days)
|
||||
|
||||
const fmtVal = (v: number) => useTokens ? `${formatTokens(v)} tok` : formatCompactCurrency(v, currency)
|
||||
const heroText = useTokens ? `${formatTokens(totalTokens)} tokens` : formatCurrency(totalCost, currency)
|
||||
const hovered = hoveredIdx !== null ? bars[hoveredIdx] : null
|
||||
|
||||
return (
|
||||
<div className="trend-insight">
|
||||
<div className="insight-header">
|
||||
<div>
|
||||
<div className="insight-sublabel">Last {TREND_DAYS} days</div>
|
||||
<div className="insight-hero">{heroText}</div>
|
||||
</div>
|
||||
{delta !== null && (
|
||||
<div className="delta-badge">
|
||||
{delta >= 0 ? <ArrowUpRight size={9} /> : <ArrowDownRight size={9} />}
|
||||
<span>{delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="trend-chart" onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<div className="trend-bars">
|
||||
{bars.map((bar, i) => {
|
||||
const val = metric(bar)
|
||||
const pct = (val / maxVal) * 100
|
||||
const cls = [
|
||||
'trend-bar',
|
||||
bar.isToday ? 'trend-bar-today' : '',
|
||||
val <= 0 ? 'trend-bar-empty' : '',
|
||||
hoveredIdx === i ? 'trend-bar-hovered' : '',
|
||||
].join(' ')
|
||||
return (
|
||||
<div
|
||||
key={bar.date}
|
||||
className="trend-bar-col"
|
||||
onMouseEnter={() => setHoveredIdx(i)}
|
||||
>
|
||||
<div className={cls} style={{ height: `${Math.max(MIN_BAR_PCT, pct)}%` }} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="trend-avg-line"
|
||||
style={{ bottom: `${Math.min((avgVal / maxVal) * 100, 100)}%` }}
|
||||
/>
|
||||
{hovered && (
|
||||
<div className="bar-tooltip" role="tooltip">
|
||||
<div className="bar-tooltip-header">
|
||||
<span>{prettyDate(hovered.date)}</span>
|
||||
<span className="bar-tooltip-value">{fmtVal(metric(hovered))}</span>
|
||||
</div>
|
||||
{hovered.topModels.slice(0, MAX_TOOLTIP_MODELS).map(m => (
|
||||
<div key={m.name} className="bar-tooltip-model">
|
||||
<span className="bar-tooltip-dot" />
|
||||
<span className="bar-tooltip-name">{m.name}</span>
|
||||
<span className="bar-tooltip-tokens">{formatTokens(m.inputTokens + m.outputTokens)} tok</span>
|
||||
<span className="bar-tooltip-split">({formatTokens(m.inputTokens)}/{formatTokens(m.outputTokens)})</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mini-stats">
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Avg/day</div>
|
||||
<div className="mini-stat-value">{fmtVal(avgVal)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Peak</div>
|
||||
<div className="mini-stat-value">
|
||||
{peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Yesterday</div>
|
||||
<div className="mini-stat-value">{yesterday ? fmtVal(metric(yesterday)) : '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
windows/src/lib/cache.ts
Normal file
43
windows/src/lib/cache.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/// Per (period, provider) payload cache. Entries are served instantly on tab switches and
|
||||
/// refreshed in the background (stale-while-revalidate); `age` lets the caller decide
|
||||
/// whether a background refresh is due.
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
ts: number
|
||||
}
|
||||
|
||||
export class PayloadCache<T> {
|
||||
private store = new Map<string, CacheEntry<T>>()
|
||||
private flights = new Set<string>()
|
||||
|
||||
private key(period: string, provider: string): string {
|
||||
return `${period}:${provider}`
|
||||
}
|
||||
|
||||
get(period: string, provider: string): T | null {
|
||||
return this.store.get(this.key(period, provider))?.data ?? null
|
||||
}
|
||||
|
||||
/// Milliseconds since the entry was stored, or Infinity when absent.
|
||||
age(period: string, provider: string): number {
|
||||
const entry = this.store.get(this.key(period, provider))
|
||||
return entry ? Date.now() - entry.ts : Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
set(period: string, provider: string, data: T): void {
|
||||
this.store.set(this.key(period, provider), { data, ts: Date.now() })
|
||||
}
|
||||
|
||||
isInFlight(period: string, provider: string): boolean {
|
||||
return this.flights.has(this.key(period, provider))
|
||||
}
|
||||
|
||||
markInFlight(period: string, provider: string): void {
|
||||
this.flights.add(this.key(period, provider))
|
||||
}
|
||||
|
||||
clearInFlight(period: string, provider: string): void {
|
||||
this.flights.delete(this.key(period, provider))
|
||||
}
|
||||
}
|
||||
70
windows/src/lib/currency.ts
Normal file
70
windows/src/lib/currency.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/// Currency formatting that mirrors the macOS app's Double.asCurrency / asCompactCurrency.
|
||||
/// The Rust backend hands us { code, symbol, rate } so the frontend stays dumb about FX --
|
||||
/// it just multiplies and renders.
|
||||
|
||||
export type CurrencyState = {
|
||||
code: string
|
||||
symbol: string
|
||||
rate: number
|
||||
}
|
||||
|
||||
export const USD: CurrencyState = { code: 'USD', symbol: '$', rate: 1 }
|
||||
|
||||
export const CURRENCY_CODES = [
|
||||
'USD', 'GBP', 'EUR', 'AUD', 'CAD', 'NZD', 'JPY', 'CHF', 'INR',
|
||||
'BRL', 'SEK', 'SGD', 'HKD', 'KRW', 'MXN', 'ZAR', 'DKK',
|
||||
] as const
|
||||
|
||||
const SUB_CENT = 0.005
|
||||
|
||||
/// Wider format with thousands separators. Used for the hero value.
|
||||
export function formatCurrency(usdAmount: number, currency: CurrencyState): string {
|
||||
const converted = usdAmount * currency.rate
|
||||
const parts = converted.toFixed(2).split('.')
|
||||
const whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return `${currency.symbol}${whole}.${parts[1]}`
|
||||
}
|
||||
|
||||
/// Compact form (no thousands separators) used in dense tables where the monospace font
|
||||
/// already gives visual grouping.
|
||||
export function formatCompactCurrency(usdAmount: number, currency: CurrencyState): string {
|
||||
const converted = usdAmount * currency.rate
|
||||
return `${currency.symbol}${converted.toFixed(2)}`
|
||||
}
|
||||
|
||||
/// For savings and other tiny amounts: never print a misleading "$0.00".
|
||||
export function formatSmallCurrency(usdAmount: number, currency: CurrencyState): string {
|
||||
const converted = usdAmount * currency.rate
|
||||
if (converted > 0 && converted < SUB_CENT) return `<${currency.symbol}0.01`
|
||||
return formatCompactCurrency(usdAmount, currency)
|
||||
}
|
||||
|
||||
/// Token compaction shared by every surface (the mac app rounds K to whole numbers).
|
||||
export function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
const BADGE_THOUSAND = 1_000
|
||||
const BADGE_MILLION = 1_000_000
|
||||
|
||||
/// The spend string drawn into the tray icon. Budget is a 16px-wide pixel grid, so at most
|
||||
/// four glyph slots: "$9.5", "$87", "142", "1.2K", "12K", "0.1M". The `$` only fits when
|
||||
/// there are two digits or fewer, and only USD has a glyph in the icon font.
|
||||
export function trayBadgeText(usdAmount: number, currency: CurrencyState): string {
|
||||
const v = Math.max(0, usdAmount * currency.rate)
|
||||
const symbol = currency.code === 'USD' ? '$' : ''
|
||||
// Thresholds sit at the rounding boundary of the format above them, so "9.96" becomes
|
||||
// "$10" rather than "$10.0" and "999.7" becomes "1.0K" rather than "1000".
|
||||
if (v < 9.95) return `${symbol}${v.toFixed(1)}`
|
||||
if (v < 99.5) return `${symbol}${Math.round(v)}`
|
||||
if (v < 999.5) return `${Math.round(v)}`
|
||||
if (v < 9_950) return `${(v / BADGE_THOUSAND).toFixed(1)}K`
|
||||
if (v < 999_500) return `${Math.round(v / BADGE_THOUSAND)}K`
|
||||
return `${(v / BADGE_MILLION).toFixed(1)}M`
|
||||
}
|
||||
|
||||
export function plural(n: number, singular: string, pluralForm = `${singular}s`): string {
|
||||
return `${n} ${n === 1 ? singular : pluralForm}`
|
||||
}
|
||||
89
windows/src/lib/dates.ts
Normal file
89
windows/src/lib/dates.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/// All calendar math is in the machine's local time zone. The CLI buckets `history.daily`
|
||||
/// by local date, so "today" here must be the same local day or the trend chart and the
|
||||
/// hero disagree around midnight.
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||
const MONTH_NAMES = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
export const MS_PER_DAY = 86_400_000
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n)
|
||||
}
|
||||
|
||||
export function todayKey(): string {
|
||||
return formatDateKey(new Date())
|
||||
}
|
||||
|
||||
export function formatDateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
|
||||
}
|
||||
|
||||
export function parseDateKey(ymd: string): Date {
|
||||
const [y, m, d] = ymd.split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d.getTime())
|
||||
r.setDate(r.getDate() + n)
|
||||
return r
|
||||
}
|
||||
|
||||
export function startOfDay(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
||||
}
|
||||
|
||||
export function prettyDate(ymd: string): string {
|
||||
const dt = parseDateKey(ymd)
|
||||
return `${DAY_NAMES[dt.getDay()]} ${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}`
|
||||
}
|
||||
|
||||
export function monthDay(ymd: string): string {
|
||||
const dt = parseDateKey(ymd)
|
||||
return `${MONTH_NAMES[dt.getMonth()]} ${dt.getDate()}`
|
||||
}
|
||||
|
||||
export function shortDate(ymd: string): string {
|
||||
const parts = ymd.split('-')
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
}
|
||||
|
||||
export function firstOfMonth(d: Date): Date {
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1)
|
||||
}
|
||||
|
||||
export function daysInMonth(d: Date): number {
|
||||
return new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
|
||||
}
|
||||
|
||||
export function dayOfMonth(d: Date): number {
|
||||
return d.getDate()
|
||||
}
|
||||
|
||||
export function previousMonthRange(d: Date): { first: string; last: string } {
|
||||
const first = new Date(d.getFullYear(), d.getMonth() - 1, 1)
|
||||
const last = new Date(d.getFullYear(), d.getMonth(), 0)
|
||||
return { first: formatDateKey(first), last: formatDateKey(last) }
|
||||
}
|
||||
|
||||
/// "in 42m", "in 3h", "in 2d", or "now".
|
||||
export function relativeFuture(target: Date, now = new Date()): string {
|
||||
const secs = (target.getTime() - now.getTime()) / 1000
|
||||
if (secs <= 0) return 'now'
|
||||
if (secs < 3600) return `in ${Math.ceil(secs / 60)}m`
|
||||
if (secs < 86_400) return `in ${Math.ceil(secs / 3600)}h`
|
||||
return `in ${Math.ceil(secs / 86_400)}d`
|
||||
}
|
||||
|
||||
/// "just now", "2 min ago", "1 h ago".
|
||||
export function relativePast(target: Date, now = new Date()): string {
|
||||
const secs = Math.max(0, (now.getTime() - target.getTime()) / 1000)
|
||||
if (secs < 45) return 'just now'
|
||||
if (secs < 3600) return `${Math.round(secs / 60)} min ago`
|
||||
if (secs < 86_400) return `${Math.round(secs / 3600)} h ago`
|
||||
return `${Math.round(secs / 86_400)} d ago`
|
||||
}
|
||||
99
windows/src/lib/history.ts
Normal file
99
windows/src/lib/history.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { DailyEntry } from './payload'
|
||||
import {
|
||||
formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth, dayOfMonth,
|
||||
previousMonthRange, parseDateKey, MS_PER_DAY,
|
||||
} from './dates'
|
||||
|
||||
/// Derived numbers over `history.daily` that several insights share (Trend, Forecast,
|
||||
/// Stats, Tips). One implementation so the streak in Tips and the streak in Stats agree.
|
||||
|
||||
const MAX_STREAK_LOOKBACK_DAYS = 400
|
||||
const WEEK_DAYS = 7
|
||||
|
||||
export type HistoryStats = {
|
||||
weekTotal: number
|
||||
priorWeekTotal: number
|
||||
weekDelta: number | null
|
||||
yesterday: number
|
||||
monthToDate: number
|
||||
monthProjection: number
|
||||
previousMonthTotal: number | null
|
||||
activeDaysThisMonth: number
|
||||
currentStreak: number
|
||||
longestStreak: number
|
||||
peak: DailyEntry | null
|
||||
trackedTotal: number
|
||||
trackedDays: number
|
||||
}
|
||||
|
||||
export function computeHistoryStats(history: DailyEntry[], now = new Date()): HistoryStats {
|
||||
const today = startOfDay(now)
|
||||
const costByDate = new Map(history.map(d => [d.date, d.cost]))
|
||||
const sum = (from: string, to: string) =>
|
||||
history.filter(d => d.date >= from && d.date <= to).reduce((s, d) => s + d.cost, 0)
|
||||
|
||||
const todayKey = formatDateKey(today)
|
||||
const weekStart = formatDateKey(addDays(today, -(WEEK_DAYS - 1)))
|
||||
const priorWeekStart = formatDateKey(addDays(today, -(2 * WEEK_DAYS - 1)))
|
||||
const priorWeekEnd = formatDateKey(addDays(today, -WEEK_DAYS))
|
||||
const weekTotal = sum(weekStart, todayKey)
|
||||
const priorWeekTotal = sum(priorWeekStart, priorWeekEnd)
|
||||
const weekDelta = priorWeekTotal > 0 ? ((weekTotal - priorWeekTotal) / priorWeekTotal) * 100 : null
|
||||
|
||||
const yesterday = costByDate.get(formatDateKey(addDays(today, -1))) ?? 0
|
||||
|
||||
const fomKey = formatDateKey(firstOfMonth(now))
|
||||
const monthToDate = sum(fomKey, todayKey)
|
||||
const dom = dayOfMonth(now)
|
||||
const monthProjection = dom > 0 ? (monthToDate / dom) * daysInMonth(now) : 0
|
||||
const prev = previousMonthRange(now)
|
||||
const prevEntries = history.filter(d => d.date >= prev.first && d.date <= prev.last)
|
||||
const previousMonthTotal = prevEntries.length > 0 ? prevEntries.reduce((s, d) => s + d.cost, 0) : null
|
||||
|
||||
const activeDaysThisMonth = history.filter(d => d.date >= fomKey && d.cost > 0).length
|
||||
|
||||
let currentStreak = 0
|
||||
for (let i = 0; i < MAX_STREAK_LOOKBACK_DAYS; i++) {
|
||||
if ((costByDate.get(formatDateKey(addDays(today, -i))) ?? 0) > 0) currentStreak++
|
||||
else break
|
||||
}
|
||||
|
||||
let longestStreak = 0
|
||||
if (history.length > 0) {
|
||||
const first = parseDateKey([...history].sort((a, b) => a.date.localeCompare(b.date))[0].date)
|
||||
const totalDays = Math.min(
|
||||
MAX_STREAK_LOOKBACK_DAYS,
|
||||
Math.round((today.getTime() - first.getTime()) / MS_PER_DAY) + 1,
|
||||
)
|
||||
const start = addDays(today, -(totalDays - 1))
|
||||
let running = 0
|
||||
for (let i = 0; i < totalDays; i++) {
|
||||
if ((costByDate.get(formatDateKey(addDays(start, i))) ?? 0) > 0) {
|
||||
running++
|
||||
longestStreak = Math.max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const peak = history.reduce<DailyEntry | null>(
|
||||
(best, d) => (!best || d.cost > best.cost) ? d : best, null,
|
||||
)
|
||||
|
||||
return {
|
||||
weekTotal,
|
||||
priorWeekTotal,
|
||||
weekDelta,
|
||||
yesterday,
|
||||
monthToDate,
|
||||
monthProjection,
|
||||
previousMonthTotal,
|
||||
activeDaysThisMonth,
|
||||
currentStreak,
|
||||
longestStreak,
|
||||
peak: peak && peak.cost > 0 ? peak : null,
|
||||
trackedTotal: history.reduce((s, d) => s + d.cost, 0),
|
||||
trackedDays: history.length,
|
||||
}
|
||||
}
|
||||
57
windows/src/lib/payload.ts
Normal file
57
windows/src/lib/payload.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/// Shape of the JSON returned by `codeburn status --format menubar-json`. Kept in sync with
|
||||
/// `src/menubar-json.ts` (CLI) and `mac/Sources/CodeBurnMenubar/Data/MenubarPayload.swift`
|
||||
/// (macOS app). Any field change there must land here too or the frontend silently drops it.
|
||||
export type MenubarPayload = {
|
||||
generated: string
|
||||
current: {
|
||||
label: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
oneShotRate: number | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheHitPercent: number
|
||||
topActivities: Activity[]
|
||||
topModels: Model[]
|
||||
providers: Record<string, number>
|
||||
}
|
||||
optimize: {
|
||||
findingCount: number
|
||||
savingsUSD: number
|
||||
topFindings: Array<{ title: string; impact: 'high' | 'medium' | 'low'; savingsUSD: number }>
|
||||
}
|
||||
history: { daily: DailyEntry[] }
|
||||
}
|
||||
|
||||
export type Activity = {
|
||||
name: string
|
||||
cost: number
|
||||
turns: number
|
||||
oneShotRate: number | null
|
||||
}
|
||||
|
||||
export type Model = {
|
||||
name: string
|
||||
cost: number
|
||||
calls: number
|
||||
}
|
||||
|
||||
export type DailyModel = {
|
||||
name: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
export type DailyEntry = {
|
||||
date: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
topModels?: DailyModel[]
|
||||
}
|
||||
73
windows/src/lib/plan.ts
Normal file
73
windows/src/lib/plan.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/// Claude subscription usage as returned by the Rust `plan_usage` command, plus the
|
||||
/// projection math from the macOS PlanInsight so both apps draw the same marker.
|
||||
|
||||
export type PlanWindow = {
|
||||
key: 'five_hour' | 'seven_day' | 'seven_day_opus' | 'seven_day_sonnet' | string
|
||||
label: string
|
||||
percent: number
|
||||
resets_at: string | null
|
||||
previous_final: number | null
|
||||
}
|
||||
|
||||
export type PlanUsage =
|
||||
| { state: 'ok'; tier: string; raw_tier: string | null; windows: PlanWindow[]; fetched_at: string }
|
||||
| { state: 'no_credentials' }
|
||||
| { state: 'failed'; message: string }
|
||||
|
||||
export type PlanProjection = {
|
||||
percent: number
|
||||
willOverflow: boolean
|
||||
hitsLimitAt: Date | null
|
||||
source: 'linear' | 'historical'
|
||||
}
|
||||
|
||||
const FIVE_HOUR_SECONDS = 5 * 3600
|
||||
const SEVEN_DAY_SECONDS = 7 * 86_400
|
||||
/// Below this fraction of the window the linear extrapolation is noise; fall back to
|
||||
/// last cycle's final reading instead.
|
||||
const FRESH_WINDOW_THRESHOLD = 0.05
|
||||
const FULL_PERCENT = 100
|
||||
|
||||
function windowSeconds(key: string): number {
|
||||
return key === 'five_hour' ? FIVE_HOUR_SECONDS : SEVEN_DAY_SECONDS
|
||||
}
|
||||
|
||||
export function projectWindow(window: PlanWindow, now = new Date()): PlanProjection | null {
|
||||
if (!window.resets_at) return null
|
||||
const resetsAt = new Date(window.resets_at)
|
||||
if (Number.isNaN(resetsAt.getTime())) return null
|
||||
const seconds = windowSeconds(window.key)
|
||||
const windowStart = resetsAt.getTime() / 1000 - seconds
|
||||
const elapsed = now.getTime() / 1000 - windowStart
|
||||
const elapsedFraction = elapsed / seconds
|
||||
|
||||
if (elapsedFraction > FRESH_WINDOW_THRESHOLD && window.percent > 0) {
|
||||
const projected = window.percent / elapsedFraction
|
||||
let hitsLimitAt: Date | null = null
|
||||
if (projected > FULL_PERCENT && window.percent < FULL_PERCENT) {
|
||||
const percentPerSecond = window.percent / elapsed
|
||||
if (percentPerSecond > 0) {
|
||||
hitsLimitAt = new Date(now.getTime() + ((FULL_PERCENT - window.percent) / percentPerSecond) * 1000)
|
||||
}
|
||||
}
|
||||
return { percent: projected, willOverflow: projected > FULL_PERCENT, hitsLimitAt, source: 'linear' }
|
||||
}
|
||||
|
||||
if (window.previous_final != null) {
|
||||
return {
|
||||
percent: window.previous_final,
|
||||
willOverflow: window.previous_final > FULL_PERCENT,
|
||||
hitsLimitAt: null,
|
||||
source: 'historical',
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function earliestReset(windows: PlanWindow[]): Date | null {
|
||||
const dates = windows
|
||||
.map(w => (w.resets_at ? new Date(w.resets_at) : null))
|
||||
.filter((d): d is Date => d !== null && !Number.isNaN(d.getTime()))
|
||||
if (dates.length === 0) return null
|
||||
return dates.reduce((a, b) => (a < b ? a : b))
|
||||
}
|
||||
10
windows/src/lib/platform.ts
Normal file
10
windows/src/lib/platform.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/// Paths shown in copy use the reader's own OS spelling.
|
||||
|
||||
export const IS_WINDOWS = navigator.userAgent.includes('Windows')
|
||||
|
||||
const HOME = IS_WINDOWS ? '%USERPROFILE%' : '~'
|
||||
const SEP = IS_WINDOWS ? '\\' : '/'
|
||||
|
||||
export function homePath(...parts: string[]): string {
|
||||
return [HOME, ...parts].join(SEP)
|
||||
}
|
||||
42
windows/src/lib/settings.ts
Normal file
42
windows/src/lib/settings.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/// The few preferences that live in the webview (everything the CLI also needs, like the
|
||||
/// currency, lives in ~/.config/codeburn/config.json via the Rust side).
|
||||
|
||||
const KEYS = {
|
||||
theme: 'codeburn.theme',
|
||||
insight: 'codeburn.insight',
|
||||
starBannerDismissed: 'codeburn.starBannerDismissed',
|
||||
trayBadge: 'codeburn.trayBadge',
|
||||
} as const
|
||||
|
||||
type Key = keyof typeof KEYS
|
||||
|
||||
export function readSetting(key: Key): string | null {
|
||||
try {
|
||||
return localStorage.getItem(KEYS[key])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSetting(key: Key, value: string | null): void {
|
||||
try {
|
||||
if (value === null) localStorage.removeItem(KEYS[key])
|
||||
else localStorage.setItem(KEYS[key], value)
|
||||
} catch {
|
||||
// Storage can be unavailable in a locked-down webview; preferences are best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export function currentTheme(): Theme {
|
||||
const stamped = document.documentElement.getAttribute('data-theme')
|
||||
if (stamped === 'dark' || stamped === 'light') return stamped
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme | null): void {
|
||||
if (theme) document.documentElement.setAttribute('data-theme', theme)
|
||||
else document.documentElement.removeAttribute('data-theme')
|
||||
writeSetting('theme', theme)
|
||||
}
|
||||
48
windows/src/lib/tips.ts
Normal file
48
windows/src/lib/tips.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { MenubarPayload } from './payload'
|
||||
import type { CurrencyState } from './currency'
|
||||
import { formatCompactCurrency, formatSmallCurrency } from './currency'
|
||||
import { computeHistoryStats } from './history'
|
||||
|
||||
export type TipItem = { text: string; trailing: string | null }
|
||||
export type TipGroup = { label: string; icon: string; items: TipItem[] }
|
||||
|
||||
const CACHE_HIT_GOOD = 80
|
||||
const CACHE_HIT_LOW = 50
|
||||
const ONESHOT_GOOD = 0.75
|
||||
const ONESHOT_LOW = 0.5
|
||||
const SPEND_DOWN_THRESHOLD = -10
|
||||
const SPEND_UP_THRESHOLD = 25
|
||||
const STREAK_MILESTONE = 5
|
||||
const MONTH_GROWTH_WARNING = 1.3
|
||||
const TOP_FINDINGS_COUNT = 3
|
||||
|
||||
export function computeTipGroups(payload: MenubarPayload, currency: CurrencyState): TipGroup[] {
|
||||
const stats = computeHistoryStats(payload.history.daily)
|
||||
const { cacheHitPercent, oneShotRate } = payload.current
|
||||
|
||||
const wins: TipItem[] = []
|
||||
if (cacheHitPercent >= CACHE_HIT_GOOD) wins.push({ text: `Cache hit at ${Math.round(cacheHitPercent)}% - most prompts reuse cache`, trailing: null })
|
||||
if (oneShotRate != null && oneShotRate >= ONESHOT_GOOD) wins.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - edits landing first try`, trailing: null })
|
||||
if (stats.weekDelta != null && stats.weekDelta < SPEND_DOWN_THRESHOLD) wins.push({ text: `Spend down ${Math.round(Math.abs(stats.weekDelta))}% vs last 7 days`, trailing: null })
|
||||
if (stats.currentStreak >= STREAK_MILESTONE) wins.push({ text: `${stats.currentStreak}-day usage streak`, trailing: null })
|
||||
|
||||
const improvements: TipItem[] = payload.optimize.topFindings.slice(0, TOP_FINDINGS_COUNT).map(f => ({
|
||||
text: f.title,
|
||||
trailing: formatSmallCurrency(f.savingsUSD, currency),
|
||||
}))
|
||||
|
||||
const risks: TipItem[] = []
|
||||
if (stats.weekDelta != null && stats.weekDelta > SPEND_UP_THRESHOLD) risks.push({ text: `Spend up ${Math.round(stats.weekDelta)}% vs prior 7 days`, trailing: null })
|
||||
if (cacheHitPercent > 0 && cacheHitPercent < CACHE_HIT_LOW) risks.push({ text: `Cache hit only ${Math.round(cacheHitPercent)}% - paying for cold prompts`, trailing: null })
|
||||
if (oneShotRate != null && oneShotRate < ONESHOT_LOW) risks.push({ text: `${Math.round(oneShotRate * 100)}% one-shot - lots of iteration`, trailing: null })
|
||||
if (stats.previousMonthTotal != null && stats.previousMonthTotal > 0 && stats.monthProjection > stats.previousMonthTotal * MONTH_GROWTH_WARNING) {
|
||||
const pct = Math.round(((stats.monthProjection - stats.previousMonthTotal) / stats.previousMonthTotal) * 100)
|
||||
risks.push({ text: `On pace for ${formatCompactCurrency(stats.monthProjection, currency)} this month (+${pct}% vs last)`, trailing: null })
|
||||
}
|
||||
|
||||
return [
|
||||
{ label: "What's working", icon: 'check', items: wins },
|
||||
{ label: 'What to improve', icon: 'up', items: improvements },
|
||||
{ label: 'Risks', icon: 'warn', items: risks },
|
||||
]
|
||||
}
|
||||
10
windows/src/main.tsx
Normal file
10
windows/src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { App } from './App'
|
||||
import './styles.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
1153
windows/src/styles.css
Normal file
1153
windows/src/styles.css
Normal file
File diff suppressed because it is too large
Load diff
75
windows/tokens.json
Normal file
75
windows/tokens.json
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
{
|
||||
"version": 1,
|
||||
"_comment": "Canonical design tokens for CodeBurn. The Swift mac/ app reads this at build time to populate Theme.swift; the windows/ Tauri frontend imports it as CSS custom properties. Side-by-side screenshots on macOS and Linux should read as the same product.",
|
||||
"color": {
|
||||
"brand": {
|
||||
"accent": "#C9521D",
|
||||
"accentDark": "#E8774A",
|
||||
"emberDeep": "#8B3E13",
|
||||
"emberGlow": "#F0A070",
|
||||
"bright": "#FF7A2A"
|
||||
},
|
||||
"surface": {
|
||||
"light": "#FAF7F3",
|
||||
"dark": "#1C1816",
|
||||
"elevated": "#FFFFFF",
|
||||
"elevatedDark": "#2A2320"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#1C1816",
|
||||
"primaryDark": "#FAF7F3",
|
||||
"secondary": "#6E5D53",
|
||||
"secondaryDark": "#B5A49A",
|
||||
"tertiary": "#A0897D",
|
||||
"tertiaryDark": "#8A7A70"
|
||||
},
|
||||
"categorical": {
|
||||
"claude": "#C9521D",
|
||||
"cursor": "#4A7D5C",
|
||||
"codex": "#5C7CA3",
|
||||
"pi": "#8B5A9C",
|
||||
"copilot":"#B8944C"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"family": {
|
||||
"sans": "Inter, 'Segoe UI Variable', 'SF Pro Text', system-ui, sans-serif",
|
||||
"mono": "'JetBrains Mono', 'SF Mono', 'Cascadia Code', Menlo, monospace",
|
||||
"rounded": "'SF Pro Rounded', Inter, system-ui, sans-serif"
|
||||
},
|
||||
"scale": {
|
||||
"hint": 9.5,
|
||||
"caption": 10.5,
|
||||
"body": 11.5,
|
||||
"label": 12.5,
|
||||
"heading": 16,
|
||||
"hero": 32
|
||||
},
|
||||
"weight": {
|
||||
"regular": 400,
|
||||
"medium": 500,
|
||||
"semibold": 600
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"xs": 2,
|
||||
"sm": 6,
|
||||
"md": 10,
|
||||
"lg": 14,
|
||||
"xl": 20
|
||||
},
|
||||
"radius": {
|
||||
"sm": 3,
|
||||
"md": 6,
|
||||
"lg": 8,
|
||||
"pill": 999
|
||||
},
|
||||
"layout": {
|
||||
"popoverWidth": 360,
|
||||
"popoverHeight": 660,
|
||||
"activityBarWidth": 56,
|
||||
"trendBarWidth": 13,
|
||||
"trendBarGap": 4,
|
||||
"trendChartHeight": 90
|
||||
}
|
||||
}
|
||||
24
windows/tsconfig.json
Normal file
24
windows/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "src-tauri"]
|
||||
}
|
||||
29
windows/vite.config.ts
Normal file
29
windows/vite.config.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// Tauri expects a fixed dev-server port so the Rust webview can connect reliably. The
|
||||
// `@tauri-apps/plugin-*` runtime expects these HMR + strictPort settings to mirror what
|
||||
// `tauri dev` spawns; tweaking them breaks the IPC bridge on first boot.
|
||||
const TAURI_DEV_PORT = 1420
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: TAURI_DEV_PORT,
|
||||
strictPort: true,
|
||||
host: process.env.TAURI_DEV_HOST || false,
|
||||
hmr: process.env.TAURI_DEV_HOST
|
||||
? { protocol: 'ws', host: process.env.TAURI_DEV_HOST, port: 1421 }
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ['**/src-tauri/**'],
|
||||
},
|
||||
},
|
||||
envPrefix: ['VITE_', 'TAURI_ENV_*'],
|
||||
build: {
|
||||
target: process.env.TAURI_ENV_PLATFORM === 'windows' ? 'chrome105' : 'safari13',
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
}))
|
||||
Loading…
Add table
Add a link
Reference in a new issue