Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .github/workflows/offline-check.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: offline-check

on:
push:
pull_request:
workflow_dispatch:

# Enforces the offline-by-default guarantee: no networking outside
# Sources/AgentMeter/Network/ (and none at all in AgentMeterCore).
jobs:
offline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check no networking outside Network/
run: ./Scripts/check-offline.sh
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,13 @@ brew install --cask TeaLance/tap/agentmeter

## 隱私

只**讀取**本機 `~/.claude` 與 `~/.codex` 的檔案來統計用量,**不連網、不讀 Keychain、不傳送任何資料**。
用量統計只讀取本機 `~/.claude` 與 `~/.codex` 的紀錄,**不讀 Keychain、不傳送任何資料給第三方**。

為了顯示登入帳號與真實額度,預設會做兩件事(都可在**設定 → 進階**關閉):
- **顯示登入帳號**:讀取本機登入憑證「檔案」(**不讀 Keychain**)解出 email/方案;不連網。
- **Codex 即時額度**:用本機憑證的權杖連線 OpenAI 取得真實 5h/每週額度。

關閉以上兩項後即**完全離線**。網路碼僅限於 `Sources/AgentMeter/Network/`(有 CI `Scripts/check-offline.sh` 把關)。

## 授權

Expand Down
19 changes: 19 additions & 0 deletions Scripts/check-offline.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Verifiable "offline by default" guarantee: no networking outside Sources/AgentMeter/Network/.
# AgentMeterCore must never touch the network at all.
set -euo pipefail
cd "$(dirname "$0")/.."

pattern='URLSession|NWConnection|NWBrowser|import Network|CFSocketRef|getaddrinfo'

# Anything under Network/ is allowed; everything else (incl. all of AgentMeterCore) is not.
hits=$(grep -rnE "$pattern" Sources \
| grep -v '/Network/' || true)

if [[ -n "$hits" ]]; then
echo "❌ offline check FAILED — networking found outside Sources/AgentMeter/Network/:"
echo "$hits"
exit 1
fi

echo "✅ offline check passed — no networking outside Sources/AgentMeter/Network/"
107 changes: 95 additions & 12 deletions Sources/AgentMeter/AgentMeterApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,20 @@ import AgentMeterCore
@main
struct AgentMeterApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var store = UsageStore()
@StateObject private var store = UsageStore.shared
@StateObject private var lang = LanguageStore.shared
@StateObject private var colors = ServiceColorStore.shared

var body: some Scene {
MenuBarExtra {
MenuContentView()
.environmentObject(store)
.environmentObject(lang)
.environmentObject(colors)
} label: {
MenuBarLabel(store: store)
}
.menuBarExtraStyle(.window)

Settings {
SettingsView()
.environmentObject(store)
}
}
}

Expand All @@ -28,14 +27,97 @@ struct AgentMeterApp: App {
struct MenuBarLabel: View {
@ObservedObject var store: UsageStore
@AppStorage(SettingsKeys.menuBarMetrics) private var metricsCSV = defaultMenuBarMetricsCSV
// Re-render when these settings change (render reads them).
@AppStorage(SettingsKeys.meterShowsRemaining) private var showRemaining = false
@AppStorage(SettingsKeys.menuBarOrientation) private var orientation = "vertical"
@AppStorage(SettingsKeys.menuBarShowIcon) private var showIcon = true

var body: some View {
let text = MenuBarMetric.barString(MenuBarMetric.list(fromCSV: metricsCSV), store: store)
if text.isEmpty {
let cells = MenuBarMetric.cells(MenuBarMetric.list(fromCSV: metricsCSV), store: store)
if cells.isEmpty {
Image(systemName: "gauge.with.dots.needle.33percent")
} else {
Text(text)
// SwiftUI multi-line labels get clipped to the menu-bar height, so draw
// the label ourselves into a template image the system scales to fit.
Image(nsImage: MenuBarLabel.render(cells, horizontal: orientation == "horizontal", showIcon: showIcon))
}
}

private enum Item { case icon(AgentTool); case cell(top: NSAttributedString, bot: NSAttributedString) }

/// Render the metric cells as a template image. Each service group is preceded
/// by its logo; cells are stacked (vertical) or inline (horizontal).
static func render(_ cells: [(tool: AgentTool?, top: String, bottom: String)], horizontal: Bool, showIcon: Bool = true) -> NSImage {
// Horizontal: label and value share one size so the row is a single height.
// Vertical: a smaller label sits above the bold value.
let topFont: NSFont, botFont: NSFont
if horizontal {
topFont = NSFont.systemFont(ofSize: 13, weight: .regular)
botFont = NSFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold)
} else {
topFont = NSFont.systemFont(ofSize: 8, weight: .regular)
botFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .semibold)
}
let cellGap: CGFloat = 8, iconGap: CGFloat = 4, inlineGap: CGFloat = 3
let attrs: (NSFont) -> [NSAttributedString.Key: Any] = { [.font: $0, .foregroundColor: NSColor.black] }

// Baseline-relative line metrics (descender is negative → flip to a magnitude).
let topAsc = topFont.ascender, topDesc = -topFont.descender
let botAsc = botFont.ascender, botDesc = -botFont.descender
let botLineH = botAsc + botDesc
let height = ceil(horizontal ? max(topAsc, botAsc) + max(topDesc, botDesc)
: botLineH + topAsc + topDesc)
// Logo matches the text height (horizontal) or spans both lines a bit smaller
// (vertical). Centered vertically in the cell.
let iconSide = round(height * (horizontal ? 0.92 : 0.62))
let iconY = round((height - iconSide) / 2)

// Lay out icons + cells with per-item leading gaps.
var items: [(item: Item, width: CGFloat, gap: CGFloat)] = []
var lastTool: AgentTool?
for c in cells {
let topA = NSAttributedString(string: c.top, attributes: attrs(topFont))
let botA = NSAttributedString(string: c.bottom, attributes: attrs(botFont))
if showIcon, let t = c.tool, t != lastTool {
items.append((.icon(t), iconSide, items.isEmpty ? 0 : cellGap))
lastTool = t
}
let precededByIcon = items.last.map { if case .icon = $0.item { return true } else { return false } } ?? false
let topW = topA.size().width
let lead = topW > 0 ? topW + inlineGap : 0 // drop the gap when there's no label
let w = horizontal ? ceil(lead + botA.size().width)
: ceil(max(topW, botA.size().width))
items.append((.cell(top: topA, bot: botA), w, items.isEmpty ? 0 : (precededByIcon ? iconGap : cellGap)))
if c.tool == nil { lastTool = nil }
}

let totalW = items.reduce(0) { $0 + $1.gap + $1.width }
let image = NSImage(size: NSSize(width: max(1, ceil(totalW)), height: max(1, height)))
image.lockFocus()
// Shared baseline (distance from image bottom) so label and value sit on one line.
let baseline = max(topDesc, botDesc)
var x: CGFloat = 0
for entry in items {
x += entry.gap
switch entry.item {
case .icon(let tool):
MenuBarIcon.draw(tool, side: iconSide, origin: NSPoint(x: x, y: iconY))
case .cell(let topA, let botA):
let topW = topA.size().width
if horizontal {
if topW > 0 { topA.draw(at: NSPoint(x: x, y: baseline - topDesc)) }
let valX = x + (topW > 0 ? topW + inlineGap : 0)
botA.draw(at: NSPoint(x: valX, y: baseline - botDesc))
} else {
botA.draw(at: NSPoint(x: x + (entry.width - botA.size().width) / 2, y: 0))
if topW > 0 { topA.draw(at: NSPoint(x: x + (entry.width - topW) / 2, y: botLineH)) }
}
}
x += entry.width
}
image.unlockFocus()
image.isTemplate = true // adapt to light/dark menu bar automatically
return image
}
}

Expand All @@ -44,12 +126,13 @@ struct MenuBarLabel: View {
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// After the Settings window is dismissed (app loses focus), drop the Dock
// icon again so we stay a menu-bar-only app.
FloatingPanelController.shared.refresh() // restore the HUD if enabled
// When the app loses focus, drop back to a menu-bar-only app. Resetting the
// coordinator also clears any window entry whose close we couldn't hook.
NotificationCenter.default.addObserver(
forName: NSApplication.willResignActiveNotification, object: nil, queue: .main
) { _ in
NSApp.setActivationPolicy(.accessory)
MainActor.assumeIsolated { ActivationPolicyCoordinator.shared.reset() }
}
}
}
37 changes: 31 additions & 6 deletions Sources/AgentMeter/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,40 @@ enum SettingsKeys {
static let menuBarMetrics = "menuBarMetrics"
static let showClaude = "showClaude"
static let showCodex = "showCodex"
/// Which quota the Claude panel shows as its hero number (`ClaudeHero`).
static let heroMetricClaude = "heroMetricClaude"
/// Meters show remaining (true) vs used (false) percentage.
static let meterShowsRemaining = "meterShowsRemaining"
/// Menu-bar cell layout: "vertical" (stacked) or "horizontal" (inline).
static let menuBarOrientation = "menuBarOrientation"
/// Whether each service group shows its agent logo in the menu bar.
static let menuBarShowIcon = "menuBarShowIcon"
// Floating desktop HUD.
static let floatingEnabled = "floatingEnabled"
static let floatingShowClaude = "floatingShowClaude"
static let floatingShowCodex = "floatingShowCodex"
static let floatingIdleOpacity = "floatingIdleOpacity"
// Opt-in (default OFF; each gated behind a confirmation).
static let netCodexQuota = "netCodexQuota" // networked Codex quota
static let showAccounts = "showAccounts" // read credential files to show account
}

/// The Claude panel's primary "hero" metric. Default 5-hour; the panel's
/// `[5h|週]` toggle and Settings can switch it to weekly.
enum ClaudeHero: String { case fiveHour, weekly }

/// Default menu-bar selection: a single combined token figure (v1/v2 behaviour).
let defaultMenuBarMetricsCSV = MenuBarMetric.combinedTokens.rawValue

/// Refresh interval choices, in seconds.
let refreshIntervalOptions: [(label: String, seconds: Double)] = [
("15 秒", 15),
("30 秒", 30),
("1 分鐘", 60),
("5 分鐘", 300),
]
let refreshIntervalSecondsOptions: [Double] = [15, 30, 60, 300]

/// Localized label for a refresh interval (re-evaluated on language change).
func refreshIntervalLabel(_ seconds: Double) -> String {
switch seconds {
case 15: return tr("15 sec", "15 秒")
case 30: return tr("30 sec", "30 秒")
case 60: return tr("1 min", "1 分鐘")
default: return tr("5 min", "5 分鐘")
}
}
57 changes: 57 additions & 0 deletions Sources/AgentMeter/Appearance/ServiceColors.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import AppKit
import AgentMeterCore

/// Per-service identity colour (used for the menu-bar icon, the swatch, the
/// floating ring frame, and the stats chart series). NEVER used for status —
/// status stays on the 4-state ramp in `DesignSystem`.
@MainActor
final class ServiceColorStore: ObservableObject {
static let shared = ServiceColorStore()

enum Key {
static let claude = "serviceColorClaude"
static let codex = "serviceColorCodex"
}
/// Brand defaults (also offered as presets alongside a neutral mono option).
static let claudeBrand = "#D97757"
static let codexBrand = "#6C6C70"
static let mono = "#8A857A"

@Published var claudeHex: String { didSet { persist(claudeHex, Key.claude) } }
@Published var codexHex: String { didSet { persist(codexHex, Key.codex) } }

init() {
let d = UserDefaults.standard
claudeHex = d.string(forKey: Key.claude) ?? Self.claudeBrand
codexHex = d.string(forKey: Key.codex) ?? Self.codexBrand
}

func color(for tool: AgentTool) -> Color {
switch tool {
case .claudeCode: return Color(amHex: claudeHex, fallback: Color(amHex: Self.claudeBrand))
case .codex: return Color(amHex: codexHex, fallback: Color(amHex: Self.codexBrand))
}
}

func hex(for tool: AgentTool) -> String {
tool == .claudeCode ? claudeHex : codexHex
}

func setHex(_ hex: String, for tool: AgentTool) {
if tool == .claudeCode { claudeHex = hex } else { codexHex = hex }
}

private func persist(_ value: String, _ key: String) {
UserDefaults.standard.set(value, forKey: key)
}
}

/// Convert a SwiftUI `Color` (often display-P3 from `ColorPicker`) to a stable
/// `#RRGGBB` string by pinning to sRGB first — otherwise the round-trip drifts.
func hexString(from color: Color) -> String {
let ns = NSColor(color).usingColorSpace(.sRGB) ?? .gray
return HexColor.string(r: Int((ns.redComponent * 255).rounded()),
g: Int((ns.greenComponent * 255).rounded()),
b: Int((ns.blueComponent * 255).rounded()))
}
Loading
Loading