diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a15d387..961a97b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,133 @@ name: Release -# Push a version tag (e.g. v0.1.0) to build the app and publish a GitHub Release. +# A version bump on main creates its tag and publishes automatically. Pushing a +# matching v* tag remains supported for manual recovery/re-runs. on: push: + branches: + - main tags: - "v*" + paths: + - package.json + - src-tauri/Cargo.toml + - src-tauri/tauri.conf.json workflow_dispatch: + inputs: + tag: + description: Existing version tag to publish, for example v1.3.0 + required: true + type: string + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: + prepare: + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + publish: ${{ steps.version.outputs.publish }} + tag: ${{ steps.version.outputs.tag }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Validate version and prepare tag + id: version + shell: bash + run: | + set -euo pipefail + VERSION=$(node scripts/version.mjs) + TAG="v${VERSION}" + + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + if [ "${GITHUB_REF_NAME}" != "$TAG" ]; then + echo "::error::Tag ${GITHUB_REF_NAME} does not match app version ${VERSION}" + exit 1 + fi + elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + if [ "${{ inputs.tag }}" != "$TAG" ]; then + echo "::error::Requested tag ${{ inputs.tag }} does not match app version ${VERSION}" + exit 1 + fi + if ! git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG does not exist" + exit 1 + fi + else + BEFORE="${{ github.event.before }}" + PREVIOUS_PACKAGE=$(git show "${BEFORE}:package.json" 2>/dev/null || true) + PREVIOUS_VERSION="" + if [ -n "$PREVIOUS_PACKAGE" ]; then + PREVIOUS_VERSION=$(printf '%s' "$PREVIOUS_PACKAGE" | node -e \ + 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(JSON.parse(s).version))') + fi + if [ "$VERSION" = "$PREVIOUS_VERSION" ]; then + echo "Version unchanged at $VERSION; nothing to publish." + echo "publish=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then + echo "::error::Tag $TAG already exists; choose a new version" + exit 1 + fi + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$TAG" -m "Tokenscope $TAG" + git push origin "$TAG" + fi + + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # Create the release once, here, before the platform jobs run. Two + # concurrent "create release" calls for the same tag race (one gets a + # 403 "Resource not accessible by integration"); pre-creating a draft + # lets every platform job find it and upload its assets, and the + # publish job flips it public only after all of them succeeded. + - name: Create draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if ! gh release view "$TAG" >/dev/null 2>&1; then + gh release create "$TAG" \ + --draft \ + --title "Tokenscope $TAG" \ + --notes "Work in progress — assets are uploaded by the platform build jobs; the release becomes public once every platform succeeds." \ + --repo "${{ github.repository }}" + fi + release: - # Only build/publish for version tags. workflow_dispatch from a branch would - # otherwise set the version/tag to the branch name and publish a malformed - # release (and fail the Homebrew step on the resulting 404). - if: startsWith(github.ref, 'refs/tags/v') + needs: prepare + if: needs.prepare.outputs.publish == 'true' permissions: contents: write strategy: - # One platform's failure must not cancel the other — a broken Windows - # bundler shouldn't block the macOS .dmg from publishing (and vice versa). + # One platform's failure must not cancel the other. fail-fast: false matrix: include: + # Build one app per CPU architecture so each package contains the + # matching single-architecture HappyUsage binary. + - os: macos-latest + target: aarch64-apple-darwin + rust-targets: aarch64-apple-darwin - os: macos-latest - target: universal-apple-darwin - rust-targets: aarch64-apple-darwin,x86_64-apple-darwin - - os: windows-latest - target: x86_64-pc-windows-msvc - rust-targets: x86_64-pc-windows-msvc + target: x86_64-apple-darwin + rust-targets: x86_64-apple-darwin runs-on: ${{ matrix.os }} + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} steps: - uses: actions/checkout@v6 @@ -53,107 +153,124 @@ jobs: workspaces: "./src-tauri -> target" - name: Install frontend deps - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Validate updater signing + shell: bash + run: | + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + echo "::error::TAURI_SIGNING_PRIVATE_KEY is required for updater artifacts" + exit 1 + fi - - name: Build & publish release + - name: Prepare release notes + id: release_notes + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gen=$(gh api --method POST \ + "repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="$RELEASE_TAG" --jq '.body' || true) + + { + cat <<'BODY' + Menu-bar / system-tray dashboard for local AI coding agent token usage (Claude Code + Codex). + + ## Install + + **macOS**: download `Tokenscope_*_aarch64.dmg` on Apple Silicon or `Tokenscope_*_x64.dmg` on Intel. Unsigned — right-click → **Open** on first launch, or install via `brew install --cask sunchj/tokenscope/tokenscope`. + + BODY + if [ -n "$gen" ]; then + printf '\n%s\n' "$gen" + else + printf '\n## What changed\n\n- Changes included since the previous release.\n' + fi + } > release-body.md + + { + echo "body<> "$GITHUB_OUTPUT" + + - name: Build and upload release (draft) uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # --- Code signing + notarization (optional, macOS only) --- - # UNSIGNED BUILD BY DEFAULT. Do NOT uncomment these until the matching - # secrets actually exist: tauri's bundler treats an empty - # APPLE_CERTIFICATE env var as "a certificate is present" and then - # fails on `security import` ("SecKeychainItemImport ... not valid"). - # - # Once you have an Apple Developer ID, add these in repo - # Settings → Secrets and uncomment to get signed + notarized builds: - # - # APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} # base64 of Developer ID .p12 - # APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} # .p12 export password - # APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} # "Developer ID Application: Name (TEAMID)" - # APPLE_ID: ${{ secrets.APPLE_ID }} # Apple ID email - # APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} # app-specific password - # APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} # 10-char Team ID - # Windows builds are also unsigned — users get a SmartScreen warning - # on first launch, see README "Install on Windows". with: - tagName: ${{ github.ref_name }} - releaseName: "Tokenscope ${{ github.ref_name }}" - releaseBody: | - Menu-bar / system-tray dashboard for Claude CLI token usage. - - **macOS**: download `Tokenscope_*_universal.dmg` (Apple Silicon + Intel). Unsigned — right-click → **Open** on first launch, or install via `brew install --cask hdusy/tokenscope/tokenscope`. - - **Windows**: download `Tokenscope_*_x64-setup.exe`. Unsigned — on first launch click **More info → Run anyway** to dismiss SmartScreen. - # Publish immediately (not a draft): the asset's public download URL - # must be live for the Homebrew Cask step below to fetch it and for - # `brew install` to work. - releaseDraft: false + tagName: ${{ env.RELEASE_TAG }} + releaseName: "Tokenscope ${{ env.RELEASE_TAG }}" + releaseBody: ${{ steps.release_notes.outputs.body }} + # Draft until every platform has uploaded: a failed job must not + # leave a partial public release with only one macOS architecture. + releaseDraft: true prerelease: false - args: --target ${{ matrix.target }} - - # Append an auto-generated changelog (PRs/commits since the previous tag) - # to the release notes. Best-effort: never fail the release over notes. - # Only run once (on the macOS leg) — both jobs publish to the same release. - - name: Add changelog to release - if: matrix.os == 'macos-latest' - continue-on-error: true + includeUpdaterJson: true + args: --target ${{ matrix.target }} --config src-tauri/tauri.release.conf.json + + publish: + needs: [prepare, release] + if: needs.prepare.outputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_TAG: ${{ needs.prepare.outputs.tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@v6 + + # Every platform uploaded its draft assets; flip the release public. + - name: Publish release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - gen=$(gh api --method POST \ - "repos/${{ github.repository }}/releases/generate-notes" \ - -f tag_name="${{ github.ref_name }}" --jq '.body' || true) - cur=$(gh release view "${{ github.ref_name }}" --json body --jq '.body' || true) - { - printf '%s\n' "$cur" - if [ -n "$gen" ]; then printf '\n%s\n' "$gen"; fi - } > release-body.md - gh release edit "${{ github.ref_name }}" --notes-file release-body.md + gh release edit "$RELEASE_TAG" --draft=false + echo "Published $RELEASE_TAG" - name: Update Homebrew Cask - if: matrix.os == 'macos-latest' env: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | - VERSION="${GITHUB_REF_NAME#v}" - DMG_NAME="Tokenscope_${VERSION}_universal.dmg" - DOWNLOAD_URL="https://github.com/HduSy/tokenscope/releases/download/${GITHUB_REF_NAME}/${DMG_NAME}" - - # Wait for the release asset to be available, then compute sha256. - # -f makes curl fail (non-zero) on a 404 so we keep retrying instead - # of hashing GitHub's error page and writing a bogus checksum. - SHA256="" - for i in $(seq 1 10); do - if curl -fsSL "$DOWNLOAD_URL" -o /tmp/tokenscope.dmg; then - SHA256=$(shasum -a 256 /tmp/tokenscope.dmg | cut -d' ' -f1) - break - fi - echo "Retry $i: asset not ready yet..." - sleep 10 - done - if [ -z "$SHA256" ]; then - echo "::error::Release asset $DMG_NAME never became available"; exit 1 + if [ -z "$HOMEBREW_TAP_TOKEN" ]; then + echo "HOMEBREW_TAP_TOKEN not set; skipping tap update." + exit 0 fi + BASE_URL="https://github.com/${{ github.repository }}/releases/download/${RELEASE_TAG}" + fetch_sha() { + arch="$1" + name="Tokenscope_${RELEASE_VERSION}_${arch}.dmg" + for i in $(seq 1 10); do + if curl -fsSL "${BASE_URL}/${name}" -o "/tmp/tokenscope-${arch}.dmg"; then + shasum -a 256 "/tmp/tokenscope-${arch}.dmg" | cut -d' ' -f1 + return 0 + fi + echo "Retry $i: $name not ready yet..." >&2 + sleep 10 + done + echo "::error::Release asset $name never became available" >&2 + return 1 + } + SHA_ARM="$(fetch_sha aarch64)" + SHA_INTEL="$(fetch_sha x64)" - # Clone the tap repo and update the cask - git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/HduSy/homebrew-tokenscope.git /tmp/homebrew-tap + git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/SunChJ/homebrew-tokenscope.git /tmp/homebrew-tap cat > /tmp/homebrew-tap/Casks/tokenscope.rb <= :catalina" + desc "Menu-bar dashboard for local AI coding agent token usage (Claude Code, Codex)" + homepage "https://github.com/${{ github.repository }}" + depends_on macos: :catalina app "Tokenscope.app" - # Unsigned/unnotarized build: strip the quarantine flag Homebrew - # adds so the app opens without the "Apple cannot verify" prompt. postflight do system_command "/usr/bin/xattr", args: ["-cr", "#{appdir}/Tokenscope.app"], @@ -166,5 +283,5 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Casks/tokenscope.rb - git diff --cached --quiet || git commit -m "Update cask to v${VERSION}" + git diff --cached --quiet || git commit -m "Update cask to ${RELEASE_TAG}" git push diff --git a/.gitignore b/.gitignore index 158acf8..36b40d2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ public/dev-dashboard.json .history/ *.log claude-design-files/ + +src-tauri/bin/hu diff --git a/README-zh.md b/README-zh.md index ea83de1..655a670 100644 --- a/README-zh.md +++ b/README-zh.md @@ -2,156 +2,49 @@ [English](README.md) · **中文** -Tokenscope - MacOS menu-bar dashboard for Claude CLI token usage | Product Hunt +**macOS 菜单栏工具**,为本机 **AI 编码 Agent**(Pi、Claude Code、Codex CLI)提供一个统一仪表盘:**Token 用量、估算花费、按模型 / MCP / Skill 的统计,以及实时订阅额度**。 -**macOS 菜单栏 / Windows 系统托盘工具**,展示 Claude CLI 的 **每日 Token 用量、估算花费、按模型 / MCP / Skill 的调用统计**。 - -技术栈:**Tauri 2 + React + TypeScript**(前端)/ **Rust**(数据层)。 +技术栈:**Tauri 2 + React + TypeScript**(前端)+ **Rust**(数据层)。 ![Tokenscope 面板(深色 / 浅色)](docs/screenshot.png) -## 它做什么 - -- 菜单栏图标旁显示当日 Token 数(如 `⬡ 14.00M`) -- 点击打开面板:Day / Week / Month 切换 -- 指标:总 Token(input/output)、估算花费、Requests / Sessions -- 三个切片:**按模型** / **按 MCP 调用** / **按 Skill 调用** -- 成本甜甜圈(hover 看单模型)、年度活跃热力图 -- **只统计用户自己安装的 MCP / Skill**,过滤所有 Claude 内置工具与 Anthropic 自带 MCP - -## 数据来源(零侵入,只读) - -| 用途 | 路径 | -|------|------| -| 会话日志(Token / 模型 / 工具调用) | `~/.claude/projects/**/*.jsonl` | -| 用户 MCP 白名单 | `~/.claude.json` → `mcpServers` + `projects[*].mcpServers` | -| 用户 Skill 白名单 | `~/.claude/skills/` 目录 | -| 模型价格 | **主**:[models.dev](https://models.dev/api.json)(裸模型名,匹配 Claude CLI 日志)→ **兜底**:[LiteLLM](https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json) → 内置快照。缓存于 `~/Library/Caches/tokenscope/`,24h 刷新,离线回退 | - -### 关键处理 -- 按 `message.id` 去重(流式/重试会重复 usage);同一消息跨多行时合并其工具调用,token 只计一次 -- token 拆分:`input`(未缓存) / `cache`(creation+read) / `output`;UI 默认把 cache 并入 In 显示,并单列「cached %」 -- 价格匹配:精确名 → 归一化名(去厂商前缀 + `.`↔`p`,如 `glm-5.1`⇄`glm-5p1`);models.dev 优先官方裸名价 -- 成本按四类 token 分别计价;模型带 `priced` 标记,**两源都查不到的模型只计 Token、UI 标注「暂无定价」** -- 日志只有裸模型名、无厂商信息 → 第三方模型默认取官方厂商价(估算) -- 工具分类:`mcp____*` 且 server 在用户配置中 → MCP;Skill 调用(`Skill` 工具的 `input.skill`,或 `/skill` 斜杠命令)且在 skills 目录中 → Skill;其余忽略 - -> 花费为按公开价格的**估算**;订阅用户应理解为「等效消费价值」。 - -### 四类 Token 与计价公式 - -每条 assistant 消息的 `usage` 给出四个**互斥**的 token 计数(同一 token 不会被重复统计): - -| 阶段 | `usage` 字段 | 含义 | 单价(相对 input) | -|------|-------------|------|------------------| -| **Input**(未缓存) | `input_tokens` | 本轮新发送的提示词 token | 1× | -| **Cache 写入** | `cache_creation_input_tokens` | 写入提示缓存的上下文 | 约 1.25× | -| **Cache 命中**(读) | `cache_read_input_tokens` | 从缓存重放的上下文 | 约 0.1×(便宜很多) | -| **Output** | `output_tokens` | 模型生成的 token | 约 5× | - -**Tokens**(按周期对消息求和): +## 亮点 -``` -total = input + cache_creation + cache_read + output -# UI 展示: In = input + cache_creation + cache_read, Out = output, cached % = cache_read / total -``` - -**Cost**(每个阶段各按价格表里自己的单价计算): - -``` -cost = input × price.input - + cache_creation × price.cache_creation - + cache_read × price.cache_read # 缓存命中按折扣后的 read 单价计费 - + output × price.output -``` - -所以缓存命中**不会**按普通 input 计费,而是用专门(更便宜)的 `cache_read` 单价——这就是重度缓存场景下 token 量很大、花费却不高的原因。UI 只是把 cache 折进「In」做展示,计费始终按上面四个独立单价。 +- **一个仪表盘,覆盖所有 Agent** — 过滤 chips(All / Claude / Codex / Pi)在汇总与单 Agent 视图间切换,各自带品牌色主题 +- **Token 与成本分析** — 今天 / 本周 / 本月 + 自定义日期区间;按模型、MCP 调用、Skill 切片(只统计你自己安装的,内置工具全部过滤) +- **实时订阅额度** — Claude(5 小时 + 每周)与 Codex(每周 + Spark)每 5 分钟刷新,Codex status 风格展示(`29% left (resets 05:01 on 9 Aug)`),含消耗速率预测 +- **菜单栏一瞥即知** — 当日 Token 数 + 各 provider 最紧张窗口的紧凑额度摘要(`52.8M · Cl79% · Cx29%`)或完整列表 +- **项目结算** — 按 Git 仓库 / 工作目录归集用量,导出 CSV,不暴露原始路径或对话正文 +- **可靠性与上下文遥测** — 中止任务浪费、工具错误、上下文窗口压力、压缩次数与 reasoning 占比 +- **本地优先、只读** — 就地解析 session 日志;额度来自内置 HappyUsage CLI(不缓存凭据,不用本地日志计算额度) ## 安装 -### 方式一:Homebrew(推荐) - ```bash -brew install --cask hdusy/tokenscope/tokenscope +brew install --cask sunchj/tokenscope/tokenscope ``` -安装后会自动清除隔离属性(cask 的 `postflight` 已内置 `xattr -cr`),**首次直接打开即可,不会弹「Apple 无法验证」**。 +DMG 直接下载与更新方式见 [Releases](https://github.com/SunChJ/tokenscope-remix/releases)。 -打开一次后即注册为登录项,之后**每次开机自动在菜单栏运行**。 +## 快速上手 -升级: - -```bash -brew update && brew upgrade --cask tokenscope -``` - -### 方式二:下载 .dmg - -1. 从 [Releases](https://github.com/HduSy/tokenscope/releases) 下载最新的 `Tokenscope_*_universal.dmg`(同时支持 Apple Silicon 与 Intel) -2. 拖入「应用程序」 -3. 因为是**未签名 / 未公证**构建,首次打开会被 Gatekeeper 拦截,二选一: - - 右键 App →「打开」→ 再次确认「打开」,或 - - 终端执行一次: - ```bash - xattr -cr /Applications/Tokenscope.app && open /Applications/Tokenscope.app - ``` - -> 未签名是当前的已知限制。要彻底「双击直开」需 Apple Developer ID 签名 + 公证,见 `PRD.md` §6.4。 - -### 方式三:Windows 安装 - -1. 从 [Releases](https://github.com/HduSy/tokenscope/releases) 下载最新的 `Tokenscope_*_x64-setup.exe` -2. 双击安装。因为是**未签名**构建,首次运行会被 SmartScreen 拦截 —— 点 **"更多信息" → "仍要运行"** 即可 -3. 安装器按当前用户安装(无需管理员权限),并**自动注册开机自启** -4. 系统要求:**Windows 10 1803 及以上 / Windows 11**,需要 WebView2 运行时(Win 11 预装;Win 10 用户若没装,安装器会引导补装) - -### 首次启动后 - -- **macOS**:菜单栏出现图标 + 当日 Token 数(如 `⬡ 12.40M`) -- **Windows**:系统托盘出现图标。Windows 任务栏托盘 API 不支持在图标旁显示文字,**鼠标悬停托盘图标**即可看到当日 Token 数(提示气泡形如 `Tokenscope · today 12.40M`) -- 左键点击图标开/关面板,右键出菜单(Open / Refresh / Quit) -- 已自动设置**登录自启**,无需手动配置 +1. 启动后,菜单栏 / 托盘出现图标和当日 Token 数 +2. **左键**切换仪表盘;**右键**打开菜单(额度详情、菜单栏显示、Dashboard 快捷键、语言等) +3. **菜单栏显示**控制 Token 数旁的额度摘要:关闭 / 紧凑 / 详细 ## 开发 ```bash pnpm install -pnpm tauri dev # 启动桌面 App(需要 Rust 工具链) +pnpm tauri dev # 桌面 App(需要 Rust 工具链) +pnpm tauri build # macOS:.app / .dmg ``` -仅预览前端(用真实数据快照 `public/dev-dashboard.json`): +## 文档 -```bash -pnpm dev # http://localhost:1420 -# 刷新快照: -cd src-tauri && cargo run --example dump > ../public/dev-dashboard.json -``` - -## 构建 - -```bash -pnpm tauri build # macOS 产出 .app / .dmg,Windows 产出 .exe (NSIS),均位于 src-tauri/target/release/bundle/ -``` - -分发见 `PRD.md` §6.3(macOS 推荐 Homebrew Cask;`.dmg` / `.exe` 直接下载建议代码签名 + 公证)。 - -## 结构 - -``` -src/ React 前端 - data.ts 类型 + Tauri 桥 + 主题 + 格式化 - charts.tsx 图表原语(柱状/甜甜圈/sparkline/热力图/分段控件) - App.tsx 主面板 -src-tauri/src/ - store.rs JSONL 增量摄取(按 message.id 去重 + 多行合并) - parser.rs 聚合(Day/Week/Month + 热力图) - pricing.rs models.dev / LiteLLM 价格加载与计价 - config.rs 用户 MCP / Skill 白名单 - model.rs 返回给前端的数据结构 - lib.rs Tauri 命令 + 菜单栏托盘 -``` +- 数据来源与统计口径:[docs/DESIGN-usage-api.md](docs/DESIGN-usage-api.md)、[docs/DESIGN-pi-adapter.md](docs/DESIGN-pi-adapter.md)、[docs/DESIGN-multi-agent.md](docs/DESIGN-multi-agent.md) +- 已知问题与修复记录:[docs/BUGFIXES.md](docs/BUGFIXES.md) -## Bug 记录 +## 致谢 -开发过程中遇到的典型 bug(现象、根因、解决办法)汇总在 -[docs/BUGFIXES.md](docs/BUGFIXES.md)。 +本项目源自 [@HduSy](https://github.com/HduSy) 的 [tokenscope](https://github.com/HduSy/tokenscope)——继承的 Rust 数据摄取/聚合架构与面板设计均出自原作者之手,本仓库沿用 MIT 协议。Provider 额度使用 [HappyUsage](https://github.com/SunChJ/happyusage)(`hu`)。问题与需求请在本仓库提交。 diff --git a/README.md b/README.md index bd62b00..04109b3 100644 --- a/README.md +++ b/README.md @@ -2,156 +2,49 @@ **English** · [中文](README-zh.md) -Tokenscope - MacOS menu-bar dashboard for Claude CLI token usage | Product Hunt +A macOS menu-bar app that gives you **one dashboard for all your local AI coding agents** — token usage, estimated cost, per-model / MCP / Skill breakdowns, and live subscription quota. -A **menu-bar / system-tray app for macOS and Windows** that shows your Claude CLI **daily token usage, estimated cost, and per-model / MCP / Skill call breakdown**. - -Stack: **Tauri 2 + React + TypeScript** (frontend) / **Rust** (data layer). +Built with **Tauri 2 + React + TypeScript**, backed by a Rust data layer. Works with **Pi, Claude Code, and Codex CLI**. ![Tokenscope panel (dark / light)](docs/screenshot.png) -## What it does - -- Shows today's token count next to the menu-bar icon (e.g. `⬡ 14.00M`) -- Click to open the panel: Day / Week / Month toggle -- Metrics: total tokens (input/output), estimated cost, requests / sessions -- Three breakdowns: **by model** / **by MCP call** / **by Skill call** -- Cost donut (hover for a single model), year-long activity heatmap -- **Counts only the MCP servers / Skills you installed yourself** — all Claude built-in tools and Anthropic's bundled MCP servers are filtered out - -## Data sources (zero-intrusion, read-only) - -| Purpose | Path | -|---------|------| -| Session logs (tokens / model / tool calls) | `~/.claude/projects/**/*.jsonl` | -| User MCP whitelist | `~/.claude.json` → `mcpServers` + `projects[*].mcpServers` | -| User Skill whitelist | `~/.claude/skills/` directory | -| Model prices | **Primary**: [models.dev](https://models.dev/api.json) (bare model names, matching Claude CLI logs) → **Fallback**: [LiteLLM](https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json) → built-in snapshot. Cached in `~/Library/Caches/tokenscope/`, refreshed every 24h, with offline fallback | - -### Key processing -- Deduplicated by `message.id` (streaming/retries repeat the same usage); when one message spans multiple lines, its tool calls are merged and the token usage is counted once -- Token split: `input` (uncached) / `cache` (creation+read) / `output`; the UI folds cache into "In" by default and shows a separate "cached %" -- Price matching: exact id → normalized id (strip vendor prefix + `.`↔`p`, e.g. `glm-5.1`⇄`glm-5p1`); models.dev's official bare-name price wins -- Cost is priced per the four token types; each model carries a `priced` flag — **models not found in either source still count tokens but are labelled "no price" in the UI** -- Logs contain only the bare model name (no vendor) → third-party models default to the official vendor price (an estimate) -- Tool classification: `mcp____*` where the server is in your config → MCP; a Skill call (the `Skill` tool's `input.skill`, or a `/skill` slash command) whose name is in your skills directory → Skill; everything else is ignored - -> Cost is an **estimate** based on public prices; subscription users should read it as "equivalent spend value". - -### Token types & cost formula - -Every assistant message's `usage` reports four **mutually exclusive** token counts (they never double-count the same token): - -| Stage | `usage` field | What it is | Price (relative to input) | -|-------|---------------|------------|---------------------------| -| **Input** (uncached) | `input_tokens` | New prompt tokens sent this turn | 1× | -| **Cache write** | `cache_creation_input_tokens` | Context written into the prompt cache | ~1.25× | -| **Cache read** (hit) | `cache_read_input_tokens` | Context replayed from the cache | ~0.1× (much cheaper) | -| **Output** | `output_tokens` | Tokens the model generated | ~5× | - -**Tokens** (per period, summed over messages): +## Highlights -``` -total = input + cache_creation + cache_read + output -# the UI shows: In = input + cache_creation + cache_read, Out = output, cached % = cache_read / total -``` - -**Cost** (each stage priced at its own per-token rate from the price table): - -``` -cost = input × price.input - + cache_creation × price.cache_creation - + cache_read × price.cache_read # cache hits billed at the discounted read rate - + output × price.output -``` - -So a cache hit is **not** billed as normal input — it uses the dedicated (cheaper) `cache_read` rate, which is why heavily-cached usage shows a huge token count but a modest cost. The UI folds cache into "In" for display only; billing always uses the four separate rates above. +- **One dashboard, every agent** — filter chips (All / Claude / Codex / Pi) switch between aggregated and per-agent views, each with its own accent theme +- **Token & cost analytics** — day / week / month plus custom date ranges; breakdowns by model, MCP calls, and Skills (yours only — built-ins are filtered out) +- **Live subscription quota** — Claude (5-hour + weekly) and Codex (weekly + Spark) refreshed every 5 minutes, shown Codex-status style (`29% left (resets 05:01 on 9 Aug)`), with a burn-rate projection +- **Menu-bar glanceability** — today's tokens plus a compact per-provider quota summary (`52.8M · Cl79% · Cx29%`) or a detailed list +- **Project settlement** — usage grouped by Git repo / working directory, exported as CSV without raw paths or conversation content +- **Reliability & context telemetry** — aborted-turn waste, tool errors, context-window pressure, compaction and reasoning share +- **Local-first and read-only** — parses session logs in place; quota comes from the bundled HappyUsage CLI (no credentials stored, no local log-based quota) ## Install -### Option 1: Homebrew (recommended) - ```bash -brew install --cask hdusy/tokenscope/tokenscope +brew install --cask sunchj/tokenscope/tokenscope ``` -The cask's `postflight` strips the quarantine attribute (`xattr -cr`) automatically, so **it opens on first launch without the "Apple cannot verify" prompt**. +DMG downloads and update details: see [Releases](https://github.com/SunChJ/tokenscope-remix/releases). -After you open it once it registers as a login item, then **launches in the menu bar automatically on every boot**. +## Quick start -Upgrade: - -```bash -brew update && brew upgrade --cask tokenscope -``` - -### Option 2: Download the .dmg - -1. Download the latest `Tokenscope_*_universal.dmg` from [Releases](https://github.com/HduSy/tokenscope/releases) (works on both Apple Silicon and Intel) -2. Drag it into Applications -3. Because the build is **unsigned / unnotarized**, Gatekeeper blocks the first launch — pick one: - - Right-click the app → **Open** → confirm **Open** again, or - - Run once in the terminal: - ```bash - xattr -cr /Applications/Tokenscope.app && open /Applications/Tokenscope.app - ``` - -> Unsigned is a current known limitation. A true "double-click to open" experience requires Apple Developer ID signing + notarization — see `PRD.md` §6.4. - -### Option 3: Install on Windows - -1. Download the latest `Tokenscope_*_x64-setup.exe` from [Releases](https://github.com/HduSy/tokenscope/releases) -2. Double-click to install. Because the build is **unsigned**, Windows SmartScreen will warn on first run — click **More info → Run anyway** -3. The app installs per-user (no admin required) and registers itself for **launch at login** automatically -4. Requirements: **Windows 10 1803+ / Windows 11** with the WebView2 runtime (preinstalled on Windows 11; Windows 10 users without it will be prompted by the installer) - -### After first launch - -- **macOS**: an icon plus today's token count appears in the menu bar (e.g. `⬡ 12.40M`) -- **Windows**: the tray icon appears in the notification area. The Windows tray API doesn't show a label beside the icon — **hover the tray icon** to see today's token count in the tooltip (e.g. `Tokenscope · today 12.40M`) -- Left-click the icon to toggle the panel; right-click for the menu (Open / Refresh / Quit) -- **Launch-at-login is set up automatically** — no manual configuration needed +1. Launch — an icon with today's token count appears in the menu bar / tray +2. **Left-click** toggles the dashboard; **right-click** for the menu (Provider Limits, Menu Bar Display, Dashboard Shortcut, language, …) +3. **Menu Bar Display** controls the quota summary next to the token count: Off / Compact / Detailed ## Develop ```bash pnpm install -pnpm tauri dev # launch the desktop app (requires the Rust toolchain) +pnpm tauri dev # desktop app (requires the Rust toolchain) +pnpm tauri build # macOS .app / .dmg ``` -Frontend-only preview (using the real-data snapshot `public/dev-dashboard.json`): +## Docs -```bash -pnpm dev # http://localhost:1420 -# refresh the snapshot: -cd src-tauri && cargo run --example dump > ../public/dev-dashboard.json -``` - -## Build - -```bash -pnpm tauri build # outputs .app / .dmg on macOS, .exe (NSIS) on Windows to src-tauri/target/release/bundle/ -``` - -For distribution see `PRD.md` §6.3 (Homebrew Cask recommended on macOS; direct `.dmg` / `.exe` downloads benefit from code signing + notarization). - -## Structure - -``` -src/ React frontend - data.ts types + Tauri bridge + theme + formatting - charts.tsx chart primitives (bars / donut / sparkline / heatmap / segmented control) - App.tsx main panel -src-tauri/src/ - store.rs incremental JSONL ingest (dedup by message.id + multi-line merge) - parser.rs aggregation (Day/Week/Month + heatmap) - pricing.rs models.dev / LiteLLM price loading and costing - config.rs user MCP / Skill whitelist - model.rs data structures returned to the frontend - lib.rs Tauri commands + menu-bar tray -``` +- Data sources & accounting model: [docs/DESIGN-usage-api.md](docs/DESIGN-usage-api.md), [docs/DESIGN-pi-adapter.md](docs/DESIGN-pi-adapter.md), [docs/DESIGN-multi-agent.md](docs/DESIGN-multi-agent.md) +- Known bugs and fixes: [docs/BUGFIXES.md](docs/BUGFIXES.md) -## Bug log +## Acknowledgements -Notable bugs found during development — symptom, root cause, and fix — are -collected in [docs/BUGFIXES.md](docs/BUGFIXES.md). +A remix of [tokenscope](https://github.com/HduSy/tokenscope) by [@HduSy](https://github.com/HduSy) — the clean Rust ingest/aggregation architecture and panel design are his work; this repo stays under the MIT license. Provider quota uses [HappyUsage](https://github.com/SunChJ/happyusage) (`hu`). Issues and feature requests go to this repository. diff --git a/docs/BUGFIXES.md b/docs/BUGFIXES.md index eaed043..264c029 100644 --- a/docs/BUGFIXES.md +++ b/docs/BUGFIXES.md @@ -50,9 +50,76 @@ fix. Newest first. Useful as a reference for similar issues. --- +## Data accuracy (cost & token totals) + +### 4. Cache tokens priced at $0, undercounting Claude cost ~4× + +- **Symptom**: Claude's monthly cost read several-fold too low (~4× on the logs + it was found with), while Codex looked correct. The error came and went + depending on which model was in use. +- **Cause**: models.dev lists the same model under many providers (first-party + vendor plus resellers / gateways / clouds). `ingest_modelsdev` is + first-writer-wins over providers iterated in key order — and `serde_json` + has no `preserve_order`, so that order is **alphabetical**. The previous sort + only pushed bare ids ahead of `vendor/model` ones, so among bare entries the + alphabetically first provider won. For `claude-fable-5` that is `abacus`, + whose entry is not a cheaper price but an **incomplete record**: identical + `input`/`output` to Anthropic's, with `cache_read`/`cache_write` simply + absent, so both defaulted to `0`. Claude Code usage is ~95%+ cache-read + tokens, so this charged for only a small fraction of the volume. + `claude-sonnet-5` was worse — the shadowing entry also carried a 50% higher + input price. +- **Fix**: Added `is_first_party()` and ordered entries best-first in + `pricing.rs`: the model's first-party vendor, then entries that actually + carry cache pricing, then bare ids over `vendor/model` duplicates. Ported + from upstream `3a30273` / `0987a4c`. + +### 5. Forked Codex threads double-counted their parent's history + +- **Symptom**: Codex token totals ran several-fold high — most of the ingested + Codex volume was duplicate — the model breakdown didn't sum to Total tokens, + some events were attributed to the wrong session and project, and the weekly + quota bar sat pinned at 100% long after the window had rolled over. +- **Cause**: Codex forks a subagent/resumed thread by replaying the parent + thread's **entire history** — `token_count`, `task_started`/`task_complete`, + tool calls, even the parent's `session_meta` — into the head of the child's + rollout file, all restamped with the fork instant. Claude events dedupe by + `message.id`, but **Codex events carry no id**, so nothing caught it and + every replayed turn was counted a second time. The replayed `session_meta` + also clobbered the child's own session id and cwd. Because `state.model` is + only set by `turn_context`, the replayed events also had an empty model, so + they landed in Total tokens but were excluded from requests, the model + breakdown, and cost. +- **First attempt (v9, incomplete)**: `FileState` gained `replaying`/`meta_seen` + and a forked file skipped every line until its own first `turn_context`. That + assumed the replay always *precedes* the fork's first turn. It does in one + layout; in the more common one the fork's `turn_context` sits at line ~7 and + the parent's history is replayed **after** it, so the window closed + immediately and the replay was ingested anyway. The window check caught a + small minority of replayed events and missed an order of magnitude more. +- **Why the first verification missed it**: the parser was reconciled against a + "ground truth" that summed every `token_count` in the raw logs. Both sides + shared the same duplicate-counting flaw, so agreeing to 0.01% proved only that + they were consistent, not that either was right. A reconciliation has to be + derived independently of the code under test. +- **Fix (v10)**: dedup by content instead of by position. Codex `token_count` + lines carry no id, but a replay reproduces the parent's turns verbatim — same + turn ids, same order — so `(turn_id, position-within-turn)` identifies a + replayed event as one already ingested, exactly the way `message.id` does for + Claude. The check runs *before* any side effect, so a replay can no longer + double-count tokens, duplicate turn telemetry, or let the parent's stale + `rate_limits` win the newest-snapshot race. Files are now walked in path order + (chronological for Codex's `YYYY/MM/DD/rollout-` layout) so the original, + not the restamped replay, is the first writer. `STORE_VERSION` 10. +- **Known limitation**: dedup needs a turn id. Events parsed before any + `turn_context` is seen get no id and are still counted as-is; that is the + conservative direction (keep, don't drop), and such events are rare. + +--- + ## Release & distribution (CI) -### 4. Release CI failed: empty Apple signing env var +### 6. Release CI failed: empty Apple signing env var - **Symptom**: The `v0.1.1` build failed at the bundle step with `security: SecKeychainItemImport: ... parameters ... not valid` / @@ -65,7 +132,7 @@ fix. Newest first. Useful as a reference for similar issues. - **Fix**: Commented out the Apple signing/notarization env in `release.yml` until the real secrets exist. The build now does ad-hoc signing, like local. -### 5. GitHub Release had no .dmg / .app — it was a draft +### 7. GitHub Release had no .dmg / .app — it was a draft - **Symptom**: "The release has no artifacts." - **Cause**: `releaseDraft: true` — the build *did* succeed and attach the @@ -76,7 +143,7 @@ fix. Newest first. Useful as a reference for similar issues. - **Fix**: Set `releaseDraft: false` so each tag publishes immediately and the asset URL is live for the Homebrew step and `brew install`. -### 6. Homebrew Cask step would hash a 404 page +### 8. Homebrew Cask step would hash a 404 page - **Symptom**: Latent — the cask `sha256` could be computed from an error page. - **Cause**: The cask step fetched the asset with `curl -sL` (no `-f`), so a 404 @@ -85,7 +152,7 @@ fix. Newest first. Useful as a reference for similar issues. - **Fix**: Use `curl -fsSL` so a missing asset fails and retries; fail loudly (`exit 1`) if the asset never appears. -### 7. DMG name didn't match the tag +### 9. DMG name didn't match the tag - **Symptom**: A `v0.1.1` tag would build `Tokenscope_0.1.0_universal.dmg`, which the cask step (computing the name from the tag) couldn't download → 404. @@ -99,7 +166,7 @@ fix. Newest first. Useful as a reference for similar issues. ## App behavior & packaging -### 8. Two menu-bar icons after reinstall +### 10. Two menu-bar icons after reinstall - **Symptom**: Reinstalling/relaunching left two Tokenscope icons in the menu bar. @@ -108,7 +175,7 @@ fix. Newest first. Useful as a reference for similar issues. - **Fix**: Added `tauri-plugin-single-instance` (registered first) so a second launch hands off to the running instance (showing the popover) and exits. -### 9. Unsigned app blocked by Gatekeeper on first open +### 11. Unsigned app blocked by Gatekeeper on first open - **Symptom**: "Apple cannot verify Tokenscope.app is free of malware." - **Cause**: The build is unsigned/unnotarized, and Homebrew adds a quarantine @@ -118,7 +185,7 @@ fix. Newest first. Useful as a reference for similar issues. manual right-click → Open or `xattr -cr`; a full fix needs Developer ID signing + notarization.) -### 10. App icon had opaque white corners +### 12. App icon had opaque white corners - **Symptom**: The rounded app icon showed white square corners in Launchpad. - **Cause**: The icon PNGs had a white (opaque) background in the corners @@ -131,7 +198,7 @@ fix. Newest first. Useful as a reference for similar issues. ## UI / charts -### 11. Bar-chart tooltip overlapped the legend above it +### 13. Bar-chart tooltip overlapped the legend above it - **Symptom**: Hovering a token bar showed its tooltip floating up over the Total-tokens "Input … cached" legend, even for short bars. @@ -143,7 +210,7 @@ fix. Newest first. Useful as a reference for similar issues. *visible bar top* (`r.bottom − barPx`, baseline minus bar height) instead of the column top, so short bars get a low tooltip clear of the legend. -### 12. Mockup tooltip drifted to the panel centre (backdrop-filter) +### 14. Mockup tooltip drifted to the panel centre (backdrop-filter) - **Symptom**: In `tokenscope-panel.html` only, the heatmap/bar tooltips appeared near the middle of the panel instead of next to the hovered cell/bar. @@ -157,7 +224,7 @@ fix. Newest first. Useful as a reference for similar issues. relative to each chart's own wrapper (coords offset from the wrapper rect). The mockup never scrolls, so it doesn't need `fixed`. -### 13. Total-tokens bar showed slivers when usage was zero +### 15. Total-tokens bar showed slivers when usage was zero - **Symptom**: With no usage in the period (Total = 0.00M), the input/output split bar still showed a small coloured sliver instead of being empty. @@ -167,34 +234,11 @@ fix. Newest first. Useful as a reference for similar issues. and only render the coloured segments when `totalTokens > 0`; otherwise the bar is just the empty track. -### 16. Total-tokens split bar didn't fill — gray track showed through - -- **Symptom**: The 2-colour split bar under Total tokens read as only partly - filled — coloured segments on the left, gray track visible on the right — - instead of always 100% when there was usage. Most visible when the split was - lopsided (e.g. right after clearing stats, with output ≈ 0). -- **Cause**: The two segments used `flexGrow` + `flexBasis: 0` (+ `minWidth: 4`). - In the WebKit webview that combination sizes each segment to roughly **its own - grow factor as an absolute fraction of the bar**, not the **grow-factor ratio** - — so `flexGrow` 0.10 vs 1e-6 covered ~10% of the bar, not ~100%, and the track - showed through. The data was correct (verified by dumping `build_dashboard`'s - JSON and computing the expected ratio); only the rendering was wrong. -- **Fix** (`src/App.tsx`): use explicit `width: X%` instead of `flexGrow` - (interpreted correctly, always sums to exactly 100%). While here, re-purposed - the bar from input+cache vs output to **cached vs rest (uncached input + - output)**: the dark segment is the cache share (matching the "% cached" label), - and "rest" is wider than output-alone, so a small non-cached share still reads - past the pill's rounded corner without distorting the ratio. Pill shape kept; - the `SplitLegend` below was changed from "Input / Output" to "Cached / New" to - match. (A temporary "清零" tray menu item — clearing the cache and - fast-forwarding ingest offsets — was used to reproduce the lopsided state, then - removed.) - --- ## Theme -### 14. "System" theme mode didn't follow the macOS appearance +### 16. "System" theme mode didn't follow the macOS appearance - **Symptom**: On macOS, the "System" theme option didn't track the OS dark/light mode — neither when toggling system appearance with the popover open, nor after @@ -223,7 +267,7 @@ fix. Newest first. Useful as a reference for similar issues. `block` re-exports already imported in `lib.rs`. (`src-tauri/src/lib.rs`, `src/App.tsx`) -### 15. Selected period pill flashed white→transparent on a light→dark switch +### 17. Selected period pill flashed white→transparent on a light→dark switch - **Symptom**: After the fix above, switching the system theme from light to dark while the popover was hidden, then opening it, showed a brief "white → @@ -246,6 +290,16 @@ fix. Newest first. Useful as a reference for similar issues. Day/Week/Month still animates). Skipped on the very first render. (`src/main.tsx`, `src/App.tsx`) +### 18. Total-tokens split bar didn't fill — gray track showed through + +- **Symptom**: The split bar under Total tokens was only partly filled, leaving + gray track visible on the right. It was most obvious with a lopsided split. +- **Cause**: WebKit incorrectly sized segments using `flexGrow` with + `flexBasis: 0` instead of treating the values as a ratio. +- **Fix** (`src/App.tsx`): use explicit percentage widths for both the per-agent + bar and the single-agent cached/new bar. The latter now matches its legend: + dark is cached tokens and light is uncached input plus output. + --- ## Notes diff --git a/docs/DESIGN-multi-agent.md b/docs/DESIGN-multi-agent.md new file mode 100644 index 0000000..99cb20e --- /dev/null +++ b/docs/DESIGN-multi-agent.md @@ -0,0 +1,226 @@ +# TokenScope 多智能体用量设计方案(Multi-Agent Design) + +> 版本:v0.1 草案 · 2026-07-02 +> 目标:从「Claude 用量仪表盘」演进为「本地所有 AI Agent 的统一用量仪表盘」。 +> 第一步:支持 Codex CLI。 +> +> 此文档保留 Codex Phase 1 的历史设计;后续 Pi 一等数据源设计见 [DESIGN-pi-adapter.md](DESIGN-pi-adapter.md)。 + +--- + +## 1. 产品定位升级 + +### 1.1 新的一句话定位 + +> 一个常驻菜单栏的小工具,统一展示本机所有 AI 编码工具(Claude Code、Codex、未来更多)的 Token 用量、花费与使用习惯。 + +### 1.2 为什么是这个方向 + +- 开发者普遍**同时使用多个 CLI Agent**(Claude Code + Codex 双持很常见),但每个工具的用量入口互相割裂。 +- 各工具都把会话日志落在本地(`~/.claude`、`~/.codex`…),零侵入解析的技术路线可以完全复用。 +- 「跨 Agent 对比」本身就是新价值:这周 Claude 和 Codex 各烧了多少?哪个项目在用哪个工具? + +### 1.3 核心原则(继承自现有 PRD,不变) + +1. 零侵入:只读日志,不装 hook、不代理流量。 +2. 全本地:不上传任何数据。 +3. 花费永远标注「估算 (est.)」。 + +--- + +## 2. 数据源抽象:Source Adapter + +这是本次架构的核心:把「Claude」从硬编码变成第一个 **UsageSource 适配器**。 + +``` +trait UsageSource { + fn id() -> "claude" | "codex" | ... // 稳定标识 + fn display_name() -> "Claude Code" | "Codex" + fn detect() -> bool // 本机是否安装/有数据 + fn watch_paths() -> Vec // FS 监听目录 + fn parse_increment(file, offset) -> Vec // 增量解析 +} +``` + +### 2.1 统一事件模型(Normalized Schema) + +所有适配器解析后归一化为同一结构,聚合层/UI 层完全 source-agnostic: + +```jsonc +{ + "source": "codex", // 归属 Agent + "sessionId": "...", // 会话去重 + "eventId": "...", // 消息/回合去重 + "timestamp": "...", + "model": "gpt-5.5", + "tokens": { + "input": 470, // 非缓存输入(已扣除 cached) + "output": 274, + "cacheRead": 78208, + "cacheWrite": 0, // Codex 无此概念,置 0 + "reasoning": 0 // Claude 无此字段,置 0(含在 output 内) + }, + "cwd": "/Users/.../project", + "toolCalls": [{ "kind": "mcp", "server": "...", "name": "..." }] +} +``` + +### 2.2 Claude 适配器(现状迁移) + +现有 parser.rs 逻辑原样搬进适配器,无行为变化。 + +### 2.3 Codex 适配器(本期新增) + +**数据源**:`~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl` +(目录可被 `CODEX_HOME` 环境变量重定向,设置里允许自定义路径。) + +关键事件与字段(已在本机实测验证,codex-cli 0.142): + +| 事件 | 用途 | 关键字段 | +|------|------|---------| +| `session_meta` | 会话元信息 | `session_id`、`cwd`、`cli_version`、`model_provider`、`source.subagent`(子代理标记)、`parent_thread_id` | +| `turn_context` | 回合上下文 | `turn_id`、`model`(如 `gpt-5.5`)、`cwd`、`reasoning_effort` | +| `event_msg / token_count` | **Token 用量** | `info.last_token_usage`(本回合增量)与 `info.total_token_usage`(会话累计):`input_tokens` / `cached_input_tokens` / `output_tokens` / `reasoning_output_tokens` | +| `event_msg / token_count` | **配额** | `rate_limits.primary` / `rate_limits.secondary` 的有效窗口与 `plan_type`;历史记录可能仍含已停用的 5h 窗口 | +| `response_item / function_call` | 工具调用 | MCP / 自定义工具调用统计 | + +**解析要点**: + +1. **Token 口径归一**:Codex 的 `cached_input_tokens` 是 `input_tokens` 的子集(Claude 的 cache_read 则是独立字段)。归一化时 Codex 的 `input = input_tokens - cached_input_tokens`、`cacheRead = cached_input_tokens`,保证跨 Agent 口径一致。 +2. **按回合取增量**:用 `last_token_usage` 作为单事件用量,按时间戳归入天/小时桶;模型归属取最近一条 `turn_context.model`(同一会话可中途换模型)。 +3. **去重**:`token_count` 无消息 id,用 `(file, 行号/字节偏移)` 作为 eventId;会话被 resume/fork 时是新文件新 session_id,`parent_thread_id` 仅作参考,不合并。 +4. **子代理**:`source.subagent`(如 review)的会话照常计入 token,会话数统计时归入主线程(可后期再细化)。 +5. **兜底校验**:每个文件最终的 `total_token_usage` 可用来校验增量求和是否漏算(容错:坏行跳过)。 +6. **花费**:`model` + `model_provider` 映射 LiteLLM key(`gpt-5.5` 等 OpenAI 系模型价格表已覆盖);缓存部分按 `cache_read_input_token_cost` 计价。 + +### 2.4 未来适配器(占位,不实现) + +Gemini CLI、Cursor CLI、OpenCode、Copilot CLI……只要实现 UsageSource 即插入。UI 按「已检测到数据的 source」动态展示,代码里不出现写死的双 Agent 假设。 + +--- + +## 3. 视觉与交互方案 + +### 3.1 总体思路:一套面板,一个过滤器,一套颜色语言 + +不做多页签、不做双栏对比视图。在现有单列面板上加一个 **Agent 过滤器**,并引入 **按 Agent 着色** 的颜色语言。理由: + +- 现有面板信息架构(总量 → 趋势 → 模型 → 花费 → 工具 → 热力图)对任何 Agent 都成立; +- 菜单栏浮窗宽度有限,双栏对比放不下也没必要; +- 「All 聚合视图」才是日常主视图,单 Agent 视图是下钻。 + +### 3.2 Agent 颜色语言 + +每个 Agent 分配一个固定的品牌色调,贯穿全部图表(堆叠条、圆环、列表圆点、过滤 chip): + +| Agent | 色调 | 示例 | +|-------|------|------| +| Claude Code | 珊瑚橙 `#D97757`(Anthropic 品牌色系) | 深浅两档用于 input/output | +| Codex | 青绿 `#10A37F`(OpenAI 品牌色系) | 同上 | +| All(聚合) | 沿用现有绿色主题 | 中性 | +| 未来 Agent | 从预置调色板顺序取(蓝/紫/黄…) | — | + +交互细节:**过滤到单个 Agent 时,整个面板的强调色切换为该 Agent 的色调**(图表、进度条、菜单栏图标 tint 不变)。用户一眼就知道当前在看谁。深浅色主题各配一组通过对比度校验的色值。 + +### 3.3 面板布局(All 视图) + +``` +┌──────────────────────────────────────────┐ +│ ◉ Tokenscope [Day|Week|Month] │ ← 不变 +│ ┌─────┬────────┬───────┐ │ +│ │ All │ ⬤Claude│ ⬤Codex│ ← Agent 过滤 chips(新增,仅检测到≥2个源时显示) +│ └─────┴────────┴───────┘ │ +│ TOTAL TOKENS Est.cost │ +│ 12.40M ▲14% $46.10 │ ← 全 Agent 合计 +│ ⬤ Claude 8.1M ⬤ Codex 4.3M │ ← 原 Input/Output 行改为按 Agent 分段条 +│ │ +│ ▐▐▐▐▐▐▐ 周趋势堆叠柱状图 │ ← 堆叠维度改为 Agent(橙+青绿) +│ │ hover 提示各 Agent 当日量 +│ TOKENS BY MODEL │ +│ ⬤ Claude Sonnet 4.5 ████████ 5.8M 47% │ ← 圆点用所属 Agent 色 +│ ⬤ GPT-5.5 ███ 1.9M 16% │ +│ │ +│ COST BY MODEL ◔ $46.10 │ ← 圆环切片按 Agent 色系分深浅 +│ │ +│ [REQUESTS 2,847] [COST TREND $46.10] │ ← 不变,数据为合计 +│ │ +│ MCP CALLS 1,284 · 14 servers │ ← 两源合并,行尾小圆点标 Agent +│ SKILL CALLS (Claude 专属,见 3.5) │ +│ DAILY ACTIVITY 热力图(合计) │ +└──────────────────────────────────────────┘ +``` + +要点: +- **Input/Output 分段条 → Agent 分段条**:All 视图下最重要的问题从「输入输出比」变成「谁在烧」。Input/Output 拆分下沉到单 Agent 视图。 +- 只装了一个 Agent 的用户**看不到任何变化**:chips 不出现,面板与今天完全一致(着色沿用现有绿色)。 + +### 3.4 单 Agent 视图(点击 chip 下钻) + +- 布局与现在的 Claude 视图一致:总量条恢复为 **Input / Output** 拆分,趋势图恢复 input/output 堆叠。 +- 强调色切换为该 Agent 色调。 +- **Codex 视图专属卡片:配额(Rate Limits)** —— 数据直接来自日志,Claude 没有的差异化能力: + +``` +┌ CODEX QUOTA ────────────────────────────┐ +│ Weekly ███░░░░░░░░░░░ 23% │ +│ Plan: Pro resets in 5d │ +└─────────────────────────────────────────┘ +``` + + 取**最新一条** `token_count.rate_limits`;展示有效窗口,隐藏官方已停用的 5h 窗口及缺失字段形成的空窗口。数据超过 1 小时未更新时置灰并标注「as of HH:MM」。≥80% 时进度条转警示色,可选系统通知(后期)。 +- Codex 视图中 output 条内再细分 **reasoning tokens**(浅色纹理段),hover 显示占比。 + +### 3.5 各区块在不同过滤态下的行为 + +| 区块 | All | Claude | Codex | +|------|-----|--------|-------| +| 总量卡 | 合计 + Agent 分段条 | Input/Output 条 | Input/Output 条(含 reasoning 细分) | +| 趋势柱状图 | 按 Agent 堆叠 | 按 In/Out 堆叠 | 按 In/Out 堆叠 | +| By Model | 全部模型,圆点带 Agent 色 | 仅 claude-* | 仅该源模型 | +| Cost 圆环 | 按模型,色相随 Agent | 现状 | 同左 | +| MCP Calls | 合并 + 行尾 Agent 圆点 | 仅 Claude | 仅 Codex | +| Skill Calls | 显示(仅 Claude 数据) | 显示 | **隐藏**(Codex 无此概念) | +| Quota 卡 | 隐藏 | 隐藏 | **显示** | +| 热力图 | 合计 | 单源 | 单源 | + +原则:**没有数据的区块整块隐藏,不显示空表**;概念不存在的功能不硬造对应物。 + +### 3.6 菜单栏 + +- 数字 = **所有 Agent 今日合计**(延续「只显示 token 不显示钱」)。 +- 设置项「菜单栏统计范围」:All(默认)/ 仅某个 Agent。 +- 图标不按 Agent 换色,保持系统级中性。 + +### 3.7 空态与引导 + +- 首次启动自动探测:`~/.claude/projects` 与 `~/.codex/sessions` 谁有数据就启用谁。 +- 检测到新数据源时,面板顶部出现一次性提示条:「检测到 Codex 用量数据,已自动纳入统计 ✕」。 +- 设置页:每个数据源一行 —— 开关 + 路径(默认路径 / 自定义)+ 检测状态(`已检测到 N 个会话` / `未找到数据`)。 + +--- + +## 4. 分期计划 + +### Phase 1 — Codex 支持(本期) +1. Rust 端抽出 `UsageSource` trait,Claude 逻辑迁入适配器(行为不变,回归验证)。 +2. Codex 适配器:目录扫描 + FS 监听 + token_count/turn_context 增量解析 + LiteLLM 计价。 +3. UI:Agent 过滤 chips、Agent 颜色语言、All 视图分段条/堆叠图、单源视图。 +4. Codex Quota 卡片。 +5. 设置页数据源管理 + 空态。 + +### Phase 2 — 打磨 +- MCP 合并视图的 Agent 标注;reasoning token 细分;配额 ≥80% 通知。 +- 「按项目 (cwd)」切片:两源都有 cwd,天然可做跨 Agent 项目视图。 + +### Phase 3 — 更多 Agent +- Gemini CLI / Cursor / OpenCode / Copilot CLI 适配器,chips 超过 4 个时折叠为下拉。 + +--- + +## 5. 关键决策记录 + +1. **过滤器而非页签/双栏**:All 聚合是主场景,单源是下钻;浮窗宽度也不允许并排对比。 +2. **All 视图堆叠维度从 In/Out 改为 Agent**:多源场景下「谁在烧」比「输入输出比」更重要,后者下沉到单源视图。 +3. **Codex 用 last_token_usage 增量而非 total 快照**:才能落到小时/天粒度桶;total 仅作校验。 +4. **单源用户零变化**:只有检测到 ≥2 个数据源才出现任何多 Agent UI,避免打扰存量用户。 +5. **Quota 卡做成 source 专属能力**:适配器可声明可选能力(capabilities),UI 按能力渲染,未来别的 Agent 有独有数据同理接入。 diff --git a/docs/DESIGN-pi-adapter.md b/docs/DESIGN-pi-adapter.md new file mode 100644 index 0000000..df089dd --- /dev/null +++ b/docs/DESIGN-pi-adapter.md @@ -0,0 +1,54 @@ +# Pi Session Adapter + +## Decision + +Pi is a first-class usage source (`agent = "pi"`). It is not merged into Codex when Pi happens to use the `openai-codex` provider: the dashboard's agent dimension represents the harness, while provider/model identity controls request metadata and pricing. + +## Source discovery + +The adapter always scans the default root under `PI_CODING_AGENT_DIR` (normally `~/.pi/agent/sessions`) and additionally scans an absolute session override from: + +1. `PI_CODING_AGENT_SESSION_DIR` +2. `settings.json` → `sessionDir` + +Pi resolves relative session directories against each CLI process's cwd. A background desktop app has no single equivalent cwd, so only absolute overrides can be discovered globally. + +## Normalization + +| Pi session data | Normalized data | +|---|---| +| Session header `id` / `cwd` | Session and project identity | +| Assistant `provider` / `model` | Active provider/model metadata | +| `usage.input` | Uncached input | +| `usage.cacheWrite` | Cache creation | +| `usage.cacheRead` | Cache read | +| `usage.output` | Output (already includes `reasoning`) | +| `usage.reasoning` | Reasoning telemetry only; never added to token total again | +| `usage.cost.total` | Preferred persisted request cost | +| Assistant `stopReason` | Completed/aborted turn outcome | +| Tool result `isError` | Tool-error telemetry | +| `compaction` entry | Compaction count; optional summary usage is also billed | + +Model context windows are resolved from Pi's `models-store.json` and `models.json`. Turn duration and first-response latency are derived from persisted user/assistant timestamps. + +## Tree and copy semantics + +All physical assistant entries in one tree file represent real upstream requests, including abandoned branches, and therefore count as usage. + +`/fork` and `/clone` can copy an active path into a new file. Pi preserves each entry's stable id, so the store persists a global `entry id → original source` manifest and accepts only the first occurrence. Files are ordered by their timestamp-prefixed filename so the original normally wins. This deduplication runs before token, tool, and reliability side effects. + +## Tools and Skills + +- Direct `mcp____` calls are treated as user-extension MCP calls; Pi has no built-in MCP registry. +- A `read` tool call targeting `...//SKILL.md` records one invocation per skill per user turn. +- `/skill:` user commands are recognized when persisted directly. +- Skill whitelists include Pi's global/shared/project locations and explicit non-glob settings paths. + +## Provider quota + +Pi sessions do not persist Codex rate limits, so quota is intentionally separate from session ingestion. Tokenscope queries provider quota through the HappyUsage `hu` CLI (see [DESIGN-usage-api.md](DESIGN-usage-api.md)); the resulting Claude / Codex limits are global and never attached to the Pi scope. + +## Non-goals + +- Relative custom `sessionDir` values cannot be globally discovered outside their originating project cwd. +- Generic extension tools are not classified as MCP unless their persisted name uses the `mcp__` convention. diff --git a/docs/DESIGN-usage-api.md b/docs/DESIGN-usage-api.md new file mode 100644 index 0000000..4d00da2 --- /dev/null +++ b/docs/DESIGN-usage-api.md @@ -0,0 +1,69 @@ +# Provider Quota via HappyUsage + +## Decision + +Live provider subscription quota is collected **only** through the HappyUsage +`hu` CLI. `hu` owns credential discovery, OAuth refresh, and the provider API +calls (Claude via the Anthropic OAuth usage API, Codex via the ChatGPT +`wham/usage` endpoint). + +There is deliberately **no fallback to locally computed quota** (session-log +`rate_limits`, `~/.claude.json` caches, or previously persisted snapshots): +when `hu` cannot serve a provider, that provider shows an explicit +**unavailable** state. Local logs count tokens; they do not describe +subscription limits. + +## Architecture + +``` +refresh thread (every 5 min) + └─ quota_api::reload() + ├─ bundled hu → `hu usage claude --json` → claude ProviderLimit + ├─ bundled hu → `hu usage codex --json` → codex ProviderLimit + └─ failed fetch ⇒ that provider is None (unavailable) +parser::build_dashboard() + └─ quota_api::shared() → provider_limits (dashboard + tray) + └─ trend: live points only, carried across refreshes in memory +``` + +The in-memory cache holds only the current process's last successful readings +so trend points can accumulate; it is never persisted and never treated as a +data source when a refresh fails. + +## Bundling + +`src-tauri/bin/build-hu.sh` downloads the `hu` release asset matching the +Tauri target OS/architecture before `tauri build`; `bundle.resources` ships it +as `Resources/bin/hu`. macOS releases are separate Apple Silicon and Intel +packages rather than a universal app, so each package contains exactly one +matching `hu`. At runtime Tokenscope invokes only that bundled binary; it never +uses or installs a system copy whose version and output schema it does not +control. + +## Normalization + +| Provider | Source | Windows | +|---|---|---| +| Claude | `hu` envelope `quotas[]` | `session` → 5-hour, `weekly` → 7d | +| Codex | `hu` envelope `quotas[]` | primary → Weekly, `Spark` → Spark | + +- Matching is case-insensitive and tolerates renamed quota names across + happyusage versions (`Spark` / `Spark_weekly`, `session` / `weekly`). +- Codex's `period` label is **not** trusted (`limit_window_seconds` can be + absent, which makes `hu` fall back to a misleading `5h`); the 5h Codex + window is retired anyway, so both Codex windows are classified as weekly. +- `used_pct` is retained in storage; presentation converts to rounded + percentage left (`29% left (resets 05:01 on 9 Aug)`). + +## Menu bar and dashboard + +- **Provider Limits** submenu: one read-only row per provider + (`Claude — 5h 0% · W 79%`), refreshed with every dashboard build. +- **Menu Bar Display**: Off / Compact / Detailed. + - Compact: `52.8M · Cl79% · Cx29%` — tightest window per provider. + - Detailed: `52.8M · Cl 5h0/W79 · Cx W29/S31`. +- Dashboard: a global two-column card (Claude | Codex), each window showing + remaining capacity, the reset date, and an optional burn-rate projection. + A provider with no `hu` snapshot shows **Unavailable**; if both are + unavailable the whole section says so. Provider limits never attach to a + token-usage scope (Pi / Codex CLI remain only usage sources). diff --git a/docs/REVIEW.md b/docs/REVIEW.md index f8755c9..0ef8568 100644 --- a/docs/REVIEW.md +++ b/docs/REVIEW.md @@ -14,7 +14,7 @@ 2. 一切都经 `build_dashboard` 每 30s + 每次开 popover 触发,造成 O(全部历史) 的重复全量读写/解析(含主线程阻塞 IO 与锁内网络抓取); 3. 发布流水线脆弱。 -**跨平台/Windows 风险尤其突出**:当前 `feat/windows-support` 分支的 Critical 构建中断同时阻断 macOS `.dmg` 与 Windows NSIS 安装器;autostart 在 Windows 上写 `HKCU\...\Run` 且无法关闭;非原子写入与 AV/文件锁在 Windows 上更易触发缓存损坏;彩带依赖 WebView2 冷启动时序。 +**跨平台/Windows 风险尤其突出**:当前 `feat/windows-support` 分支的 Critical 构建中断同时阻断 macOS `.dmg` 与 Windows NSIS 安装器;autostart 在 Windows 上写 `HKCU\...\Run` 且无法关闭;非原子写入与 AV/文件锁在 Windows 上更易触发缓存损坏。 --- @@ -107,9 +107,7 @@ 9. **`fetch_cached` 持久化任何以 `{` 开头的 200 响应,可毒化缓存 24h**(`pricing.rs:70-79`)——结构校验通过后再覆盖缓存。 10. **Shell 插件与 `shell:allow-open` 能力被启用但从未使用**(`capabilities/default.json:16`、`lib.rs:550`、`Cargo.toml:19`)——纯负债,扩大攻击面,删除。 11. **`workflow_dispatch` 从非 tag ref 触发会产生畸形 release 并使 Homebrew 步骤失败**(`release.yml`)——加 `if: startsWith(github.ref, 'refs/tags/v')` 守卫。 -12. **Milestone 快照在状态锁外持久化,可能写入回退的旧 floor**(`lib.rs:134-155`)——`save_milestones` 移入持锁区并令 floor 单调。 -13. **请求趋势 sparkline 计入了请求指标排除的 slash-command 事件**(`parser.rs:288/350/420`)——给趋势自增加 `if !e.model.is_empty()` 守卫。 -14. **彩带尾部被截断:动画寿命长于固定 4200ms 隐藏定时器**(`confetti.html:135-141`、`lib.rs:255-256`)——经 IPC 发 `confetti-done` 再隐藏,或延迟提到 ~5500ms。 +12. **请求趋势 sparkline 计入了请求指标排除的 slash-command 事件**(`parser.rs:288/350/420`)——给趋势自增加 `if !e.model.is_empty()` 守卫。 --- diff --git a/docs/screenshot.png b/docs/screenshot.png index 25f9da5..7f01e89 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/package.json b/package.json index d49739a..8920a2b 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,20 @@ { "name": "tokenscope", - "version": "1.0.3", + "version": "1.5.7", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "version:check": "node scripts/version.mjs", + "version:set": "node scripts/version.mjs" }, "dependencies": { "@tauri-apps/api": "^2.1.1", + "@tauri-apps/plugin-process": "^2.3.1", + "@tauri-apps/plugin-updater": "^2.10.1", "modern-screenshot": "^4.7.0", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -24,6 +28,8 @@ "vite": "^5.4.11" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": [ + "esbuild" + ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76b24de..641307f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@tauri-apps/api': specifier: ^2.1.1 version: 2.11.0 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 modern-screenshot: specifier: ^4.7.0 version: 4.7.0 @@ -316,66 +322,79 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -433,30 +452,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.11.2': resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.11.2': resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.11.2': resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.11.2': resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.11.2': resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==} @@ -481,6 +505,12 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1006,6 +1036,14 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.0 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 diff --git a/public/confetti.html b/public/confetti.html deleted file mode 100644 index 3f7cf4a..0000000 --- a/public/confetti.html +++ /dev/null @@ -1,147 +0,0 @@ - - - - - - Tokenscope Celebration - - - - - - - diff --git a/scripts/version.mjs b/scripts/version.mjs new file mode 100644 index 0000000..0c303cc --- /dev/null +++ b/scripts/version.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import fs from "node:fs"; + +const requested = process.argv[2]; +const semver = /^\d+\.\d+\.\d+$/; + +function readJson(path) { + return JSON.parse(fs.readFileSync(path, "utf8")); +} + +function replaceVersion(path, pattern, version) { + const source = fs.readFileSync(path, "utf8"); + if (!pattern.test(source)) throw new Error(`version field not found in ${path}`); + fs.writeFileSync(path, source.replace(pattern, (_match, before, after) => `${before}${version}${after}`)); +} + +if (requested) { + const version = requested.replace(/^v/, ""); + if (!semver.test(version)) throw new Error("version must use X.Y.Z"); + + replaceVersion("package.json", /^(\s*"version":\s*")[^"]+(".*)$/m, version); + replaceVersion("src-tauri/tauri.conf.json", /^(\s*"version":\s*")[^"]+(".*)$/m, version); + replaceVersion("src-tauri/Cargo.toml", /^(version = ")[^"]+(".*)$/m, version); + replaceVersion( + "src-tauri/Cargo.lock", + /(\[\[package\]\]\nname = "tokenscope"\nversion = ")[^"]+(".*)/, + version, + ); +} + +const versions = { + package: readJson("package.json").version, + tauri: readJson("src-tauri/tauri.conf.json").version, + cargo: fs.readFileSync("src-tauri/Cargo.toml", "utf8").match(/^version = "([^"]+)"/m)?.[1], + lock: fs.readFileSync("src-tauri/Cargo.lock", "utf8") + .match(/\[\[package\]\]\nname = "tokenscope"\nversion = "([^"]+)"/)?.[1], +}; +const unique = new Set(Object.values(versions)); +if (unique.size !== 1 || unique.has(undefined)) { + throw new Error(`version mismatch: ${JSON.stringify(versions)}`); +} +if (!semver.test(versions.package)) throw new Error("version must use X.Y.Z"); + +process.stdout.write(versions.package); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 62100d9..790b621 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -732,6 +741,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1339,6 +1359,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1460,6 +1490,24 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "global-hotkey" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" +dependencies = [ + "crossbeam-channel", + "keyboard-types", + "objc2", + "objc2-app-kit", + "once_cell", + "serde", + "thiserror 2.0.18", + "windows-sys 0.59.0", + "x11rb", + "xkeysym", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1637,6 +1685,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1912,6 +1975,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2137,6 +2230,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2420,6 +2519,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.12.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2435,6 +2535,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.12.1", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2507,6 +2619,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2523,6 +2641,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -2935,15 +3067,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3012,6 +3149,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3021,6 +3170,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3047,6 +3223,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3104,6 +3289,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.12.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3324,6 +3532,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3525,7 +3749,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3558,6 +3782,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3581,7 +3816,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -3723,6 +3958,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-global-shortcut" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" +dependencies = [ + "global-hotkey", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-positioner" version = "2.3.2" @@ -3738,6 +3988,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" @@ -3753,6 +4013,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs 6.0.0", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -3763,7 +4056,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -3786,7 +4079,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -3974,7 +4267,7 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokenscope" -version = "1.0.3" +version = "1.5.7" dependencies = [ "base64 0.22.1", "chrono", @@ -3986,8 +4279,11 @@ dependencies = [ "tauri-build", "tauri-nspanel", "tauri-plugin-autostart", + "tauri-plugin-global-shortcut", "tauri-plugin-positioner", + "tauri-plugin-process", "tauri-plugin-single-instance", + "tauri-plugin-updater", "ureq", "walkdir", ] @@ -4006,6 +4302,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4641,6 +4947,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5356,7 +5671,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5403,6 +5718,39 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + [[package]] name = "yoke" version = "0.8.3" @@ -5547,6 +5895,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 191b4a3..a69717b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tokenscope" -version = "1.0.3" -description = "Claude CLI token usage dashboard" +version = "1.5.7" +description = "Menu-bar dashboard for local AI coding agents (Pi, Claude Code, Codex) token usage" authors = ["you"] edition = "2021" @@ -9,6 +9,12 @@ edition = "2021" name = "tokenscope_lib" crate-type = ["staticlib", "cdylib", "rlib"] +[lints.rust] +# The old `objc` crate (pulled in via tauri-nspanel) emits cfg(feature = +# "cargo-clippy") from its macros, which trips this lint at every msg_send! +# call site in our crate. Nothing we can fix locally — silence it. +unexpected_cfgs = "allow" + [build-dependencies] tauri-build = { version = "2", features = [] } @@ -27,6 +33,9 @@ walkdir = "2" # watch ~/.claude/projects so the dashboard refreshes within ~1s of a log write notify = "6" tauri-plugin-single-instance = "2" +tauri-plugin-updater = "2" +tauri-plugin-process = "2" +tauri-plugin-global-shortcut = "2" [target.'cfg(target_os = "macos")'.dependencies] tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" } diff --git a/src-tauri/bin/build-hu.sh b/src-tauri/bin/build-hu.sh new file mode 100755 index 0000000..5d6d15a --- /dev/null +++ b/src-tauri/bin/build-hu.sh @@ -0,0 +1,51 @@ +#!/bin/sh +# Bundle the HappyUsage binary matching the Tauri build target. +# Override the version with HAPPYUSAGE_VERSION=vX.Y.Z. +set -e +cd "$(dirname "$0")" + +REPO="SunChJ/happyusage" +VERSION="${HAPPYUSAGE_VERSION:-}" + +if [ -z "$VERSION" ]; then + VERSION="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/${REPO}/releases/latest" \ + | sed -E 's#.*/tag/##' | sed 's#/$##')" +fi +[ -n "$VERSION" ] || { echo "failed to resolve latest version" >&2; exit 1; } + +case "${TAURI_ENV_PLATFORM:-$(uname -s)}" in + darwin|Darwin) OS_GO="darwin" ;; + linux|Linux) OS_GO="linux" ;; + windows|Windows|MINGW*|MSYS*|CYGWIN*) OS_GO="windows" ;; + *) echo "unsupported OS: ${TAURI_ENV_PLATFORM:-$(uname -s)}" >&2; exit 1 ;; +esac + +# beforeBuildCommand receives TAURI_ENV_ARCH from the requested Tauri target. +# Fall back to the host architecture for direct/local script invocations. +case "${TAURI_ENV_ARCH:-$(uname -m)}" in + x86_64|x86|amd64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "unsupported architecture: ${TAURI_ENV_ARCH:-$(uname -m)}" >&2; exit 1 ;; +esac + +if [ "$OS_GO" = "windows" ]; then EXT="zip"; else EXT="tar.gz"; fi +ASSET="hu-${OS_GO}-${ARCH}.${EXT}" +URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "==> fetching ${URL}" +if [ "$OS_GO" = "windows" ]; then + curl -fsSL -o "$TMP/hu.zip" "$URL" + unzip -o -q "$TMP/hu.zip" -d "$TMP/extract" +else + mkdir -p "$TMP/extract" + curl -fsSL -o "$TMP/hu.tgz" "$URL" + tar -xzf "$TMP/hu.tgz" -C "$TMP/extract" +fi + +BIN_PATH="$(find "$TMP/extract" -type f \( -name 'hu' -o -name 'hu.exe' \) | head -1)" +[ -n "$BIN_PATH" ] || { echo "hu binary not found in ${ASSET}" >&2; exit 1; } +cp "$BIN_PATH" hu +chmod +x hu +echo "==> bundled hu ${VERSION} (${OS_GO}/${ARCH})" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index bd6a919..f563fd1 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -15,6 +15,8 @@ "positioner:default", "autostart:allow-enable", "autostart:allow-disable", - "autostart:allow-is-enabled" + "autostart:allow-is-enabled", + "updater:default", + "process:allow-restart" ] } diff --git a/src-tauri/src/codex_adapter.rs b/src-tauri/src/codex_adapter.rs new file mode 100644 index 0000000..6188517 --- /dev/null +++ b/src-tauri/src/codex_adapter.rs @@ -0,0 +1,272 @@ +//! Codex-specific token accounting. +//! +//! Codex `token_count` events expose two different views of usage: +//! `last_token_usage` is the exact latest upstream response, while +//! `total_token_usage` is the accumulated session snapshot. The same positive +//! `last_token_usage` can be emitted again when only rate limits change, so the +//! accumulated snapshot is the authority for whether a new response occurred. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default)] +pub(crate) struct TokenUsage { + pub(crate) input_tokens: i64, + pub(crate) cached_input_tokens: i64, + pub(crate) cache_write_input_tokens: i64, + pub(crate) output_tokens: i64, + pub(crate) reasoning_output_tokens: i64, + pub(crate) total_tokens: i64, +} + +impl TokenUsage { + fn from_value(value: &Value) -> Self { + let number = |key: &str| { + value + .get(key) + .and_then(Value::as_i64) + .unwrap_or_default() + .max(0) + }; + Self { + input_tokens: number("input_tokens"), + cached_input_tokens: number("cached_input_tokens"), + cache_write_input_tokens: number("cache_write_input_tokens"), + output_tokens: number("output_tokens"), + reasoning_output_tokens: number("reasoning_output_tokens"), + total_tokens: number("total_tokens"), + } + } + + fn has_response_usage(&self) -> bool { + self.input_tokens > 0 || self.output_tokens > 0 + } + + fn event_id(&self, turn_id: &str) -> String { + if turn_id.is_empty() { + return String::new(); + } + format!( + "codex:{turn_id}@{}:{}:{}:{}:{}:{}", + self.input_tokens, + self.cached_input_tokens, + self.cache_write_input_tokens, + self.output_tokens, + self.reasoning_output_tokens, + self.total_tokens, + ) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct State { + last_total: Option, +} + +impl State { + /// Advance the cumulative cursor without producing usage. Fork heads need + /// this while their replayed lines are otherwise ignored. + pub(crate) fn remember_total(&mut self, info: Option<&Value>) { + if let Some(total) = info + .and_then(|value| value.get("total_token_usage")) + .map(TokenUsage::from_value) + { + self.last_total = Some(total); + } + } + + pub(crate) fn observe(&mut self, info: Option<&Value>, turn_id: &str) -> TokenCountOutcome { + let Some(info) = info else { + return TokenCountOutcome::NoUsage; + }; + let last = info + .get("last_token_usage") + .map(TokenUsage::from_value) + .unwrap_or_default(); + let total = info.get("total_token_usage").map(TokenUsage::from_value); + let previous = total + .as_ref() + .and_then(|current| self.last_total.replace(current.clone())); + + // Local context estimates populate only total_tokens. They are useful + // for compaction, but are not billable upstream usage. + if !last.has_response_usage() { + return TokenCountOutcome::NoUsage; + } + + // Rate-limit-only TokenCount events retain the previous positive + // last_token_usage. An unchanged accumulated snapshot proves that no + // new upstream response completed. + if previous + .as_ref() + .zip(total.as_ref()) + .is_some_and(|(before, current)| before == current) + { + return TokenCountOutcome::RepeatedSnapshot; + } + + let event_id = total + .as_ref() + .map(|snapshot| snapshot.event_id(turn_id)) + // Old/incomplete logs without a total snapshot cannot be safely + // deduplicated across files. Keep their usage rather than risk + // silently dropping two equally-sized genuine responses. + .unwrap_or_default(); + + let raw_input = last.input_tokens.max(0); + let cache_read = last.cached_input_tokens.clamp(0, raw_input); + let cache_write = last + .cache_write_input_tokens + .clamp(0, raw_input - cache_read); + let input = raw_input - cache_read - cache_write; + let output = last.output_tokens.max(0); + let reasoning = last.reasoning_output_tokens.clamp(0, output); + + TokenCountOutcome::Usage(Usage { + event_id, + input_tokens: input, + cache_write_input_tokens: cache_write, + cache_read_input_tokens: cache_read, + output_tokens: output, + reasoning_output_tokens: reasoning, + raw_input_tokens: raw_input, + }) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Usage { + pub(crate) event_id: String, + pub(crate) input_tokens: i64, + pub(crate) cache_write_input_tokens: i64, + pub(crate) cache_read_input_tokens: i64, + pub(crate) output_tokens: i64, + pub(crate) reasoning_output_tokens: i64, + pub(crate) raw_input_tokens: i64, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TokenCountOutcome { + NoUsage, + RepeatedSnapshot, + Usage(Usage), +} + +/// Stable across Codex fork/replay restamping because the outer JSONL +/// timestamp is intentionally not part of the fingerprint. +pub(crate) fn token_count_fingerprint( + turn_id: &str, + info: Option<&Value>, + rate_limits: Option<&Value>, +) -> String { + let encoded = serde_json::to_vec(&(turn_id, info, rate_limits)).unwrap_or_default(); + let mut hash = 0xcbf29ce484222325u64; + for byte in encoded { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("codex-token-count:{hash:016x}") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn info(last_input: i64, last_cached: i64, last_write: i64, total_input: i64) -> Value { + json!({ + "last_token_usage": { + "input_tokens": last_input, + "cached_input_tokens": last_cached, + "cache_write_input_tokens": last_write, + "output_tokens": 10, + "reasoning_output_tokens": 4, + "total_tokens": last_input + 10 + }, + "total_token_usage": { + "input_tokens": total_input, + "cached_input_tokens": last_cached, + "cache_write_input_tokens": last_write, + "output_tokens": 10, + "reasoning_output_tokens": 4, + "total_tokens": total_input + 10 + } + }) + } + + #[test] + fn splits_cache_read_and_write_out_of_codex_input() { + let mut state = State::default(); + let TokenCountOutcome::Usage(usage) = + state.observe(Some(&info(100, 40, 50, 100)), "turn-a") + else { + panic!("expected usage"); + }; + + assert_eq!(usage.input_tokens, 10); + assert_eq!(usage.cache_read_input_tokens, 40); + assert_eq!(usage.cache_write_input_tokens, 50); + assert_eq!(usage.output_tokens, 10); + assert_eq!(usage.reasoning_output_tokens, 4); + assert_eq!(usage.raw_input_tokens, 100); + assert_eq!( + usage.input_tokens + + usage.cache_read_input_tokens + + usage.cache_write_input_tokens + + usage.output_tokens, + 110 + ); + } + + #[test] + fn rejects_positive_last_usage_when_total_snapshot_is_unchanged() { + let mut state = State::default(); + let first = info(100, 40, 0, 100); + assert!(matches!( + state.observe(Some(&first), "turn-a"), + TokenCountOutcome::Usage(_) + )); + assert_eq!( + state.observe(Some(&first), "turn-b"), + TokenCountOutcome::RepeatedSnapshot + ); + } + + #[test] + fn cumulative_snapshot_makes_ids_stable_but_not_position_based() { + let first = info(100, 40, 0, 100); + let second = info(100, 40, 0, 200); + + let mut original = State::default(); + let TokenCountOutcome::Usage(first_original) = original.observe(Some(&first), "turn-a") + else { + panic!("expected usage"); + }; + let TokenCountOutcome::Usage(second_original) = original.observe(Some(&second), "turn-a") + else { + panic!("expected usage"); + }; + + let mut replay = State::default(); + let TokenCountOutcome::Usage(first_replay) = replay.observe(Some(&first), "turn-a") else { + panic!("expected replay candidate"); + }; + + assert_eq!(first_original.event_id, first_replay.event_id); + assert_ne!(first_original.event_id, second_original.event_id); + } + + #[test] + fn remembers_replayed_total_as_the_incremental_baseline() { + let first = info(100, 40, 0, 100); + let mut state = State::default(); + state.remember_total(Some(&first)); + + assert_eq!( + state.observe(Some(&first), "child-turn"), + TokenCountOutcome::RepeatedSnapshot + ); + } +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index d7baa78..dbc7993 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -5,8 +5,11 @@ use std::fs; use std::path::{Path, PathBuf}; pub struct UserConfig { - pub mcp_servers: HashSet, - pub skills: HashSet, + pub mcp_servers: HashSet, // claude, from ~/.claude.json + pub codex_mcp_servers: HashSet, // codex, from ~/.codex/config.toml + pub claude_skills: HashSet, + pub codex_skills: HashSet, + pub pi_skills: HashSet, } fn home() -> Option { @@ -42,12 +45,16 @@ fn mcps_from(json: Option<&serde_json::Value>) -> HashSet { } /// Add each subdirectory name of `dir` to the set (skills are folders). -fn scan_skill_dir(dir: &Path, set: &mut HashSet) { +fn scan_skill_dir(dir: &Path, set: &mut HashSet, include_hidden: bool) { if let Ok(entries) = fs::read_dir(dir) { for e in entries.flatten() { if e.path().is_dir() { if let Some(name) = e.file_name().to_str() { - set.insert(name.to_string()); + if (include_hidden || !name.starts_with('.')) + && e.path().join("SKILL.md").is_file() + { + set.insert(name.to_string()); + } } } } @@ -61,29 +68,330 @@ fn scan_skill_dir(dir: &Path, set: &mut HashSet) { fn load_user_skills() -> HashSet { let mut set = HashSet::new(); if let Some(h) = home() { - scan_skill_dir(&h.join(".claude").join("skills"), &mut set); + scan_skill_dir(&h.join(".claude").join("skills"), &mut set, true); + } + set +} + +fn codex_home() -> Option { + std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".codex"))) +} + +fn pi_agent_dir() -> Option { + std::env::var_os("PI_CODING_AGENT_DIR") + .map(PathBuf::from) + .or_else(|| home().map(|h| h.join(".pi").join("agent"))) +} + +/// User Codex skills live in $CODEX_HOME/skills and ~/.agents/skills. Also +/// include project `.agents/skills` directories from session working dirs. +/// Hidden directories such as $CODEX_HOME/skills/.system are built-ins. +fn load_codex_skills(project_dirs: &[PathBuf]) -> HashSet { + let mut set = HashSet::new(); + let mut dirs = HashSet::new(); + if let Some(d) = codex_home() { + dirs.insert(d.join("skills")); + } + if let Some(h) = home() { + dirs.insert(h.join(".agents").join("skills")); + } + for project in project_dirs { + for dir in project.ancestors() { + dirs.insert(dir.join(".agents").join("skills")); + } + } + for dir in dirs { + scan_skill_dir(&dir, &mut set, false); + } + set +} + +fn pi_frontmatter_name(path: &Path) -> Option { + let text = fs::read_to_string(path).ok()?; + let mut lines = text.lines(); + if lines.next()?.trim() != "---" { + return None; + } + for line in lines { + let line = line.trim(); + if line == "---" { + break; + } + if let Some(name) = line.strip_prefix("name:") { + let name = name.trim().trim_matches(['\'', '"']); + if !name.is_empty() { + return Some(name.to_string()); + } + } + } + None +} + +fn scan_pi_skill_path(path: &Path, set: &mut HashSet) { + if path.is_file() { + if path.extension().and_then(|ext| ext.to_str()) == Some("md") { + if let Some(name) = pi_frontmatter_name(path).or_else(|| { + path.file_stem() + .and_then(|name| name.to_str()) + .map(str::to_owned) + }) { + set.insert(name); + } + } + return; + } + for entry in walkdir::WalkDir::new(path) + .follow_links(false) + .into_iter() + .filter_map(Result::ok) + { + if entry.file_type().is_file() && entry.file_name() == "SKILL.md" { + if let Some(parent_name) = entry + .path() + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + { + set.insert(parent_name.to_string()); + } + if let Some(name) = pi_frontmatter_name(entry.path()) { + set.insert(name); + } + } + } +} + +fn expand_pi_path(path: &str, base: &Path) -> Option { + if path == "~" { + return home(); + } + if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { + return Some(home()?.join(rest)); + } + let path = PathBuf::from(path); + Some(if path.is_absolute() { path } else { base.join(path) }) +} + +fn read_json(path: &Path) -> Option { + serde_json::from_str(&fs::read_to_string(path).ok()?).ok() +} + +fn add_pi_settings_skills(settings: &Path, base: &Path, set: &mut HashSet) { + let Some(json) = read_json(settings) else { + return; + }; + let Some(skills) = json.get("skills").and_then(|value| value.as_array()) else { + return; + }; + for skill in skills.iter().filter_map(|value| value.as_str()) { + if skill.starts_with(['!', '-', '+']) || skill.contains('*') { + continue; + } + if let Some(path) = expand_pi_path(skill, base) { + scan_pi_skill_path(&path, set); + } + } +} + +fn npm_package_name(source: &str) -> &str { + let source = source.strip_prefix("npm:").unwrap_or(source); + if let Some(unscoped) = source.strip_prefix('@') { + unscoped + .find('@') + .map(|index| &source[..index + 1]) + .unwrap_or(source) + } else { + source.split('@').next().unwrap_or(source) + } +} + +fn pi_package_path(source: &str, base: &Path) -> Option { + let source = source.split('#').next()?.trim(); + if let Some(repo) = source.strip_prefix("git:") { + return Some(base.join("git").join(repo)); + } + if let Some(repo) = source.strip_prefix("github:") { + return Some(base.join("git").join("github.com").join(repo)); + } + if source.contains("://") { + return None; + } + let name = npm_package_name(source); + (!name.is_empty()).then(|| base.join("npm").join("node_modules").join(name)) +} + +fn add_pi_package_skills(settings: &Path, base: &Path, set: &mut HashSet) { + let Some(json) = read_json(settings) else { + return; + }; + let Some(packages) = json.get("packages").and_then(|value| value.as_array()) else { + return; + }; + for package in packages { + let source = if let Some(source) = package.as_str() { + source + } else { + let skills_enabled = package + .get("skills") + .and_then(|value| value.as_array()) + .is_none_or(|skills| !skills.is_empty()); + if !skills_enabled { + continue; + } + let Some(source) = package.get("source").and_then(|value| value.as_str()) else { + continue; + }; + source + }; + if let Some(path) = pi_package_path(source, base) { + scan_pi_skill_path(&path, set); + } + } +} + +/// Pi discovers global, shared, project, package, and explicit settings skills. +fn load_pi_skills(project_dirs: &[PathBuf]) -> HashSet { + let mut set = HashSet::new(); + if let Some(agent_dir) = pi_agent_dir() { + scan_pi_skill_path(&agent_dir.join("skills"), &mut set); + let settings = agent_dir.join("settings.json"); + add_pi_settings_skills(&settings, &agent_dir, &mut set); + add_pi_package_skills(&settings, &agent_dir, &mut set); + } + if let Some(home) = home() { + scan_pi_skill_path(&home.join(".agents").join("skills"), &mut set); + } + for project in project_dirs { + for dir in project.ancestors() { + scan_pi_skill_path(&dir.join(".pi").join("skills"), &mut set); + scan_pi_skill_path(&dir.join(".agents").join("skills"), &mut set); + let settings_dir = dir.join(".pi"); + let settings = settings_dir.join("settings.json"); + add_pi_settings_skills(&settings, &settings_dir, &mut set); + add_pi_package_skills(&settings, &settings_dir, &mut set); + if dir.join(".git").exists() { + break; + } + } + } + set +} + +/// Codex normalizes MCP server names to snake_case in tool names (a config +/// entry `chrome-devtools` calls tools named `mcp__chrome_devtools__…`), so +/// both the whitelist and lookups go through this. +fn norm_mcp(name: &str) -> String { + name.replace('-', "_") +} + +/// Collect every `[mcp_servers.]` table header. A structural TOML parse +/// isn't needed for section names, so keep this dependency-free. +fn add_codex_mcps(text: &str, set: &mut HashSet) { + for line in text.lines() { + let line = line.trim(); + // Match [mcp_servers.] and [mcp_servers.""], but not deeper + // sub-tables like [mcp_servers..env]. + let Some(rest) = line.strip_prefix("[mcp_servers.") else { continue }; + let Some(inner) = rest.strip_suffix(']') else { continue }; + let name = inner.trim_matches('"'); + if !name.is_empty() && !name.contains('.') { + set.insert(norm_mcp(name)); + } + } +} + +/// MCP servers from the global Codex config plus trusted project configs seen +/// in session working directories. +fn load_codex_mcps(project_dirs: &[PathBuf]) -> HashSet { + let mut set = HashSet::new(); + let mut paths = HashSet::new(); + if let Some(d) = codex_home() { + paths.insert(d.join("config.toml")); + } + for project in project_dirs { + for dir in project.ancestors() { + paths.insert(dir.join(".codex").join("config.toml")); + } + } + for path in paths { + if let Ok(text) = fs::read_to_string(path) { + add_codex_mcps(&text, &mut set); + } } set } impl UserConfig { - pub fn load() -> Self { + pub fn load(project_dirs: &[PathBuf]) -> Self { // Parse ~/.claude.json a single time and derive the MCP whitelist from it. let json = read_user_config(); UserConfig { mcp_servers: mcps_from(json.as_ref()), - skills: load_user_skills(), + codex_mcp_servers: load_codex_mcps(project_dirs), + claude_skills: load_user_skills(), + codex_skills: load_codex_skills(project_dirs), + pi_skills: load_pi_skills(project_dirs), } } /// A tool name like "mcp____" → is server user-installed? - pub fn is_user_mcp(&self, server: &str) -> bool { - self.mcp_servers.contains(server) + /// Checked against the owning agent's own config. + pub fn is_user_mcp(&self, agent: &str, server: &str) -> bool { + match agent { + crate::store::AGENT_CODEX => self.codex_mcp_servers.contains(&norm_mcp(server)), + // Pi has no built-in MCP registry. A persisted mcp__ tool is supplied + // by a user extension, so the invocation itself is authoritative. + crate::store::AGENT_PI => !server.is_empty(), + _ => self.mcp_servers.contains(server), + } } /// A skill id (may be "plugin:skill") → strip plugin prefix, check dir. - pub fn is_user_skill(&self, skill: &str) -> bool { + pub fn is_user_skill(&self, agent: &str, skill: &str) -> bool { let key = skill.rsplit(':').next().unwrap_or(skill); - self.skills.contains(key) + match agent { + crate::store::AGENT_CODEX => self.codex_skills.contains(key), + crate::store::AGENT_PI => self.pi_skills.contains(key), + _ => self.claude_skills.contains(key), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolves_pi_package_install_paths() { + let base = Path::new("/agent"); + assert_eq!(npm_package_name("npm:toolkit@1.2.3"), "toolkit"); + assert_eq!(npm_package_name("npm:@scope/toolkit@1.2.3"), "@scope/toolkit"); + assert_eq!( + pi_package_path("npm:@scope/toolkit@1.2.3", base), + Some(base.join("npm/node_modules/@scope/toolkit")) + ); + assert_eq!( + pi_package_path("git:github.com/org/toolkit#main", base), + Some(base.join("git/github.com/org/toolkit")) + ); + } + + #[test] + fn parses_and_normalizes_codex_mcp_server_names() { + let mut servers = HashSet::new(); + add_codex_mcps( + r#" + [mcp_servers.chrome-devtools] + [mcp_servers."node_repl"] + [mcp_servers.chrome-devtools.env] + "#, + &mut servers, + ); + assert_eq!( + servers, + HashSet::from(["chrome_devtools".into(), "node_repl".into()]) + ); } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 60e1add..f8abff4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,22 +1,25 @@ +mod codex_adapter; mod config; mod model; mod parser; mod pricing; +mod quota_api; mod store; -use model::Dashboard; -use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; +use model::{Dashboard, RangeDashboard}; +use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::Arc; +use std::time::Duration; use std::time::{SystemTime, UNIX_EPOCH}; +#[cfg(not(target_os = "macos"))] +use tauri::WindowEvent; use tauri::{ - menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, Emitter, Manager, }; -#[cfg(not(target_os = "macos"))] -use tauri::WindowEvent; -use std::time::Duration; use tauri_plugin_autostart::ManagerExt; +use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; // Positioner is only used for the non-macOS fallback; macOS positions the // NSPanel manually (see position_panel). #[cfg(not(target_os = "macos"))] @@ -34,68 +37,490 @@ fn now_ms() -> i64 { .unwrap_or(0) } -/// Rebuild the dashboard (incremental), update the tray's token count, and push -/// the fresh data to the UI so an open popover updates live. -fn refresh(app: &tauri::AppHandle) { - let dash = parser::build_dashboard(); - if let Some(tray) = app.tray_by_id("main") { - let label = fmt_tokens_m(dash.today_tokens); - // macOS shows the label next to the menu-bar icon (set_title). Windows' - // taskbar tray has no equivalent — set_title is a no-op there — so we - // surface the same number through the hover tooltip instead, the only - // text channel Shell_NotifyIcon exposes for a tray icon. - let _ = tray.set_title(Some(label.clone())); - let _ = tray.set_tooltip(Some(format!("Tokenscope · today {}", label))); +const DASHBOARD_SHORTCUT: &str = "CommandOrControl+Alt+T"; + +/// What the menu-bar title shows next to today's token count. Compact shows +/// the tightest window per provider; Detailed lists every window. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "snake_case")] +enum MenuBarQuotaDisplay { + #[default] + Off, + Compact, + Detailed, +} + +impl<'de> serde::Deserialize<'de> for MenuBarQuotaDisplay { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "off" => Self::Off, + // Legacy weekly-remaining values map to the compact summary. + "codex" | "codex_and_spark" => Self::Compact, + "compact" => Self::Compact, + "detailed" => Self::Detailed, + _ => Self::Off, + }) } - check_milestones(app, &dash); - let _ = app.emit("dashboard-updated", &dash); } -/// Persisted 100M-token milestone snapshot. Stored in the app *data* dir so it -/// survives app restarts, reboots, and updates (which only replace the .app -/// bundle, never the data dir). The per-period ids let us tell a real crossing -/// from a period reset. -#[derive(Clone, serde::Serialize, serde::Deserialize)] -struct MilestoneState { - week_id: String, - week_floor: i64, - month_id: String, - month_floor: i64, +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum AppLanguage { + #[default] + En, + Zh, } -/// 100M-token celebration tracking. `state` is the last persisted snapshot -/// (`None` only before the very first observation ever, so the first run -/// baselines without celebrating pre-existing usage). `active` guards against -/// overlapping celebrations. -struct Celebration { - state: std::sync::Mutex>, - active: AtomicBool, +impl AppLanguage { + fn parse(value: &str) -> Option { + match value { + "en" => Some(Self::En), + "zh" => Some(Self::Zh), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::En => "en", + Self::Zh => "zh", + } + } } -/// `~/Library/Application Support/tokenscope/milestones.json` (platform -/// equivalent elsewhere). Deliberately the data dir, not the Caches dir the -/// event store uses — Caches can be purged by the OS, milestones must not be. -fn milestones_path() -> Option { - let dir = dirs::data_dir()?.join("tokenscope"); - let _ = std::fs::create_dir_all(&dir); - Some(dir.join("milestones.json")) +#[derive(Clone, serde::Deserialize, serde::Serialize)] +#[serde(default)] +struct TrayPreferences { + // Persisted key stays `weekly_quota_display` for forward compatibility; + // the semantics are now Menu Bar Display (off / compact / detailed). + weekly_quota_display: MenuBarQuotaDisplay, + dashboard_shortcut: bool, + dashboard_shortcut_key: String, + language: AppLanguage, } -fn load_milestones() -> Option { - let t = std::fs::read_to_string(milestones_path()?).ok()?; - serde_json::from_str(&t).ok() +impl Default for TrayPreferences { + fn default() -> Self { + Self { + weekly_quota_display: MenuBarQuotaDisplay::Off, + dashboard_shortcut: false, + dashboard_shortcut_key: DASHBOARD_SHORTCUT.to_string(), + language: AppLanguage::En, + } + } } -fn save_milestones(m: &MilestoneState) { - if let Some(p) = milestones_path() { - if let Ok(t) = serde_json::to_string(m) { - let _ = std::fs::write(p, t); +struct TrayPreferencesState(std::sync::Mutex); +struct TrayMenuState { + open: MenuItem, + refresh: MenuItem, + check_updates: MenuItem, + provider_limits: Submenu, + provider_claude: MenuItem, + provider_codex: MenuItem, + menu_bar_display: Submenu, + display_off: CheckMenuItem, + display_compact: CheckMenuItem, + display_detailed: CheckMenuItem, + dashboard_shortcut: CheckMenuItem, + change_dashboard_shortcut: MenuItem, + autostart: CheckMenuItem, + language: Submenu, + language_en: CheckMenuItem, + language_zh: CheckMenuItem, + quit: MenuItem, +} + +struct TrayCopy { + open: &'static str, + refresh: &'static str, + check_updates: &'static str, + provider_limits: &'static str, + provider_unavailable: &'static str, + menu_bar_display: &'static str, + off: &'static str, + compact: &'static str, + detailed: &'static str, + dashboard_shortcut: &'static str, + change_dashboard_shortcut: &'static str, + autostart: &'static str, + language: &'static str, + english: &'static str, + chinese: &'static str, + quit: &'static str, + today: &'static str, + ready: &'static str, +} + +fn tray_copy(language: AppLanguage) -> &'static TrayCopy { + static EN: TrayCopy = TrayCopy { + open: "Open Tokenscope", + refresh: "Refresh", + check_updates: "Check for Updates…", + provider_limits: "Provider Limits", + provider_unavailable: "Unavailable", + menu_bar_display: "Menu Bar Display", + off: "Off", + compact: "Compact", + detailed: "Detailed", + dashboard_shortcut: "Dashboard Shortcut", + change_dashboard_shortcut: "Change Dashboard Shortcut…", + autostart: "Launch at Login", + language: "Language", + english: "English", + chinese: "Simplified Chinese", + quit: "Quit", + today: "today", + ready: "Ready", + }; + static ZH: TrayCopy = TrayCopy { + open: "打开 Tokenscope", + refresh: "刷新", + check_updates: "检查更新…", + provider_limits: "额度详情", + provider_unavailable: "不可用", + menu_bar_display: "菜单栏显示", + off: "关闭", + compact: "紧凑", + detailed: "详细", + dashboard_shortcut: "Dashboard 快捷键", + change_dashboard_shortcut: "修改 Dashboard 快捷键…", + autostart: "登录时启动", + language: "语言", + english: "English", + chinese: "简体中文", + quit: "退出", + today: "今日", + ready: "就绪", + }; + match language { + AppLanguage::En => &EN, + AppLanguage::Zh => &ZH, + } +} + +fn shortcut_label(shortcut: &str) -> String { + let macos = cfg!(target_os = "macos"); + let mut parts = Vec::new(); + for part in shortcut.split('+') { + let (order, label) = match part.to_ascii_uppercase().as_str() { + "COMMANDORCONTROL" | "COMMANDORCTRL" | "CMDORCTRL" | "CMDORCONTROL" => { + if macos { + (4, "⌘") + } else { + (1, "Ctrl") + } + } + "COMMAND" | "CMD" | "SUPER" => (4, if macos { "⌘" } else { "Win" }), + "CONTROL" | "CTRL" => (1, if macos { "⌃" } else { "Ctrl" }), + "OPTION" | "ALT" => (2, if macos { "⌥" } else { "Alt" }), + "SHIFT" => (3, if macos { "⇧" } else { "Shift" }), + _ => ( + 5, + part.strip_prefix("Key") + .or_else(|| part.strip_prefix("Digit")) + .unwrap_or(part), + ), + }; + parts.push((order, label)); + } + parts.sort_by_key(|part| part.0); + let labels = parts.into_iter().map(|part| part.1).collect::>(); + if macos { + labels.join("") + } else { + labels.join("+") + } +} + +fn dashboard_shortcut_menu_label(shortcut: &str, language: AppLanguage) -> String { + format!( + "{} ({})", + tray_copy(language).dashboard_shortcut, + shortcut_label(shortcut) + ) +} + +fn apply_tray_language(menu: &TrayMenuState, language: AppLanguage, shortcut: &str) { + let copy = tray_copy(language); + let _ = menu.open.set_text(copy.open); + let _ = menu.refresh.set_text(copy.refresh); + let _ = menu.check_updates.set_text(copy.check_updates); + let _ = menu.provider_limits.set_text(copy.provider_limits); + let _ = menu.menu_bar_display.set_text(copy.menu_bar_display); + let _ = menu.display_off.set_text(copy.off); + let _ = menu.display_compact.set_text(copy.compact); + let _ = menu.display_detailed.set_text(copy.detailed); + let _ = menu + .dashboard_shortcut + .set_text(dashboard_shortcut_menu_label(shortcut, language)); + let _ = menu + .change_dashboard_shortcut + .set_text(copy.change_dashboard_shortcut); + let _ = menu.autostart.set_text(copy.autostart); + let _ = menu.language.set_text(copy.language); + let _ = menu.language_en.set_text(copy.english); + let _ = menu.language_zh.set_text(copy.chinese); + let _ = menu.language_en.set_checked(language == AppLanguage::En); + let _ = menu.language_zh.set_checked(language == AppLanguage::Zh); + let _ = menu.quit.set_text(copy.quit); +} + +/// Percentage left for a window, hidden once its reset timestamp has passed +/// (a dormant pool may stop emitting snapshots after the window rolls over). +fn window_left(window: &model::LimitWindow, now_s: i64) -> Option { + if window.resets_at > 0 && window.resets_at <= now_s { + return None; + } + Some((100.0 - window.used_pct).clamp(0.0, 100.0).round() as u8) +} + +/// Provider prefix + window token used in menu-bar summaries. +/// Claude → Cl, Codex → Cx; windows: 5h / W / S. +fn provider_prefix(provider: &str) -> &'static str { + match provider { + "claude" => "Cl", + "codex" => "Cx", + _ => "", + } +} + +fn window_token(window: &model::LimitWindow) -> &'static str { + match window.id.as_str() { + "5h" => "5h", + "spark" => "S", + _ => "W", + } +} + +/// One provider's menu-bar segment: compact shows only the tightest window, +/// detailed lists every active window (`Cl 5h64/W82`). +fn provider_summary(limit: &model::ProviderLimit, detailed: bool, now_s: i64) -> String { + let prefix = provider_prefix(&limit.provider); + let active: Vec<&model::LimitWindow> = limit + .windows + .iter() + .filter(|window| window_left(window, now_s).is_some()) + .collect(); + if active.is_empty() { + return String::new(); + } + if !detailed { + let tightest = limit + .windows + .iter() + .filter(|window| window_left(window, now_s).is_some()) + .max_by(|a, b| { + a.used_pct + .partial_cmp(&b.used_pct) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .expect("non-empty"); + let left = window_left(tightest, now_s).expect("filtered"); + return format!("{prefix}{left}%"); + } + let mut sorted = active; + sorted.sort_by_key(|window| match window.id.as_str() { + "5h" => 0, + "weekly" => 1, + _ => 2, + }); + let parts: Vec = sorted + .iter() + .filter_map(|window| { + window_left(window, now_s).map(|left| { + format!("{}{}%", window_token(window), left) + }) + }) + .collect(); + if parts.is_empty() { + String::new() + } else { + format!("{prefix} {}", parts.join("/")) + } +} + +fn tray_label(dash: &Dashboard, display: MenuBarQuotaDisplay, language: AppLanguage) -> String { + let mut label = fmt_tokens_m(dash.today_tokens, language); + let now_s = now_ms() / 1000; + match display { + MenuBarQuotaDisplay::Off => {} + MenuBarQuotaDisplay::Compact | MenuBarQuotaDisplay::Detailed => { + let detailed = display == MenuBarQuotaDisplay::Detailed; + let mut segments: Vec = dash + .provider_limits + .iter() + .map(|limit| provider_summary(limit, detailed, now_s)) + .filter(|segment| !segment.is_empty()) + .collect(); + if !segments.is_empty() { + segments.insert(0, label); + label = segments.join(" · "); + } + } + } + label +} + +/// Menu-bar text for each provider row under Provider Limits, e.g. +/// "Claude — 5h 64% · W 82%" (Claude) or "Codex — W 29% · S 31%" (Codex). +fn provider_menu_row( + limit: Option<&model::ProviderLimit>, + provider_label: &str, + unavailable: &str, + now_s: i64, +) -> String { + let Some(limit) = limit else { + // Empty native menu-item titles collapse the macOS submenu into the + // tiny blank scroller seen when `hu` cannot run. + return format!("{provider_label} — {unavailable}"); + }; + let mut sorted: Vec<&model::LimitWindow> = limit + .windows + .iter() + .filter(|window| window_left(window, now_s).is_some()) + .collect(); + sorted.sort_by_key(|window| match window.id.as_str() { + "5h" => 0, + "weekly" => 1, + _ => 2, + }); + let parts: Vec = sorted + .iter() + .filter_map(|window| { + window_left(window, now_s).map(|left| { + let label = match window.id.as_str() { + "5h" => "5h".to_string(), + "weekly" => "W".to_string(), + _ => "S".to_string(), + }; + format!("{label} {left}%") + }) + }) + .collect(); + if parts.is_empty() { + return format!("{} — —", limit.label); + } + format!("{} — {}", limit.label, parts.join(" · ")) +} + +fn update_tray_label(app: &tauri::AppHandle, dash: &Dashboard) { + let (display, language) = app + .try_state::() + .map(|state| { + state + .0 + .lock() + .map(|prefs| (prefs.weekly_quota_display, prefs.language)) + .unwrap_or_default() + }) + .unwrap_or_default(); + let label = tray_label(dash, display, language); + let today = tray_copy(language).today; + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(tray) = handle.tray_by_id("main") { + // macOS shows the label next to the menu-bar icon (set_title). + // Windows' taskbar tray has no equivalent, so mirror it into the + // tooltip there. Both APIs touch native tray state; on macOS that + // must happen on the main thread. + let _ = tray.set_title(Some(label.clone())); + let _ = tray.set_tooltip(Some(format!("Tokenscope · {today} {label}"))); + } + }); +} + +/// Rebuild the dashboard (incremental), update the tray's token count and the +/// Provider Limits menu rows, then push the fresh data to the UI so an open +/// popover updates live. +fn refresh(app: &tauri::AppHandle) { + let dash = parser::build_dashboard(); + update_tray_label(app, &dash); + update_provider_rows(app, &dash); + let _ = app.emit("dashboard-updated", &dash); +} + +/// Keep the two Provider Limits menu rows in sync with the latest snapshot. +fn update_provider_rows(app: &tauri::AppHandle, dash: &Dashboard) { + let Some(state) = app.try_state::() else { + return; + }; + let now_s = now_ms() / 1000; + let (claude, codex) = provider_rows(&dash.provider_limits); + let language = app + .try_state::() + .and_then(|state| state.0.lock().ok().map(|prefs| prefs.language)) + .unwrap_or_default(); + let copy = tray_copy(language); + let claude_text = provider_menu_row(claude, "Claude", copy.provider_unavailable, now_s); + let codex_text = provider_menu_row(codex, "Codex", copy.provider_unavailable, now_s); + let provider_claude = state.provider_claude.clone(); + let provider_codex = state.provider_codex.clone(); + let handle = app.clone(); + let _ = handle.run_on_main_thread(move || { + let _ = provider_claude.set_text(claude_text); + let _ = provider_codex.set_text(codex_text); + }); +} + +fn provider_rows( + limits: &[model::ProviderLimit], +) -> (Option<&model::ProviderLimit>, Option<&model::ProviderLimit>) { + let mut claude = None; + let mut codex = None; + for limit in limits { + match limit.provider.as_str() { + "claude" => claude = Some(limit), + "codex" => codex = Some(limit), + _ => {} } } + (claude, codex) +} + +/// Cooldown for manual force-refreshes (the tray "Refresh" item). Price tables +/// change at most a few times a day, so back-to-back clicks inside this window +/// coalesce into one fetch. +const FORCE_COOLDOWN_MS: i64 = 30_000; +static LAST_FORCE_MS: AtomicI64 = AtomicI64::new(0); + +/// Off-thread, silent price-table refresh (models.dev + LiteLLM) bypassing the +/// 24h cache, folded into the tray's "Refresh" item. Returns immediately; once +/// the new table is swapped in, refresh() pushes dashboard-updated so an open +/// panel re-prices live, same silent path as the 30s background poll (no +/// loading state, no UI feedback). Throttled to one per FORCE_COOLDOWN_MS via +/// compare_exchange (fixed window, not sliding) so rapid clicks can't spawn +/// concurrent fetches racing on the cache. +fn refresh_pricing_bg(app: &tauri::AppHandle) { + let now = now_ms(); + loop { + let prev = LAST_FORCE_MS.load(Ordering::Relaxed); + if now - prev < FORCE_COOLDOWN_MS { + return; + } + match LAST_FORCE_MS.compare_exchange(prev, now, Ordering::Relaxed, Ordering::Relaxed) { + Ok(_) => break, + Err(_) => continue, + } + } + let handle = app.clone(); + std::thread::spawn(move || { + pricing::Pricing::reload_shared(true); + refresh(&handle); + }); +} + +#[tauri::command] +fn refresh_pricing(app: tauri::AppHandle) { + refresh_pricing_bg(&app); } // ── Launch-at-login preference ────────────────────────────────────── -// Persisted in the data dir (survives restarts/updates, like milestones). The +// Persisted in the data dir so it survives restarts and updates. The // on/off toggle lives in the tray's right-click menu; on startup we reconcile // the OS registration to this preference rather than force-enabling every // launch (which silently undid a user who had turned autostart off). @@ -118,6 +543,43 @@ fn save_autostart_pref(on: bool) { } } +fn tray_preferences_path() -> Option { + let dir = dirs::data_dir()?.join("tokenscope"); + let _ = std::fs::create_dir_all(&dir); + Some(dir.join("tray.json")) +} + +fn load_tray_preferences() -> TrayPreferences { + let Some(path) = tray_preferences_path() else { + return TrayPreferences::default(); + }; + let Some(text) = std::fs::read_to_string(path).ok() else { + return TrayPreferences::default(); + }; + parse_tray_preferences(&text) +} + +fn parse_tray_preferences(text: &str) -> TrayPreferences { + let mut preferences: TrayPreferences = serde_json::from_str(text).unwrap_or_default(); + let legacy_weekly_on = serde_json::from_str::(text) + .ok() + .filter(|value| value.get("weekly_quota_display").is_none()) + .and_then(|value| value.get("show_weekly_remaining")?.as_bool()) + .unwrap_or(false); + if legacy_weekly_on { + preferences.weekly_quota_display = MenuBarQuotaDisplay::Compact; + } + preferences +} + +fn save_tray_preferences(preferences: &TrayPreferences) { + if let Some(path) = tray_preferences_path() { + if let Ok(text) = serde_json::to_string(preferences) { + let _ = std::fs::write(path, text); + } + } +} + /// Bring the OS launch-at-login registration in line with the saved preference, /// returning the effective preference (used to seed the menu checkbox). First /// run (no saved pref) defaults to on and records it; thereafter we honor the @@ -140,197 +602,6 @@ fn reconcile_autostart(app: &tauri::AppHandle) -> bool { pref } -/// Current calendar-week and calendar-month identifiers, matching parser.rs's -/// period definitions (Monday-based week, calendar month), so a stored floor is -/// only ever compared within the same period. -fn period_ids() -> (String, String) { - use chrono::Datelike; - let d = chrono::Local::now().date_naive(); - let iso = d.iso_week(); - ( - format!("{}-W{:02}", iso.year(), iso.week()), - format!("{}-{:02}", d.year(), d.month()), - ) -} - -/// Decide whether to celebrate: fire if either period advanced to a higher -/// 100M floor *within the same period*. `None` (first ever observation) never -/// fires. A period-id mismatch means that period reset, so it re-baselines -/// silently rather than comparing floors. Returns a single bool, so a jump -/// across several boundaries — or week and month advancing together — is one -/// celebration. -fn milestone_fire(prev: Option<&MilestoneState>, cur: &MilestoneState) -> bool { - match prev { - None => false, - Some(p) => { - (p.week_id == cur.week_id && cur.week_floor > p.week_floor) - || (p.month_id == cur.month_id && cur.month_floor > p.month_floor) - } - } -} - -/// Observe the latest totals, persist the snapshot, and celebrate on a new -/// 100M-token milestone. We watch week ∪ month, not day: today is always within -/// both the current week and month, so a day crossing is already implied by the -/// month — but a calendar week can straddle a month boundary, so early in a -/// month the week total can lead the (freshly reset) month, hence both. Because -/// the snapshot is persisted, a crossing that happened while the app wasn't -/// running (it reads the logs Claude writes regardless) still catches up on the -/// next observation. -fn check_milestones(app: &tauri::AppHandle, dash: &Dashboard) { - let Some(state) = app.try_state::() else { - return; - }; - // total_tokens is already in millions, so a 100M milestone is total / 100. - let (week_id, month_id) = period_ids(); - let cur = MilestoneState { - week_id, - week_floor: (dash.week.metrics.total_tokens / 100.0).floor() as i64, - month_id, - month_floor: (dash.month.metrics.total_tokens / 100.0).floor() as i64, - }; - - let mut g = state.state.lock().unwrap(); - let fire = milestone_fire(g.as_ref(), &cur); - // Keep the persisted floors monotonic within a period: a later observation - // with a lower total (a transient/partial read, or two observers racing) - // must not regress the stored floor and re-fire the celebration on restart. - let mut next = cur.clone(); - if let Some(prev) = g.as_ref() { - if prev.week_id == next.week_id && prev.week_floor > next.week_floor { - next.week_floor = prev.week_floor; - } - if prev.month_id == next.month_id && prev.month_floor > next.month_floor { - next.month_floor = prev.month_floor; - } - } - *g = Some(next.clone()); - // Persist while still holding the lock so two observers can't interleave and - // write a stale snapshot over a newer one. - save_milestones(&next); - drop(g); - if fire { - celebrate(app); - } -} - -/// Trigger the celebration overlay. Window/panel work must run on the main -/// thread (refresh() runs on a background thread), so hop there. -fn celebrate(app: &tauri::AppHandle) { - let handle = app.clone(); - let _ = app.run_on_main_thread(move || show_celebration(&handle)); -} - -/// Show (or reuse) a full-screen, click-through, non-activating overlay on the -/// primary monitor and run the confetti animation, then hide it after it plays. -/// Must be called on the main thread. -fn show_celebration(app: &tauri::AppHandle) { - let Some(state) = app.try_state::() else { - return; - }; - // Skip if a celebration is already playing. - if state.active.swap(true, Ordering::SeqCst) { - return; - } - - let (pos, size) = match app.primary_monitor() { - Ok(Some(m)) => (*m.position(), *m.size()), - _ => { - state.active.store(false, Ordering::SeqCst); - return; - } - }; - - // Whether the confetti window was reused or freshly built — only used on - // macOS to decide whether to (re-)apply the NSPanel attributes. - #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] - let existed = app.get_webview_window("confetti").is_some(); - let win = match app.get_webview_window("confetti") { - Some(w) => w, - None => { - match tauri::WebviewWindowBuilder::new( - app, - "confetti", - tauri::WebviewUrl::App("confetti.html".into()), - ) - .title("Tokenscope Celebration") - .inner_size(size.width as f64, size.height as f64) - .decorations(false) - .transparent(true) - .shadow(false) - .always_on_top(true) - .skip_taskbar(true) - .focused(false) - .resizable(false) - .visible(false) - .build() - { - Ok(w) => w, - Err(_) => { - state.active.store(false, Ordering::SeqCst); - return; - } - } - } - }; - - // Cover the whole primary monitor and let clicks pass through to the apps - // beneath — the celebration must never interrupt what the user is doing. - let _ = win.set_position(pos); - let _ = win.set_size(size); - let _ = win.set_ignore_cursor_events(true); - - #[cfg(target_os = "macos")] - { - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - #[allow(non_upper_case_globals)] - const NS_NONACTIVATING_PANEL: i32 = 1 << 7; - - // Convert to a non-activating panel once, so it can float over apps in - // native fullscreen without stealing focus (same approach as the main - // popover). On reuse the window is already a panel. - if !existed { - if let Ok(panel) = win.to_panel() { - panel.set_level(25); // NSMainMenuWindowLevel (24) + 1 - panel.set_style_mask(NS_NONACTIVATING_PANEL); - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorMoveToActiveSpace - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary, - ); - } - } - let _ = win.eval("window.__burst&&window.__burst()"); - if let Ok(panel) = app.get_webview_panel("confetti") { - panel.show(); - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = win.eval("window.__burst&&window.__burst()"); - let _ = win.show(); - } - - // Hide once the animation has played out (emission ~2.3s + fall/fade). - let app2 = app.clone(); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(4200)); - let app3 = app2.clone(); - let _ = app2.run_on_main_thread(move || { - #[cfg(target_os = "macos")] - if let Ok(panel) = app3.get_webview_panel("confetti") { - panel.order_out(None); - } - #[cfg(not(target_os = "macos"))] - if let Some(w) = app3.get_webview_window("confetti") { - let _ = w.hide(); - } - if let Some(st) = app3.try_state::() { - st.active.store(false, Ordering::SeqCst); - } - }); - }); -} - /// Last tray-icon rectangle (physical px: x, y, width, height), captured on tray /// click. Used to anchor the panel like tauri-plugin-positioner's /// TrayBottomCenter — but we can't use the positioner itself on a swizzled @@ -505,6 +776,7 @@ fn position_popover_windows(app: &tauri::AppHandle) { /// True if our (Accessory) app is currently the frontmost application. #[cfg(target_os = "macos")] +#[allow(deprecated)] // tauri_nspanel::cocoa (objc2 migration is upstream's) fn app_is_frontmost() -> bool { use tauri_nspanel::cocoa::base::id; use tauri_nspanel::objc::{class, msg_send, sel, sel_impl}; @@ -541,6 +813,7 @@ fn hide_panel_on_context_switch(app: &tauri::AppHandle) { /// activation (mirrors tauri-nspanel's menu-bar example). The observers live for /// the whole app lifetime, so the returned tokens are intentionally dropped. #[cfg(target_os = "macos")] +#[allow(deprecated)] // tauri_nspanel::cocoa (objc2 migration is upstream's) fn register_panel_autohide(app: &tauri::AppHandle) { use std::ffi::CString; use tauri_nspanel::block::ConcreteBlock; @@ -578,6 +851,7 @@ fn register_panel_autohide(app: &tauri::AppHandle) { /// (and thus the webview's `prefers-color-scheme`) can lag the real system value. /// The user default reflects the system setting directly, regardless of focus. #[cfg(target_os = "macos")] +#[allow(deprecated)] // tauri_nspanel::cocoa (objc2 migration is upstream's) fn system_is_dark() -> bool { use std::ffi::CStr; use tauri_nspanel::cocoa::base::{id, nil}; @@ -586,7 +860,7 @@ fn system_is_dark() -> bool { let defaults: id = msg_send![class!(NSUserDefaults), standardUserDefaults]; let key: id = msg_send![ class!(NSString), - stringWithUTF8String: b"AppleInterfaceStyle\0".as_ptr() as *const std::os::raw::c_char + stringWithUTF8String: c"AppleInterfaceStyle".as_ptr() ]; let val: id = msg_send![defaults, stringForKey: key]; if val == nil { @@ -609,6 +883,7 @@ fn system_is_dark() -> bool { /// lives for the whole app lifetime, so the returned token is intentionally /// dropped (same as register_panel_autohide). #[cfg(target_os = "macos")] +#[allow(deprecated)] // tauri_nspanel::cocoa (objc2 migration is upstream's) fn watch_system_theme(app: &tauri::AppHandle) { use std::ffi::CString; use tauri_nspanel::block::ConcreteBlock; @@ -636,13 +911,68 @@ fn watch_system_theme(app: &tauri::AppHandle) { /// Show the panel as a popover anchored under the tray icon, and focus it. /// Always reset the scroll to the top so it doesn't reopen mid-scroll. fn show_popover(app: &tauri::AppHandle) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || show_popover_inner(&handle)); +} + +fn toggle_popover(app: &tauri::AppHandle) { + let handle = app.clone(); + let _ = app.run_on_main_thread(move || { + #[cfg(target_os = "macos")] + { + let visible = handle + .get_webview_panel("main") + .map(|panel| panel.is_visible()) + .unwrap_or_else(|_| { + handle + .get_webview_window("main") + .and_then(|window| window.is_visible().ok()) + .unwrap_or(false) + }); + if visible { + if let Ok(panel) = handle.get_webview_panel("main") { + panel.order_out(None); + } else if let Some(window) = handle.get_webview_window("main") { + let _ = window.hide(); + } + } else { + show_popover_inner(&handle); + } + } + #[cfg(not(target_os = "macos"))] + { + let visible = handle + .get_webview_window("main") + .and_then(|window| window.is_visible().ok()) + .unwrap_or(false); + if visible { + if let Some(window) = handle.get_webview_window("main") { + let _ = window.hide(); + } + } else { + show_popover_inner(&handle); + } + } + }); +} + +fn show_popover_inner(app: &tauri::AppHandle) { // On macOS the window is an NSPanel — position it manually, then show() // (makes it key and orders it front, incl. over fullscreen Spaces). #[cfg(target_os = "macos")] { position_panel(app); - if let Ok(panel) = app.get_webview_panel("main") { - panel.show(); + match app.get_webview_panel("main") { + Ok(panel) => panel.show(), + Err(_) => { + // If the panel state is ever unavailable (for example after a + // plugin/setup ordering regression), still surface the window + // instead of making a tray click look like a no-op. + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } + } } } #[cfg(not(target_os = "macos"))] @@ -660,6 +990,99 @@ fn show_popover(app: &tauri::AppHandle) { } } +#[tauri::command] +fn set_dashboard_shortcut(app: tauri::AppHandle, shortcut: String) -> Result<(), String> { + let shortcut = shortcut.trim().to_string(); + shortcut + .parse::() + .map_err(|_| "invalid shortcut".to_string())?; + + let state = app + .try_state::() + .ok_or_else(|| "shortcut state unavailable".to_string())?; + let (was_enabled, previous) = state + .0 + .lock() + .map(|preferences| { + ( + preferences.dashboard_shortcut, + preferences.dashboard_shortcut_key.clone(), + ) + }) + .map_err(|_| "shortcut state unavailable".to_string())?; + + if was_enabled && previous != shortcut { + app.global_shortcut() + .unregister(previous.as_str()) + .map_err(|error| format!("could not replace shortcut: {error}"))?; + } + if !was_enabled || previous != shortcut { + if let Err(error) = app.global_shortcut().register(shortcut.as_str()) { + if was_enabled && previous != shortcut { + let _ = app.global_shortcut().register(previous.as_str()); + } + return Err(format!("shortcut unavailable: {error}")); + } + } + + let preferences = state + .0 + .lock() + .map(|mut preferences| { + preferences.dashboard_shortcut = true; + preferences.dashboard_shortcut_key = shortcut.clone(); + preferences.clone() + }) + .map_err(|_| "shortcut state unavailable".to_string())?; + save_tray_preferences(&preferences); + if let Some(menu) = app.try_state::() { + let _ = menu.dashboard_shortcut.set_checked(true); + let _ = menu + .dashboard_shortcut + .set_text(dashboard_shortcut_menu_label( + &shortcut, + preferences.language, + )); + } + Ok(()) +} + +#[tauri::command] +fn get_app_language(app: tauri::AppHandle) -> String { + app.try_state::() + .and_then(|state| { + state + .0 + .lock() + .ok() + .map(|prefs| prefs.language.as_str().to_string()) + }) + .unwrap_or_else(|| AppLanguage::En.as_str().to_string()) +} + +#[tauri::command] +fn set_app_language(app: tauri::AppHandle, language: String) -> Result<(), String> { + let language = + AppLanguage::parse(language.trim()).ok_or_else(|| "invalid language".to_string())?; + let state = app + .try_state::() + .ok_or_else(|| "language state unavailable".to_string())?; + let preferences = state + .0 + .lock() + .map(|mut preferences| { + preferences.language = language; + preferences.clone() + }) + .map_err(|_| "language state unavailable".to_string())?; + save_tray_preferences(&preferences); + if let Some(menu) = app.try_state::() { + apply_tray_language(&menu, language, &preferences.dashboard_shortcut_key); + } + let _ = app.emit("language-changed", language.as_str()); + Ok(()) +} + #[tauri::command] async fn get_dashboard(app: tauri::AppHandle) -> Dashboard { // build_dashboard does blocking IO (reads/writes the cache, parses logs) and @@ -672,17 +1095,29 @@ async fn get_dashboard(app: tauri::AppHandle) -> Dashboard { // Sync the tray count to this freshly-fetched value. The panel refetches the // instant it opens, while the tray otherwise only refreshes every 30s — so // without this the two could disagree for up to 30s during heavy usage. - if let Some(tray) = app.tray_by_id("main") { - let label = fmt_tokens_m(dash.today_tokens); - let _ = tray.set_title(Some(label.clone())); - // Mirror refresh(): keep the tooltip in sync for Windows, where the - // title isn't shown next to the icon. - let _ = tray.set_tooltip(Some(format!("Tokenscope · today {}", label))); - } - check_milestones(&app, &dash); + update_tray_label(&app, &dash); dash } +#[tauri::command] +async fn get_range_dashboard( + start_date: String, + end_date: String, +) -> Result { + let start = chrono::NaiveDate::parse_from_str(&start_date, "%Y-%m-%d") + .map_err(|_| "invalid start date".to_string())?; + let end = chrono::NaiveDate::parse_from_str(&end_date, "%Y-%m-%d") + .map_err(|_| "invalid end date".to_string())?; + match tauri::async_runtime::spawn_blocking(move || { + parser::build_range_dashboard(start, end) + }) + .await + { + Ok(result) => result, + Err(error) => Err(format!("failed to build date range: {error}")), + } +} + /// Save a full-panel screenshot (a `data:image/png;base64,...` URL captured in /// the webview) to the user's Desktop as `Tokenscope at