diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa152b9..16caecf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,9 +26,11 @@ jobs: npm run lint npm test - name: Tests - run: ./scripts/test.sh + run: | + ./scripts/test.sh + python3 -m unittest discover -s tests -p test_download_metrics.py - name: Build app bundle - run: ./scripts/build.sh 0.13.1 22 + run: ./scripts/build.sh 0.14.0 23 - name: Verify bundle run: | test "$(lipo -archs dist/TokenMini.app/Contents/MacOS/MacPulse)" = "arm64" diff --git a/.gitignore b/.gitignore index 2518fdc..8134744 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,10 @@ release-notes/*.draft.md .local-secrets/ .vercel .env* + +# Local verification captures and private analytics operations never enter public source. +/artifacts/ +/analytics/ + +__pycache__/ +*.pyc diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..b66f345 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,4 @@ +* +!site +!site/** +!vercel.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 6727762..54aacfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.14.0 — 2026-09-24 + +- 平衡菜单栏图标与数值左右留白,首次扫描时使用紧凑占位,扫描后保持固定宽度。 +- 修复多选筛选器只有文字可点的问题,背景、箭头与留白区域统一响应点击。 +- 增加默认关闭、主动同意后才可启用的匿名产品统计基础;未配置服务时禁用并说明原因。 +- 提供免费的 GitHub 安装包下载报表与 JSON/CSV 导出;日活、周活尚待接通正式服务,不以下载量代替。 +- 更新中英文官网、签名安装包与更新源至构建 23。 +- 移除发布二进制中的编译机路径和调试记录,增加安装包隐私检查。 + ## 0.13.1 — 2026-09-23 - 移除 Prism AI 用量页重复的外观与监控台入口,保留统一外观和「打开完整的中控台」。 diff --git a/PRIVACY.md b/PRIVACY.md index 7e6edca..a685b70 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,8 +1,8 @@ # TokenMini 隐私说明 -更新日期:2026-09-23 +更新日期:2026-09-24 -TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有用户账号,不发送产品分析、广告数据或自动崩溃报告。只有用户主动使用 Google 登录加入社区排行榜后,才会启用下文说明的排行榜账号与汇总同步。 +TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有用户账号,不发送产品分析、广告数据或自动崩溃报告。用户可以分别选择加入产品统计或社区排行榜;两项功能互相独立,默认均不上传相关数据。只有主动使用 Google 登录加入社区排行榜后,才会启用下文说明的排行榜账号与汇总同步。 ## 本地读取的数据 @@ -12,6 +12,16 @@ TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有 会话正文不会被上传。项目名只在本机显示;启用“隐私模式”后会替换成稳定别名。 +## 产品统计(可选,0.14 候选功能) + +产品统计默认关闭。仅当安装包配置了正式 HTTPS 接收服务,且用户阅读接收方与采集内容并主动同意后才发送。未配置服务的候选包不会发送产品统计,界面会明确显示未配置状态。 + +允许发送的字段仅包括随机安装标识、应用版本、固定的功能查看事件、事件发生时间、运行时长与交互时长。不采集提示词、对话、项目名、项目路径、API Key、设备序列号或个人 AI 用量明细。随机安装标识不是实名账号,不代表精确的自然人人数。 + +统计数据保存在运营者的私有统计服务,报表访问需要服务端身份验证。客户端只包含公开写入标识,不包含读取报表或管理后台的账号、密码、查询密钥或管理员密钥;数据和管理凭据不进入公开 GitHub 仓库或安装包。服务端正常网络请求可能接触 IP 和客户端信息,正式服务发布前需明确其保留与删除规则。 + +关闭统计会停止后续发送、取消在途任务并清除本地待发队列和随机标识;已被服务端接收的数据不会自动删除。可通过项目的私密反馈渠道申请处理已接收数据。再次同意会产生新的随机标识。网络中断时事件有界保存在本机并在恢复后补传;正式报表必须具备服务端去重,候选包不能作为生产统计已联通的证明。 + ## Google 登录与社区排行榜(可选) 不登录时,系统监控、AI 用量、费用预估、清理、Skills、通知和更新等本地功能都可正常使用,TokenMini 不会向排行榜服务上传用量。 @@ -45,7 +55,7 @@ Google ID token 会发送到 TokenMini 服务端验签;TokenMini 的访问令 - 用户打开社区排行榜时:读取公开榜单;只有登录加入后才向 TokenMini 排行榜服务同步上述每日汇总。 - 用户使用 Google 登录时:在系统浏览器打开 Google OAuth,并由 TokenMini 服务端验证 Google 身份。 -除以上用户可感知功能外,TokenMini 不发送设备指纹、会话内容或匿名产品分析。 +除以上用户主动选择的功能外,TokenMini 不发送产品分析;任何情况下都不会为产品统计发送会话内容或设备指纹。 ## 删除与进程操作 diff --git a/README.md b/README.md index 6d7b705..a2fb732 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ All app screenshots above were captured from the built macOS app with privacy mo ## Privacy model -TokenMini is local-first. It does not upload conversation text, prompts, responses, private project paths, or AI credentials. Product analytics, advertising telemetry, and automatic crash reporting are not enabled. +TokenMini is local-first. It does not upload conversation text, prompts, responses, private project paths, or AI credentials. Product analytics is off by default and requires explicit consent plus a configured HTTPS collection service. The 0.14 candidate without a production endpoint sends no product analytics. Advertising telemetry and automatic crash reporting are not enabled. Network access only occurs for user-visible features such as update checks, optional Claude quota access, installing a Skill from a user-selected GitHub repository, and the optional community ranking. See the full [Privacy Notice](PRIVACY.md) and [Security Policy](SECURITY.md). diff --git a/Sources/MacPulse/AIUsage/DisplaySettings.swift b/Sources/MacPulse/AIUsage/DisplaySettings.swift index e6fc365..9f911d4 100644 --- a/Sources/MacPulse/AIUsage/DisplaySettings.swift +++ b/Sources/MacPulse/AIUsage/DisplaySettings.swift @@ -95,7 +95,8 @@ final class DisplaySettings: ObservableObject { showQuotaInMenuBar = d.bool(forKey: kQuotaBar) // 默认 false theme = AppTheme(rawValue: d.string(forKey: kTheme) ?? "") ?? .prism dashboardMode = DashboardMode(rawValue: d.string(forKey: kDashboardMode) ?? "") ?? .overview - privacyMode = d.bool(forKey: kPrivacyMode) + // Fresh installs show real project names; preserve an existing opt-in to hide them. + privacyMode = d.object(forKey: kPrivacyMode) as? Bool ?? false } func projectName(_ raw: String) -> String { diff --git a/Sources/MacPulse/AIUsage/PricingTable.swift b/Sources/MacPulse/AIUsage/PricingTable.swift index 5f71c28..ea0c8da 100644 --- a/Sources/MacPulse/AIUsage/PricingTable.swift +++ b/Sources/MacPulse/AIUsage/PricingTable.swift @@ -61,8 +61,14 @@ enum PricingTable { /// Pinned ccusage/models.dev reference snapshot. Exact raw IDs only: provider prices must not collide. /// Tiered models are intentionally excluded until their tier semantics are implemented. static let communityPrices: [String: ModelPricing] = { - let resourceBundle = Bundle.main.resourceURL.flatMap { Bundle(url: $0.appendingPathComponent("MacPulse_MacPulse.bundle")) } ?? Bundle.module - guard let url = resourceBundle.url(forResource: "CommunityPrices", withExtension: "json"), + #if DEBUG + let resourceBundle: Bundle? = Bundle.main.resourceURL.flatMap { Bundle(url: $0.appendingPathComponent("MacPulse_MacPulse.bundle")) } ?? Bundle.module + #else + // SPM's generated Bundle.module embeds an absolute build-machine path. + // Release apps resolve only the resource shipped inside the signed bundle. + let resourceBundle = Bundle.main.resourceURL.flatMap { Bundle(url: $0.appendingPathComponent("MacPulse_MacPulse.bundle")) } + #endif + guard let url = resourceBundle?.url(forResource: "CommunityPrices", withExtension: "json"), let data = try? Data(contentsOf: url), let rates = try? JSONDecoder().decode([String: CustomRate].self, from: data) else { return [:] } return rates.filter { $0.value.valid }.mapValues(\.pricing) diff --git a/Sources/MacPulse/Analytics/AnalyticsOutbox.swift b/Sources/MacPulse/Analytics/AnalyticsOutbox.swift new file mode 100644 index 0000000..29bd185 --- /dev/null +++ b/Sources/MacPulse/Analytics/AnalyticsOutbox.swift @@ -0,0 +1,124 @@ +import Foundation + +enum ProductEvent: String, Codable { + case observationStarted = "observation_started" + case appStart = "app_start" + case engagement = "app_engagement" + case menuViewed = "menu_viewed" + case dashboardViewed = "dashboard_viewed" + case maintenanceViewed = "maintenance_viewed" + case rankingsViewed = "rankings_viewed" +} + +struct AnalyticsEvent: Codable { + let id: String + let profileID: String + let name: ProductEvent + let occurredAt: Date + let cohort: String + let version: String + let runtime: Double + let interaction: Double + + func openPanelBody() throws -> Data { + try JSONSerialization.data(withJSONObject: [ + "type": "track", + "payload": ["name": name.rawValue, "profileId": profileID, "properties": [ + "product": "tokenmini", "platform": "macos", "version": version, + "cohort": cohort, "event_id": id, + "__timestamp": ISO8601DateFormatter().string(from: occurredAt), + "runtime_seconds": runtime, "interaction_seconds": interaction + ]] as [String: Any] + ]) + } +} + +/// Small disk outbox; analytics storage, exploration and cohorts belong to OpenPanel. +/// Use only on the main thread. No session content or free-form properties are accepted. +final class AnalyticsOutbox { + private struct State: Codable { + var enabled = false + var profileID: String? + var cohort: String + var events: [AnalyticsEvent] = [] + } + private let file: URL + private let limit: Int + private var state: State + private(set) var storageFailed = false + var enabled: Bool { state.enabled } + var profileID: String? { state.profileID } + var cohort: String { state.cohort } + var events: [AnalyticsEvent] { state.events } + + init(file: URL, cohort: String, limit: Int = 2000) { + self.file = file + self.limit = max(2, limit) + state = State(cohort: ["new", "upgrade", "unknown"].contains(cohort) ? cohort : "unknown") + if let data = try? Data(contentsOf: file), + let saved = try? JSONDecoder().decode(State.self, from: data) { + state = saved + if !state.enabled { state.events = []; state.profileID = nil } + } + } + + func setEnabled(_ enabled: Bool) { + guard enabled != state.enabled else { return } + state.enabled = enabled + if enabled { + state.profileID = UUID().uuidString + record(.observationStarted) + } else { + state.events = [] + state.profileID = nil + // A fresh explicit consent creates a new observation, never a new install claim. + state.cohort = "unknown" + save() + } + } + + func record(_ event: ProductEvent, at: Date = Date(), runtime: Double = 0, interaction: Double = 0) { + guard enabled, let profileID else { return } + let duration = runtime.isFinite ? min(300, max(0, runtime)) : 0 + let active = interaction.isFinite ? min(duration, max(0, interaction)) : 0 + state.events.append(AnalyticsEvent(id: UUID().uuidString, profileID: profileID, name: event, + occurredAt: at, cohort: cohort, + version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "development", + runtime: duration, interaction: active)) + // Keep the unsent cohort origin. Drop oldest routine events at the fixed disk limit. + while state.events.count > limit { + state.events.remove(at: state.events.first?.name == .observationStarted ? 1 : 0) + } + save() + } + + func acknowledge(_ id: String) { + state.events.removeAll { $0.id == id } + save() + } + + private func save() { + do { + try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true) + try JSONEncoder().encode(state).write(to: file, options: .atomic) + try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: file.path) + storageFailed = false + } catch { storageFailed = true } + } +} + +struct AnalyticsDuration: Equatable { + var runtime: Double + var interaction: Double + static let zero = Self(runtime: 0, interaction: 0) +} + +struct AnalyticsClock { + private var last: Double? + mutating func sample(uptime: Double, running: Bool, interacting: Bool) -> AnalyticsDuration { + defer { last = uptime } + guard let last, uptime >= last, uptime - last <= 15, running else { return .zero } + return .init(runtime: uptime - last, interaction: interacting ? uptime - last : 0) + } + mutating func reset() { last = nil } +} diff --git a/Sources/MacPulse/Analytics/ProductAnalytics.swift b/Sources/MacPulse/Analytics/ProductAnalytics.swift new file mode 100644 index 0000000..c5f5495 --- /dev/null +++ b/Sources/MacPulse/Analytics/ProductAnalytics.swift @@ -0,0 +1,219 @@ +import AppKit +import SwiftUI + +@MainActor +final class ProductAnalytics: ObservableObject { + static let shared = ProductAnalytics() + @Published private(set) var enabled = false + @Published private(set) var status = "产品统计默认关闭" + private let queue: AnalyticsOutbox + private let endpoint: URL? + private let clientID: String + private var timer: Timer? + private var task: Task? + private var observers: [NSObjectProtocol] = [] + private var lockObservers: [NSObjectProtocol] = [] + private var inputMonitor: Any? + private var clock = AnalyticsClock() + private var duration = AnalyticsDuration.zero + private var suspensions: Set = [] + private var suspended: Bool { !suspensions.isEmpty } + private var lastInput = ProcessInfo.processInfo.systemUptime + private var epoch = 0 + private var ticks = 0 + private var windows: [ObjectIdentifier: Surface] = [:] + private struct Surface { + weak var window: NSWindow? + let event: ProductEvent + var visible = false + } + var configured: Bool { endpoint != nil && !clientID.isEmpty } + var destination: String { endpoint?.host ?? "尚未配置统计服务器" } + + private init() { + let bundle = Bundle.main + let env = ProcessInfo.processInfo.environment + // Runtime overrides are restricted to loopback for local acceptance. + let bundleURL = (bundle.object(forInfoDictionaryKey: "TokenMiniAnalyticsURL") as? String).flatMap(URL.init(string:)) + let testURL = env["TOKENMINI_ANALYTICS_TEST_URL"].flatMap(URL.init(string:)) ?? ((bundle.bundleIdentifier?.hasSuffix(".analytics-test") ?? false) ? bundleURL : nil) + let local = testURL.map { ["localhost", "127.0.0.1", "::1"].contains($0.host ?? "") } ?? false + let candidate = local ? testURL : bundleURL + endpoint = candidate.flatMap { ($0.scheme == "https" || local) && $0.user == nil && $0.password == nil ? $0 : nil } + clientID = (local ? env["TOKENMINI_ANALYTICS_TEST_CLIENT_ID"] : nil) ?? bundle.object(forInfoDictionaryKey: "TokenMiniAnalyticsClientID") as? String ?? "" + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let cohort = UserDefaults.standard.bool(forKey: "macpulse.onboardingCompleted") ? "upgrade" : "unknown" + queue = AnalyticsOutbox(file: base.appendingPathComponent(local ? "TokenMini/analytics-test/outbox.json" : "TokenMini/analytics/outbox.json"), cohort: cohort) + enabled = queue.enabled && UserDefaults.standard.bool(forKey: consentKey) + if !enabled { queue.setEnabled(false) } + status = enabled ? "等待发送统计" : "产品统计默认关闭" + } + + private var consentKey: String { endpoint?.scheme == "http" ? "tokenmini.analytics.testConsent.v1" : "tokenmini.analytics.consent.v1" } + + func setEnabled(_ value: Bool) { + guard value != enabled else { return } + if value { + guard configured else { status = "尚未配置统计服务器"; return } + let alert = NSAlert() + alert.messageText = "帮助改进 TokenMini?" + alert.informativeText = "开启后向 \(destination) 发送随机安装标识、版本、面板打开次数和使用时长,用于活跃与留存分析。不会发送提示词、对话、项目名、文件路径、API Key 或用量明细。可随时关闭;关闭将清除待发送事件,已接收的数据不会自动删除。" + alert.addButton(withTitle: "同意并开启") + alert.addButton(withTitle: "暂不开启") + guard alert.runModal() == .alertFirstButtonReturn else { return } + } + epoch += 1 + task?.cancel(); task = nil + UserDefaults.standard.set(value, forKey: consentKey) + enabled = value + queue.setEnabled(value) + duration = .zero; clock.reset() + status = value ? "已开启,等待发送" : "已关闭,待发送事件已清除" + if queue.storageFailed { status = "本地统计存储失败;请保持关闭并重试" } + if value { queue.record(.appStart); flush() } + } + + func start() { + guard timer == nil else { return } + if enabled { queue.record(.appStart) } + inputMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown, .keyDown, .scrollWheel, .mouseMoved]) { [weak self] event in + self?.lastInput = ProcessInfo.processInfo.systemUptime + return event // Never inspect or retain key codes, characters or event contents. + } + let center = NSWorkspace.shared.notificationCenter + let pairs: [(String, Notification.Name, Notification.Name)] = [ + ("sleep", NSWorkspace.willSleepNotification, NSWorkspace.didWakeNotification), + ("session", NSWorkspace.sessionDidResignActiveNotification, NSWorkspace.sessionDidBecomeActiveNotification), + ("display", NSWorkspace.screensDidSleepNotification, NSWorkspace.screensDidWakeNotification) + ] + for (reason, pause, resume) in pairs { + for (name, value) in [(pause, true), (resume, false)] { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.pause(reason: reason, value: value) } + }) + } + } + for (name, value) in [("com.apple.screenIsLocked", true), ("com.apple.screenIsUnlocked", false)] { + lockObservers.append(DistributedNotificationCenter.default().addObserver(forName: Notification.Name(name), object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.pause(reason: "lock", value: value) } + }) + } + let timer = Timer(timeInterval: 5, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { self?.sample() } + } + timer.tolerance = 1 + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + flush() + } + + func register(_ window: NSWindow, event: ProductEvent) { + let id = ObjectIdentifier(window) + if windows[id] == nil { windows[id] = Surface(window: window, event: event) } + } + + private func pause(reason: String, value: Bool) { + persistDuration() + if value { suspensions.insert(reason) } else { suspensions.remove(reason) } + clock.reset() + } + + private func sample() { + guard enabled else { clock.reset(); return } + var visible = false + for id in Array(windows.keys) { + guard var surface = windows[id], let window = surface.window else { windows.removeValue(forKey: id); continue } + let showing = window.isVisible && !window.isMiniaturized && window.occlusionState.contains(.visible) && !suspended + if showing && !surface.visible { queue.record(surface.event); lastInput = ProcessInfo.processInfo.systemUptime } + surface.visible = showing; windows[id] = surface + visible = visible || showing + } + let uptime = ProcessInfo.processInfo.systemUptime + let elapsed = clock.sample(uptime: uptime, running: !suspended, + interacting: visible && NSApp.isActive && uptime - lastInput < 60) + duration.runtime += elapsed.runtime + duration.interaction += elapsed.interaction + ticks += 1 + if ticks % 6 == 0 { persistDuration(); flush() } + } + + private func persistDuration() { + if enabled && duration.runtime > 0 { queue.record(.engagement, runtime: duration.runtime, interaction: duration.interaction) } + duration = .zero + } + + func stop() { + persistDuration() + timer?.invalidate(); timer = nil + task?.cancel(); task = nil + if let inputMonitor { NSEvent.removeMonitor(inputMonitor) } + inputMonitor = nil + observers.forEach { NSWorkspace.shared.notificationCenter.removeObserver($0) } + observers = [] + lockObservers.forEach { DistributedNotificationCenter.default().removeObserver($0) } + lockObservers = [] + } + + private func flush() { + guard enabled, configured, let endpoint, task == nil else { return } + let generation = epoch + task = Task { [weak self] in + guard let self else { return } + defer { if self.epoch == generation { self.task = nil } } + // Sequential, bounded drain. The original ID and timestamp survive every retry. + for _ in 0..<50 { + guard self.enabled, self.epoch == generation, !Task.isCancelled, + let event = self.queue.events.first else { return } + do { + var request = URLRequest(url: endpoint.appendingPathComponent("track")) + request.httpMethod = "POST"; request.timeoutInterval = 10 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(self.clientID, forHTTPHeaderField: "openpanel-client-id") + request.setValue("tokenmini", forHTTPHeaderField: "openpanel-sdk-name") + request.httpBody = try event.openPanelBody() + let (_, response) = try await URLSession.shared.data(for: request) + guard self.enabled, self.epoch == generation, !Task.isCancelled else { return } + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + self.status = "服务器未接收,稍后重试"; return + } + self.queue.acknowledge(event.id) + self.status = "已连接 · 待发送 \(self.queue.events.count) 条" + } catch { + if self.enabled && self.epoch == generation { self.status = "暂时离线,事件留在本机等待补传" } + return + } + } + } + } +} + +private struct AnalyticsWindowReader: NSViewRepresentable { + let event: ProductEvent + final class Reader: NSView { + var event: ProductEvent = .menuViewed + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if let window { ProductAnalytics.shared.register(window, event: event) } + } + } + func makeNSView(context: Context) -> Reader { let view = Reader(); view.event = event; return view } + func updateNSView(_ nsView: Reader, context: Context) { nsView.event = event } +} + +extension View { + func analyticsSurface(_ event: ProductEvent) -> some View { + background(AnalyticsWindowReader(event: event).frame(width: 0, height: 0)) + } +} + +struct ProductAnalyticsSettings: View { + @ObservedObject private var analytics = ProductAnalytics.shared + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Toggle("帮助改进 TokenMini(可选)", isOn: Binding(get: { analytics.enabled }, set: { analytics.setEnabled($0) })) + .disabled(!analytics.configured && !analytics.enabled) + Text("仅发送随机标识、版本、面板打开次数与使用时长。不发送对话、项目或用量明细。") + Text(analytics.configured ? analytics.status : "统计服务尚未配置,本版本不会发送产品统计。") + .foregroundStyle(.secondary) + }.font(.system(size: 11)).fixedSize(horizontal: false, vertical: true) + } +} diff --git a/Sources/MacPulse/App.swift b/Sources/MacPulse/App.swift index b460f9f..5150b1b 100644 --- a/Sources/MacPulse/App.swift +++ b/Sources/MacPulse/App.swift @@ -17,6 +17,7 @@ struct MacPulseApp: App { var body: some Scene { MenuBarExtra { PopoverView() + .analyticsSurface(.menuViewed) .environmentObject(system) .environmentObject(usage) .environmentObject(cleanup) @@ -44,6 +45,7 @@ struct MacPulseApp: App { // 独立的 Token 监控台窗口(单实例);从弹窗「打开监控台」按钮开启 Window("TokenMini · Token 监控台", id: "dashboard") { DashboardRoot() + .analyticsSurface(.dashboardViewed) .environmentObject(usage) .environmentObject(system) .environmentObject(quota) @@ -54,6 +56,7 @@ struct MacPulseApp: App { Window("TokenMini · 清理与内存管理", id: "maintenance") { MaintenanceView() + .analyticsSurface(.maintenanceViewed) .environmentObject(cleanup) .environmentObject(system) .environmentObject(processActions) @@ -63,6 +66,7 @@ struct MacPulseApp: App { Window("社区排行", id: "leaderboard") { LeaderboardView() + .analyticsSurface(.rankingsViewed) .environmentObject(leaderboard) } .windowResizability(.contentMinSize) @@ -105,6 +109,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { RunLoop.main.add(guardTimer, forMode: .common) duplicateGuardTimer = guardTimer NotificationManager.shared.bootstrap() + ProductAnalytics.shared.start() showInstallLocationWarningIfNeeded() // 调试:MACPULSE_NOTIF_TEST 时 8 秒后发一条测试通知,验证 授权→排程→送达→刘海 整链 @@ -144,6 +149,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + ProductAnalytics.shared.stop() duplicateGuardTimer?.invalidate() if let duplicateLaunchObserver { NSWorkspace.shared.notificationCenter.removeObserver(duplicateLaunchObserver) @@ -198,7 +204,9 @@ private final class SingleInstanceGuard { func acquire() -> Bool { guard descriptor < 0 else { return true } - let path = "/private/tmp/macpulse-\(getuid()).lock" + // An explicitly repackaged local analytics fixture must not contend with the installed app. + let lockName = Bundle.main.bundleIdentifier == "com.liangheping.tokenmini.analytics-test" ? "tokenmini-analytics-test" : "macpulse" + let path = "/private/tmp/\(lockName)-\(getuid()).lock" let fd = open(path, O_CREAT | O_RDWR, S_IRUSR | S_IWUSR) guard fd >= 0 else { // 临时目录异常不应让 app 完全打不开;仅在明确拿不到锁时阻止重复实例。 diff --git a/Sources/MacPulse/UI/Dashboard/AppearanceSettingsPanel.swift b/Sources/MacPulse/UI/Dashboard/AppearanceSettingsPanel.swift index 3bdf889..3c71073 100644 --- a/Sources/MacPulse/UI/Dashboard/AppearanceSettingsPanel.swift +++ b/Sources/MacPulse/UI/Dashboard/AppearanceSettingsPanel.swift @@ -74,6 +74,7 @@ struct AppearanceSettingsPanel: View { duration: 6, sound: true) }.buttonStyle(PrismButtonStyle()) } + section("产品统计与隐私") { ProductAnalyticsSettings() } section("关于 TokenMini") { Text(AppInfo.displayVersion).font(.system(size: 12, weight: .medium)) HStack { diff --git a/Sources/MacPulse/UI/Dashboard/DashboardRoot.swift b/Sources/MacPulse/UI/Dashboard/DashboardRoot.swift index 8d2813f..f43808a 100644 --- a/Sources/MacPulse/UI/Dashboard/DashboardRoot.swift +++ b/Sources/MacPulse/UI/Dashboard/DashboardRoot.swift @@ -979,6 +979,7 @@ struct DisplaySettingsMenu: View { @EnvironmentObject private var quota: QuotaStore @Environment(\.openWindow) private var openWindow @State private var presented = false + @State private var analyticsPresented = false var body: some View { if settings.isPrism { Button { presented.toggle() } label: { @@ -988,7 +989,13 @@ struct DisplaySettingsMenu: View { .modifier(DashboardAppearanceChrome(unified: unifiedToolbar)) .accessibilityLabel("显示与主题设置") .popover(isPresented: $presented, arrowEdge: .bottom) { AppearanceSettingsPanel() } - } else { nativeMenu } + } else { nativeMenu.sheet(isPresented: $analyticsPresented) { + VStack(alignment: .leading, spacing: 16) { + Text("产品统计与隐私").font(.headline) + ProductAnalyticsSettings() + Button("完成") { analyticsPresented = false } + }.padding(24).frame(width: 380) + } } } private var nativeMenu: some View { @@ -1054,6 +1061,7 @@ struct DisplaySettingsMenu: View { duration: 6, sound: true) } Divider() + Button("产品统计与隐私…") { analyticsPresented = true } Button("社区排行榜…") { openWindow(id: "leaderboard") } Button("检查更新…") { updates.checkForUpdates() } .disabled(!updates.canCheckForUpdates) diff --git a/Sources/MacPulse/UI/Dashboard/MultiSelectFilter.swift b/Sources/MacPulse/UI/Dashboard/MultiSelectFilter.swift index 46ab06d..3ae7f27 100644 --- a/Sources/MacPulse/UI/Dashboard/MultiSelectFilter.swift +++ b/Sources/MacPulse/UI/Dashboard/MultiSelectFilter.swift @@ -18,9 +18,10 @@ struct MultiSelectFilter: View { var body: some View { Button { presented.toggle() } label: { ThemedMenuLabel(title: title, count: selection.count) + .modifier(ThemedMenuChrome(active: !selection.isEmpty)) + .contentShape(Rectangle()) } .buttonStyle(.plain) - .modifier(ThemedMenuChrome(active: !selection.isEmpty)) .fixedSize() .popover(isPresented: $presented, arrowEdge: .bottom) { VStack(alignment: .leading, spacing: 12) { diff --git a/Sources/MacPulse/UI/MenuBarLabel.swift b/Sources/MacPulse/UI/MenuBarLabel.swift index a28bf4b..cb30470 100644 --- a/Sources/MacPulse/UI/MenuBarLabel.swift +++ b/Sources/MacPulse/UI/MenuBarLabel.swift @@ -9,7 +9,8 @@ struct MenuBarLabel: View { @ObservedObject private var settings = DisplaySettings.shared var body: some View { - // Field widths depend only on user preferences, never live values, so the popover stays anchored. + // Loading uses a compact placeholder. After the first scan, field widths + // stay fixed across live value changes so the popover stays anchored. Image(nsImage: Self.renderLabel(fields: fields)) .accessibilityLabel(Text(accessibilityText)).help(accessibilityText) } @@ -17,7 +18,9 @@ struct MenuBarLabel: View { private var fields: [(symbol: String?, text: String, width: CGFloat)] { var result: [(String?, String, CGFloat)] = [] if settings.showTokensInMenuBar { - result.append((nil, usage.lastScan == nil ? "—" : MenuBarTokenFormatter.string(usage.today.totalTokens), 54)) + let loading = usage.lastScan == nil + result.append((nil, loading ? "—" : MenuBarTokenFormatter.string(usage.today.totalTokens), + loading ? 16 : 54)) } if settings.showCPUInMenuBar { result.append(("cpu", "\(cpuPercent)", 45)) } if settings.showMemoryInMenuBar { result.append(("memorychip", "\(memoryPercent)", 45)) } @@ -69,9 +72,20 @@ struct MenuBarLabel: View { private static func renderLabel(fields: [(symbol: String?, text: String, width: CGFloat)]) -> NSImage { let size = NSSize(width: 18 + fields.reduce(0) { $0 + $1.width + 5 }, height: 20) + // Keep the native canvas stable, but distribute unused trailing field space + // equally around the visible logo + values instead of leaving it all on the right. + let contentWidth: CGFloat + if let last = fields.last { + let symbolWidth: CGFloat = last.symbol == nil ? 0 : 17 + let textWidth = fittedText(last.text, maxWidth: last.width - symbolWidth).size().width + contentWidth = size.width - last.width + symbolWidth + textWidth + } else { + contentWidth = 16 + } + let leadingInset = max(0, (size.width - contentWidth) / 2) let image = NSImage(size: size, flipped: false) { rect in - BrandImages.menuBar?.draw(in: NSRect(x: 0, y: 2, width: 16, height: 16)) - var x: CGFloat = 23 + BrandImages.menuBar?.draw(in: NSRect(x: leadingInset, y: 2, width: 16, height: 16)) + var x: CGFloat = leadingInset + 23 for field in fields { if let symbol = field.symbol { drawSymbol(symbol, x: x, in: rect) @@ -102,19 +116,23 @@ struct MenuBarLabel: View { x: CGFloat, maxWidth: CGFloat, in rect: NSRect) { + let attributed = fittedText(text, maxWidth: maxWidth) + let textSize = attributed.size() + attributed.draw(at: NSPoint(x: x, + y: rect.minY + max(0, (rect.height - textSize.height) / 2))) + } + + private static func fittedText(_ text: String, maxWidth: CGFloat) -> NSAttributedString { let baseSize: CGFloat = 13 let baseFont = NSFont.monospacedDigitSystemFont(ofSize: baseSize, weight: .medium) let baseWidth = NSAttributedString(string: text, attributes: [.font: baseFont]).size().width let fittedSize = baseWidth > maxWidth ? max(10, baseSize * maxWidth / baseWidth) : baseSize - let attributed = NSAttributedString(string: text, attributes: [ + return NSAttributedString(string: text, attributes: [ .font: NSFont.monospacedDigitSystemFont(ofSize: fittedSize, weight: .medium), .foregroundColor: NSColor.black ]) - let textSize = attributed.size() - attributed.draw(at: NSPoint(x: x, - y: rect.minY + max(0, (rect.height - textSize.height) / 2))) } /// 人民币 1 万以内直接显示完整整数(¥5700),避免菜单栏里的 K 需要二次换算。 diff --git a/docs/simple-metrics.md b/docs/simple-metrics.md new file mode 100644 index 0000000..127c18d --- /dev/null +++ b/docs/simple-metrics.md @@ -0,0 +1,23 @@ +# 免费统计与迁移约定 + +下载使用 GitHub Release asset 的 `download_count`。运行 `scripts/查看下载统计.command` 获取实时快照,输出保存在被 Git 忽略的 `dist/metrics/`,包括 HTML、JSON、CSV 和历史快照。统计只包含公开安装 DMG;TokenMini 和旧名 MacPulse 分开,校验文件、源码压缩包和调试符号不计入。下载次数无法推导人数或安装数。 + +0.14.0 保留默认关闭、需明确同意的匿名产品统计客户端。正式接收服务尚未配置时,设置中禁用开关并说明原因,不发送统计,也不把缺失的日活/周活标为零。GitHub 不提供应用日活/周活;不要把仓库访问量当成应用活跃量。 + +免费服务接入目标为 Cloudflare Workers + D1;启用需要运营者登录并确认免费套餐,不创建收费资源。接入前必须验证真实客户端 → HTTPS 接收 → 持久化 → 私有报表完整链路,再配置正式包。客户端只带公开写入标识,读取密钥只放服务端。免费额度用尽时统计暂停,不自动升级收费,不影响应用本地功能。 + +## 稳定口径 + +- 日活:北京时间当天启动应用或实际与面板交互的、自愿参与统计的随机设备标识去重数。 +- 周活:含当天的最近 7 个北京时间自然日中,上述标识去重数;不能相加每日人数。 +- 后台驻留时长独立于前台交互时长,不能仅因后台计时就算作活跃。 +- 设备标识不是账号或自然人。卸载重装、关闭后重新同意可能产生新标识;不能推导总安装人数。 +- 测试与生产、不同产品必须隔离;健康状态必须区分未接入、无样本、接收失败和真实的零。 + +## 可迁移接口 + +客户端事件固定白名单,不允许自定义自由文本:产品、平台、版本、随机标识、事件 ID、原始时间、启用来源、运行/交互时长。禁止聊天、提示词、文件名/路径、项目、账号、Token 明细、密钥和设备指纹。接收方必须拒绝多余字段、限制体积/请求频率、按事件 ID 去重,并保留原始时间处理离线重传。 + +日活服务只需保存有期限的去重设备日记录和聚合数;接入时明确保留期及删除方式。SQL/CSV/JSON 导出使用 `schema_version`, `product`, `environment`, `day`, `metric`, `value`, `observed_at`。采集协议与报表适配器分离,未来可以接 OpenPanel 或自建服务,不需要替换应用业务层。 + +公开仓库只保留代码、数据结构与使用说明。数据库、匿名标识明细、生产报表、管理员凭据与本机测试截图全部私存。不要把 GitHub Issues、公开仓库提交或 Actions 日志用作用户事件数据库。 diff --git a/release-notes/0.14.0.md b/release-notes/0.14.0.md new file mode 100644 index 0000000..84daeaa --- /dev/null +++ b/release-notes/0.14.0.md @@ -0,0 +1,12 @@ +# TokenMini 0.14.0 Public Beta + +- 菜单栏图标与 Token 数值的左右留白更均衡;首次加载时使用紧凑占位。 +- 多选筛选按钮整块区域都可点击,包含背景、箭头和留白。 +- 保留 Prism 统一外观、完整中控台入口、多工具与多模型筛选及签名模型目录更新。 +- 增加可选匿名产品统计基础,默认关闭。本次未配置正式接收服务,因此不上传产品统计,日活/周活尚未启用。 +- 新增免费 GitHub 安装包下载报表,可导出 JSON/CSV,便于后期迁移。下载次数不代表用户数。 +- 发布包移除编译机路径与调试记录,并加入安装包隐私检查,调试符号仅在本机私存。 + +Apple Silicon · macOS 14+ · Build 23。费用是 API 等价估算,不是订阅实际扣款。 + +Balances menu-bar spacing and makes the full multi-select button clickable. Optional analytics remains off and unconfigured in this build. Free GitHub installer-download reporting is available in the source tools. diff --git a/scripts/build.sh b/scripts/build.sh index d09507c..23c9eee 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -16,8 +16,8 @@ DISPLAY_NAME="TokenMini" BUNDLE_ID="com.liangheping.macpulse" BUILD_DIR=".build/release" APP_DIR="dist/${APP_NAME}.app" -VERSION="${1:-0.13.1}" -BUILD_NUMBER="${2:-22}" +VERSION="${1:-0.14.0}" +BUILD_NUMBER="${2:-23}" SITE_URL="${MACPULSE_SITE_URL:-https://tokenmini.cc}" UPDATE_FEED_URL="${MACPULSE_UPDATE_FEED_URL:-${SITE_URL}/appcast.xml}" SOURCE_URL="${MACPULSE_SOURCE_URL:-https://github.com/ai798-Lab/TokenMini}" @@ -112,6 +112,30 @@ cat > "${APP_DIR}/Contents/Info.plist" << PLIST PLIST +# Optional release configuration. No endpoint means no analytics traffic. +# Only a public write client ID is allowed. Collector-to-engine credentials stay on the server. +if [[ -n "${TOKENMINI_ANALYTICS_CONFIG:-}" ]]; then + python3 - "${TOKENMINI_ANALYTICS_CONFIG}" "${APP_DIR}/Contents/Info.plist" <<'PY' +import json, plistlib, sys +from urllib.parse import urlparse +from pathlib import Path +config = json.loads(Path(sys.argv[1]).read_text()) +if set(config) - {"url", "clientId"}: + raise SystemExit("Unexpected analytics configuration fields") +url = urlparse(config.get("url", "")) +if url.scheme != "https" or not url.hostname or url.username or url.password or url.query or url.fragment: + raise SystemExit("Release analytics must use a clean HTTPS endpoint") +if not isinstance(config.get("clientId"), str) or not config["clientId"].strip(): + raise SystemExit("Missing project write client ID") +p = Path(sys.argv[2]); info = plistlib.loads(p.read_bytes()) +for source, target in [("url", "TokenMiniAnalyticsURL"), ("clientId", "TokenMiniAnalyticsClientID")]: + value = config.get(source, "") + if not isinstance(value, str): raise SystemExit("Analytics values must be strings") + info[target] = value +p.write_bytes(plistlib.dumps(info)) +PY +fi + # 图标:设计源文件(logo icon.svg)比 icns 新就自动重新生成。 # 没这一步的话,改了设计稿、构建出来的还是旧图标,看着像"图标缓存没刷新",实际是根本没重新生成过。 if [ -f "logo icon.svg" ] && [ -f "scripts/make-icon.swift" ]; then @@ -140,6 +164,11 @@ cp "Resources/Prism/PrismCore.png" "${APP_DIR}/Contents/Resources/" ditto "${BUILD_DIR}/MacPulse_MacPulse.bundle" "${APP_DIR}/Contents/Resources/MacPulse_MacPulse.bundle" ditto "Resources/ThirdParty" "${APP_DIR}/Contents/Resources/ThirdParty" +# Keep the unstripped executable in the ignored build directory for private dSYM. +# Object-file debug records include build-machine paths and must not be shipped. +strip -S "${APP_DIR}/Contents/MacOS/${EXECUTABLE_NAME}" +python3 scripts/check-bundle-privacy.py "${APP_DIR}" + echo "==> ad-hoc 签名" codesign --force -s - "${APP_DIR}" codesign --verify --strict --deep "${APP_DIR}" diff --git a/scripts/check-bundle-privacy.py b/scripts/check-bundle-privacy.py new file mode 100644 index 0000000..9ac872b --- /dev/null +++ b/scripts/check-bundle-privacy.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Fail closed on local home paths or private operational files in a release bundle.""" +import sys +from pathlib import Path + +bundle = Path(sys.argv[1]) +needle = str(Path.home()).encode() +failures = [] +for p in bundle.rglob('*'): + if not p.is_file() or p.is_symlink(): + continue + if needle in p.read_bytes() or p.suffix in {'.sqlite', '.db', '.log'} or p.name.startswith('.env'): + failures.append(str(p.relative_to(bundle))) +if failures: + print('Release privacy check failed (paths only):\n' + '\n'.join(failures), file=sys.stderr) + sys.exit(1) +print('Release bundle privacy check passed.') diff --git a/scripts/download-metrics.py b/scripts/download-metrics.py new file mode 100755 index 0000000..f80e604 --- /dev/null +++ b/scripts/download-metrics.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Private local report from public GitHub counters. No analytics identity is published.""" +import argparse +import csv +import html +import json +import re +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo + +REPOSITORY = 'ai798-Lab/TokenMini' + + +def summarize(releases): + assets = [] + for release in releases: + if release.get('draft'): + continue + for asset in release.get('assets', []): + if not re.fullmatch(r'(TokenMini|MacPulse)-\d+\.\d+\.\d+\.dmg', asset['name']): + continue + assets.append({'release': release['tag_name'], 'asset_id': asset['id'], + 'asset': asset['name'], 'downloads': asset['download_count'], + 'product': 'tokenmini' if asset['name'].startswith('TokenMini') else 'macpulse-legacy'}) + return {'schema_version': 1, 'repository': REPOSITORY, + 'observed_at': datetime.now(ZoneInfo('Asia/Shanghai')).isoformat(), + 'metric': 'github_release_installer_downloads', + 'total_downloads': sum(a['downloads'] for a in assets), + 'tokenmini_downloads': sum(a['downloads'] for a in assets if a['product'] == 'tokenmini'), + 'legacy_downloads': sum(a['downloads'] for a in assets if a['product'] == 'macpulse-legacy'), + 'assets': assets, + 'active_devices': {'status': 'not_connected', 'dau': None, 'wau': None}} + + +def render(report): + rows = ''.join(f'{html.escape(a["release"])}{html.escape(a["asset"])}{a["downloads"]}' for a in report['assets']) + return f''' +TokenMini 下载统计 +

TokenMini 下载统计

采集时间:{html.escape(report['observed_at'])} · 来源:GitHub Releases

+
TokenMini 下载次数{report['tokenmini_downloads']}
旧版 MacPulse{report['legacy_downloads']}
全部安装包{report['total_downloads']}
+

这些是累计下载次数,包含重复下载、升级及发布验收下载;不代表人数、安装数或活跃用户。校验文件和调试文件不计入。

+{rows}
版本安装包下载次数
+

日活 / 周活

尚未接通正式活跃统计服务。这里不以 0 代替未知数据,也不从下载量估算活跃人数。接通后仅统计主动同意的匿名设备:日活按北京时间当天去重,周活按含当天的最近 7 天去重。
+

重新运行“查看下载统计.command”可获取最新数值。旁边的 JSON / CSV 为可迁移的数据快照;此页面保存在本机。

''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', default=str(Path(__file__).resolve().parents[1] / 'dist/metrics')) + args = parser.parse_args() + result = subprocess.run(['gh', 'api', '--paginate', '--slurp', f'repos/{REPOSITORY}/releases?per_page=100'], capture_output=True, text=True, timeout=90) + if result.returncode: + raise SystemExit('无法获取 GitHub 下载量;保留旧报表。请检查 gh 登录和网络。') + report = summarize([release for page in json.loads(result.stdout) for release in page]) + output = Path(args.output) + output.mkdir(parents=True, exist_ok=True) + (output / 'downloads.json').write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + (output / 'index.html').write_text(render(report)) + with (output / 'downloads.csv').open('w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['release', 'asset_id', 'asset', 'downloads', 'product']) + writer.writeheader() + writer.writerows(report['assets']) + snapshot = output / 'snapshots' / (datetime.now(ZoneInfo('Asia/Shanghai')).strftime('%Y%m%d-%H%M%S') + '.json') + snapshot.parent.mkdir(exist_ok=True) + snapshot.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n') + print(f"TokenMini {report['tokenmini_downloads']} 次;旧版 {report['legacy_downloads']} 次;合计 {report['total_downloads']} 次。") + print(output / 'index.html') + + +if __name__ == '__main__': + main() diff --git a/scripts/release.sh b/scripts/release.sh index 81572b4..530598c 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -99,7 +99,7 @@ DSYM_DIR="${PRIVATE_DIR}/${APP_NAME}-${VERSION}.dSYM" DSYM_ZIP="${PRIVATE_DIR}/${APP_NAME}-${VERSION}-dSYM.zip" rm -rf "${DSYM_DIR}" rm -f "${DSYM_ZIP}" -dsymutil "${APP_DIR}/Contents/MacOS/${EXECUTABLE_NAME}" -o "${DSYM_DIR}" +dsymutil ".build/release/${EXECUTABLE_NAME}" -o "${DSYM_DIR}" ditto -c -k --keepParent "${DSYM_DIR}" "${DSYM_ZIP}" if [ "${SKIP_NOTARIZE}" != "1" ]; then diff --git "a/scripts/\346\237\245\347\234\213\344\270\213\350\275\275\347\273\237\350\256\241.command" "b/scripts/\346\237\245\347\234\213\344\270\213\350\275\275\347\273\237\350\256\241.command" new file mode 100755 index 0000000..1d976d3 --- /dev/null +++ "b/scripts/\346\237\245\347\234\213\344\270\213\350\275\275\347\273\237\350\256\241.command" @@ -0,0 +1,5 @@ +#!/bin/zsh +set -euo pipefail +cd "$(dirname "$0")/.." +python3 scripts/download-metrics.py +open dist/metrics/index.html diff --git a/site/appcast.xml b/site/appcast.xml index 65b50c5..cc4e3a5 100644 --- a/site/appcast.xml +++ b/site/appcast.xml @@ -4,25 +4,30 @@ IMPORTANT: This file was signed by Sparkle. Any modifications to this file requi TokenMini - 0.13.1 - Wed, 23 Sep 2026 22:57:04 +0800 + 0.14.0 + Thu, 24 Sep 2026 13:13:08 +0800 https://tokenmini.cc - 22 - 0.13.1 + 23 + 0.14.0 14.0 arm64 - - + diff --git a/site/index.html b/site/index.html index 0d25e88..1f1ec1f 100644 --- a/site/index.html +++ b/site/index.html @@ -33,7 +33,7 @@ -

TOKENMINI

SMALL APP.
BIG
CLARITY.

TOKEN × AI × MACEvery token. In view.FREE AI USAGE MONITOR

FREE AI USAGE MONITOR FOR MAC

Your usage.
In plain sight.

AI usage and Mac vitals. Right in your menu bar.

Download TokenMini v0.13.1

Apple Silicon · macOS 14+ · MIT open source

Processed locally. Nothing leaves your Mac.

01 / TOTAL VISIBILITY

AI usage.
Out of the dark.

One glance. Then back to creating.
A clear view of your usage, in a tiny menu bar app.

01

TOKEN INTELLIGENCE

Every token, accounted for.

Understand Claude Code and Codex usage by tool, model, and project. See input, output, cache, and API-equivalent cost at a glance.

CLAUDE CODECODEXLOCAL FIRST
02

QUOTA AWARENESS

Stay focused. Know your limits.

Keep remaining quota, reset times, and optional limit alerts in view. Focus on your work and know when your allowance resets.

QUOTA WINDOWSRESET COUNTDOWNOPTIONAL ALERTS
03

SYSTEM PULSE

Your Mac, in the picture.

CPU, memory, network, and disk, live in your menu bar. Choose your theme and see both AI usage and system activity.

HUDLEDCLASSIC
KEEP GOING

02 / STAY IN CONTROL

Create freely.
Stay in control.

Keep ideas flowing and usage clear.
From today’s tokens to your next reset.

CLAUDE CODE + CODEXMONITOR. UNDERSTAND. CREATE.

03 / LOCAL BY DEFAULT

More possibility.
Your privacy.

Your conversations.
On your Mac.

No account is needed for local monitoring. TokenMini reads session logs on your Mac and calculates usage locally. Your prompts and conversations are never uploaded.

TokenMini periodically downloads a signed model pricing catalog from the official website without uploading conversations or usage. Software updates, Claude quota checks, and Skill installation use the network as needed. Community rankings are currently unavailable.

Read the full privacy policy

SMALL APP. BIG CLARITY.

Ready for
a clearer view?

Download TokenMini v0.13.1

TokenMini 0.13.1 Public Beta · Build 22 · Formerly MacPulse
Apple Silicon · macOS 14+ · Free and open source

  1. 01

    Download

    Get the DMG installer from GitHub Releases.

  2. 02

    Move to Applications

    Drag TokenMini into Applications, then open it.

  3. 03

    Start in the menu bar

    Choose your theme and alerts. Your usage is read locally.

Costs are API-equivalent estimates, not actual subscription charges.

+

TOKENMINI

SMALL APP.
BIG
CLARITY.

TOKEN × AI × MACEvery token. In view.FREE AI USAGE MONITOR

FREE AI USAGE MONITOR FOR MAC

Your usage.
In plain sight.

AI usage and Mac vitals. Right in your menu bar.

Download TokenMini v0.14.0

Apple Silicon · macOS 14+ · MIT open source

Processed locally. Nothing leaves your Mac.

01 / TOTAL VISIBILITY

AI usage.
Out of the dark.

One glance. Then back to creating.
A clear view of your usage, in a tiny menu bar app.

01

TOKEN INTELLIGENCE

Every token, accounted for.

Understand Claude Code and Codex usage by tool, model, and project. See input, output, cache, and API-equivalent cost at a glance.

CLAUDE CODECODEXLOCAL FIRST
02

QUOTA AWARENESS

Stay focused. Know your limits.

Keep remaining quota, reset times, and optional limit alerts in view. Focus on your work and know when your allowance resets.

QUOTA WINDOWSRESET COUNTDOWNOPTIONAL ALERTS
03

SYSTEM PULSE

Your Mac, in the picture.

CPU, memory, network, and disk, live in your menu bar. Choose your theme and see both AI usage and system activity.

HUDLEDCLASSIC
KEEP GOING

02 / STAY IN CONTROL

Create freely.
Stay in control.

Keep ideas flowing and usage clear.
From today’s tokens to your next reset.

CLAUDE CODE + CODEXMONITOR. UNDERSTAND. CREATE.

03 / LOCAL BY DEFAULT

More possibility.
Your privacy.

Your conversations.
On your Mac.

No account is needed for local monitoring. TokenMini reads session logs on your Mac and calculates usage locally. Your prompts and conversations are never uploaded.

TokenMini periodically downloads a signed model pricing catalog from the official website without uploading conversations or usage. Software updates, Claude quota checks, and Skill installation use the network as needed. Community rankings are currently unavailable.

Read the full privacy policy

SMALL APP. BIG CLARITY.

Ready for
a clearer view?

Download TokenMini v0.14.0

TokenMini 0.14.0 Public Beta · Build 23 · Formerly MacPulse
Apple Silicon · macOS 14+ · Free and open source

  1. 01

    Download

    Get the DMG installer from GitHub Releases.

  2. 02

    Move to Applications

    Drag TokenMini into Applications, then open it.

  3. 03

    Start in the menu bar

    Choose your theme and alerts. Your usage is read locally.

Costs are API-equivalent estimates, not actual subscription charges.

diff --git a/site/privacy/index.html b/site/privacy/index.html index a5ebfb1..4a79234 100644 --- a/site/privacy/index.html +++ b/site/privacy/index.html @@ -33,7 +33,7 @@ -

PRIVACY · 2026-09-23

Privacy policy

TokenMini (formerly MacPulse) is a local-first, open-source macOS app. By default, there are no accounts, product analytics, advertising trackers, or automatic crash reports. Community rankings are currently offline: no ranking service is running, sign-in is unavailable, and nothing is synced. The sections below describe how rankings would work if the feature is restored.

Data that stays on your Mac by default

  • System metrics: CPU, memory, network, disk, battery, temperature, fan, and process information.
  • AI session statistics: local Claude Code and Codex JSONL logs are scanned read-only and aggregated on your Mac.
  • Skills: relevant directories are read or modified only when you actively use the management features.

Conversation content, prompts, responses, project names, paths, model breakdowns, session IDs, Claude/Codex credentials, API keys, system processes, file names, and device serial numbers are not uploaded to the rankings service.

Google sign-in and community rankings

All local features work without signing in, and public rankings can be browsed anonymously. Selecting “Sign in with Google and join” enrolls you in both weekly rankings (tokens and API-equivalent cost) and stores:

  • Your Google account identifier, email address, and display name. Your email is not shown publicly.
  • Your display-name preference. By default, all but the last character are masked.
  • Daily token totals and API-equivalent cost totals in the Shanghai time zone, the pricing-table version, and your TokenMini version.
  • Join, leave, and last-sync timestamps, plus necessary security audit records.

Sign-in tokens are stored only in macOS Keychain. Leaving the rankings stops syncing, deletes daily ranking totals and server refresh sessions, and clears local ranking credentials. Local monitoring is unaffected.

Other network requests

  • At startup and periodically while running, TokenMini downloads a signed model pricing catalog from tokenmini.cc. This request does not include conversation content, usage totals, or account credentials.
  • Enabling Claude quota checks requests Anthropic’s usage endpoint. A failure of this experimental feature does not affect other features.
  • Update checks read the official HTTPS appcast. Signed installers are downloaded from GitHub Releases.
  • Installing a Skill downloads content from the GitHub repository you specify.
  • Opening community rankings fetches the public list. The daily totals described above are uploaded only after you sign in and join.

Your controls

Notifications and Claude quota checks are off by default. You can leave the rankings at any time. Uninstalling TokenMini does not delete local Claude Code, Codex, or session files.

Report privacy and security concerns via GitHub Issues . For credentials or vulnerability details, follow the security policy to submit privately.

+

PRIVACY · 2026-09-24

Privacy policy

TokenMini (formerly MacPulse) is a local-first, open-source macOS app. By default, there are no accounts, product analytics, advertising trackers, or automatic crash reports. Community rankings are currently offline: no ranking service is running, sign-in is unavailable, and nothing is synced. The sections below describe how rankings would work if the feature is restored.

Data that stays on your Mac by default

  • System metrics: CPU, memory, network, disk, battery, temperature, fan, and process information.
  • AI session statistics: local Claude Code and Codex JSONL logs are scanned read-only and aggregated on your Mac.
  • Skills: relevant directories are read or modified only when you actively use the management features.

Conversation content, prompts, responses, project names, paths, model breakdowns, session IDs, Claude/Codex credentials, API keys, system processes, file names, and device serial numbers are not uploaded to the rankings service.

Google sign-in and community rankings

All local features work without signing in, and public rankings can be browsed anonymously. Selecting “Sign in with Google and join” enrolls you in both weekly rankings (tokens and API-equivalent cost) and stores:

  • Your Google account identifier, email address, and display name. Your email is not shown publicly.
  • Your display-name preference. By default, all but the last character are masked.
  • Daily token totals and API-equivalent cost totals in the Shanghai time zone, the pricing-table version, and your TokenMini version.
  • Join, leave, and last-sync timestamps, plus necessary security audit records.

Sign-in tokens are stored only in macOS Keychain. Leaving the rankings stops syncing, deletes daily ranking totals and server refresh sessions, and clears local ranking credentials. Local monitoring is unaffected.

Optional anonymous product analytics

Version 0.14.0 includes an opt-in analytics foundation. This release has no configured collection service: analytics cannot be enabled and no product events are uploaded. This website has no visitor tracking script.

A future configured release requires your explicit consent before sending a random device identifier, app version, panel views, and runtime/interaction duration. Conversations, prompts, projects, paths, accounts, secrets and token details are excluded. Turning analytics off clears queued events and the local random identifier. GitHub download counts measure installer downloads, not people, installs or active users.

Other network requests

  • At startup and periodically while running, TokenMini downloads a signed model pricing catalog from tokenmini.cc. This request does not include conversation content, usage totals, or account credentials.
  • Enabling Claude quota checks requests Anthropic’s usage endpoint. A failure of this experimental feature does not affect other features.
  • Update checks read the official HTTPS appcast. Signed installers are downloaded from GitHub Releases.
  • Installing a Skill downloads content from the GitHub repository you specify.
  • Opening community rankings fetches the public list. The daily totals described above are uploaded only after you sign in and join.

Your controls

Notifications and Claude quota checks are off by default. You can leave the rankings at any time. Uninstalling TokenMini does not delete local Claude Code, Codex, or session files.

Report privacy and security concerns via GitHub Issues . For credentials or vulnerability details, follow the security policy to submit privately.

diff --git a/site/release.json b/site/release.json index ec68499..92e5428 100644 --- a/site/release.json +++ b/site/release.json @@ -1,7 +1,7 @@ { - "version": "0.13.1", - "build": 22, + "version": "0.14.0", + "build": 23, "channel": "beta", - "download": "https://github.com/ai798-Lab/TokenMini/releases/download/v0.13.1/TokenMini-0.13.1.dmg", - "sha256": "8f2ef2ecc25e48d74e07c3e3aead3b81f620104cb26b22dfecc714725ea96641" + "download": "https://github.com/ai798-Lab/TokenMini/releases/download/v0.14.0/TokenMini-0.14.0.dmg", + "sha256": "074522ae29f87a190c02f0f544e0fa6363292448f4d80f3d9c46db26f95c3d38" } diff --git a/site/zh/index.html b/site/zh/index.html index 012f6a5..39f83ca 100644 --- a/site/zh/index.html +++ b/site/zh/index.html @@ -33,7 +33,7 @@ -

TOKENMINI

SMALL APP.
BIG
CLARITY.

TOKEN × AI × MAC让每一枚 Token,都心中有数。FREE AI USAGE MONITOR

免费 Mac AI 用量监控工具

让消耗,
看得见。

AI 用量与 Mac 状态,尽在菜单栏。

下载 TokenMini v0.13.1

Apple Silicon · macOS 14+ · MIT 开源

全部本地处理,数据不离开你的 Mac。

01 / 一眼看清

大模型的消耗,
不再是黑箱。

一眼看清,放手去做。
把繁杂的数据,放进小小菜单栏。

01

Token 用量分析

每一枚,都有迹可循。

Claude Code 与 Codex,按工具、模型和项目看清 Token 构成。输入、输出、缓存,以及 API 等价费用,一目了然。

CLAUDE CODECODEX本地优先
02

额度感知

尽兴投入,心中有数。

剩余额度、重置时间和接近上限的提醒,都在眼前。专注正在做的事,也知道下一次满血是什么时候。

额度窗口重置倒计时按需提醒
03

系统脉搏

你的 Mac,全程在线。

CPU、内存、网络与磁盘,实时尽在菜单栏。多套主题,自由切换。从 AI 消耗到机器状态,都能看见。

HUDLED经典
继续了解

02 / 掌控消耗

火力全开。
掌控消耗。

让灵感继续,让用量清楚。
从今天的消耗,到下一次重置。

CLAUDE CODE + CODEX观察。理解。创造。

03 / 默认本地处理

能力放开。
隐私守住。

你的会话,
留在你的 Mac。

本地监控默认不需要账号。TokenMini 读取本机的会话记录,在本地汇总用量;不会上传你的提示词或对话正文。

TokenMini 默认定期从官网获取签名模型价格目录,不上传会话或用量。软件更新、Claude 额度和 Skill 安装会按功能需要联网;社区排行榜目前未开放。

阅读完整隐私说明

小巧工具,清晰掌控。

现在,
看个清楚。

下载 TokenMini v0.13.1

TokenMini 0.13.1 Public Beta · Build 22 · 原名 MacPulse
Apple Silicon · macOS 14+ · 免费开源

  1. 01

    下载

    从 GitHub Releases 下载 DMG 安装包。

  2. 02

    拖入应用程序

    将 TokenMini 拖入 Applications 后打开。

  3. 03

    从菜单栏开始

    选择主题和提醒偏好,本地读取你的用量。

费用显示为 API 等价估算,不是订阅实际扣款。

+

TOKENMINI

SMALL APP.
BIG
CLARITY.

TOKEN × AI × MAC让每一枚 Token,都心中有数。FREE AI USAGE MONITOR

免费 Mac AI 用量监控工具

让消耗,
看得见。

AI 用量与 Mac 状态,尽在菜单栏。

下载 TokenMini v0.14.0

Apple Silicon · macOS 14+ · MIT 开源

全部本地处理,数据不离开你的 Mac。

01 / 一眼看清

大模型的消耗,
不再是黑箱。

一眼看清,放手去做。
把繁杂的数据,放进小小菜单栏。

01

Token 用量分析

每一枚,都有迹可循。

Claude Code 与 Codex,按工具、模型和项目看清 Token 构成。输入、输出、缓存,以及 API 等价费用,一目了然。

CLAUDE CODECODEX本地优先
02

额度感知

尽兴投入,心中有数。

剩余额度、重置时间和接近上限的提醒,都在眼前。专注正在做的事,也知道下一次满血是什么时候。

额度窗口重置倒计时按需提醒
03

系统脉搏

你的 Mac,全程在线。

CPU、内存、网络与磁盘,实时尽在菜单栏。多套主题,自由切换。从 AI 消耗到机器状态,都能看见。

HUDLED经典
继续了解

02 / 掌控消耗

火力全开。
掌控消耗。

让灵感继续,让用量清楚。
从今天的消耗,到下一次重置。

CLAUDE CODE + CODEX观察。理解。创造。

03 / 默认本地处理

能力放开。
隐私守住。

你的会话,
留在你的 Mac。

本地监控默认不需要账号。TokenMini 读取本机的会话记录,在本地汇总用量;不会上传你的提示词或对话正文。

TokenMini 默认定期从官网获取签名模型价格目录,不上传会话或用量。软件更新、Claude 额度和 Skill 安装会按功能需要联网;社区排行榜目前未开放。

阅读完整隐私说明

小巧工具,清晰掌控。

现在,
看个清楚。

下载 TokenMini v0.14.0

TokenMini 0.14.0 Public Beta · Build 23 · 原名 MacPulse
Apple Silicon · macOS 14+ · 免费开源

  1. 01

    下载

    从 GitHub Releases 下载 DMG 安装包。

  2. 02

    拖入应用程序

    将 TokenMini 拖入 Applications 后打开。

  3. 03

    从菜单栏开始

    选择主题和提醒偏好,本地读取你的用量。

费用显示为 API 等价估算,不是订阅实际扣款。

diff --git a/site/zh/privacy/index.html b/site/zh/privacy/index.html index 4c93469..01b4ee5 100644 --- a/site/zh/privacy/index.html +++ b/site/zh/privacy/index.html @@ -33,7 +33,7 @@ -

隐私 · 2026-09-23

隐私说明

TokenMini(原名 MacPulse)是本地优先的开源 macOS 应用。默认没有账号、产品分析、广告追踪或自动崩溃上报;社区排行榜当前未开放:没有在运行的排行服务,无法登录,也不会同步任何数据。下文中关于排行榜的说明,仅在该功能恢复后适用。

默认留在本机的数据

  • 系统指标:CPU、内存、网络、磁盘、电池、温度、风扇与进程信息。
  • AI 会话统计:只读扫描本机 Claude Code 与 Codex 会话 JSONL,并在本地聚合。
  • Skills:只有用户主动使用管理功能时,才读取或修改相应目录。

会话正文、提示词、回复内容、项目名、路径、模型明细、session ID、Claude/Codex 凭证、API Key、系统进程、文件名和设备序列号都不会上传到排行榜服务。

Google 登录与社区排行

不登录时,所有本地功能均可正常使用,也可以匿名浏览公开榜单。用户点击“使用 Google 登录并加入”后,会自动加入 Token 与 API 等价费用两个本周榜单,并保存:

  • Google 提供的账号标识、邮箱和昵称;邮箱不会公开显示。
  • 昵称显示方式;默认打码,只保留最后一个字符。
  • 上海时区每日 Token 总量、API 等价费用汇总、价格表版本和 TokenMini 版本。
  • 加入时间、退出时间、最后同步时间和必要的安全审计记录。

登录令牌只保存在 macOS Keychain。退出排行榜后会停止同步、删除每日排行汇总和服务端刷新会话,并清除本机排行榜凭证;本地监控不受影响。

其他网络请求

  • 应用启动后及运行期间,默认定期从 tokenmini.cc 获取经过签名验证的模型价格目录;此请求不包含会话内容、用量汇总或账号凭证。
  • 主动开启 Claude 额度时,请求 Anthropic 用量端点;该实验功能失败不会影响其他功能。
  • 检查更新时读取官方 HTTPS appcast,并从 GitHub Releases 下载签名安装包。
  • 主动安装 Skill 时,从用户指定的 GitHub 仓库下载内容。
  • 打开社区排行榜时读取公开榜单;只有登录加入后才上传上述每日汇总。

你的控制权

通知和 Claude 额度均默认关闭。排行榜可以随时退出;卸载 TokenMini 不会删除本机 Claude Code、Codex 或用户会话文件。

隐私与安全问题可通过 GitHub Issues 反馈;包含凭证或漏洞的信息请按 安全政策 私密提交。

+

隐私 · 2026-09-24

隐私说明

TokenMini(原名 MacPulse)是本地优先的开源 macOS 应用。默认没有账号、产品分析、广告追踪或自动崩溃上报;社区排行榜当前未开放:没有在运行的排行服务,无法登录,也不会同步任何数据。下文中关于排行榜的说明,仅在该功能恢复后适用。

默认留在本机的数据

  • 系统指标:CPU、内存、网络、磁盘、电池、温度、风扇与进程信息。
  • AI 会话统计:只读扫描本机 Claude Code 与 Codex 会话 JSONL,并在本地聚合。
  • Skills:只有用户主动使用管理功能时,才读取或修改相应目录。

会话正文、提示词、回复内容、项目名、路径、模型明细、session ID、Claude/Codex 凭证、API Key、系统进程、文件名和设备序列号都不会上传到排行榜服务。

Google 登录与社区排行

不登录时,所有本地功能均可正常使用,也可以匿名浏览公开榜单。用户点击“使用 Google 登录并加入”后,会自动加入 Token 与 API 等价费用两个本周榜单,并保存:

  • Google 提供的账号标识、邮箱和昵称;邮箱不会公开显示。
  • 昵称显示方式;默认打码,只保留最后一个字符。
  • 上海时区每日 Token 总量、API 等价费用汇总、价格表版本和 TokenMini 版本。
  • 加入时间、退出时间、最后同步时间和必要的安全审计记录。

登录令牌只保存在 macOS Keychain。退出排行榜后会停止同步、删除每日排行汇总和服务端刷新会话,并清除本机排行榜凭证;本地监控不受影响。

可选匿名产品统计

0.14.0 包含默认关闭的产品统计基础。本次安装包尚未配置正式接收服务,不能开启,也不会上传产品统计;官网当前没有访问追踪脚本。

未来配置后,必须先由你明确同意,才发送随机设备标识、版本、面板打开次数与运行/交互时长。不会发送对话、提示词、项目、路径、账号、密钥或 Token 明细。关闭会清除本机待发事件及随机标识。GitHub 下载数是安装包下载次数,不代表人数、安装数或日活。

其他网络请求

  • 应用启动后及运行期间,默认定期从 tokenmini.cc 获取经过签名验证的模型价格目录;此请求不包含会话内容、用量汇总或账号凭证。
  • 主动开启 Claude 额度时,请求 Anthropic 用量端点;该实验功能失败不会影响其他功能。
  • 检查更新时读取官方 HTTPS appcast,并从 GitHub Releases 下载签名安装包。
  • 主动安装 Skill 时,从用户指定的 GitHub 仓库下载内容。
  • 打开社区排行榜时读取公开榜单;只有登录加入后才上传上述每日汇总。

你的控制权

通知和 Claude 额度均默认关闭。排行榜可以随时退出;卸载 TokenMini 不会删除本机 Claude Code、Codex 或用户会话文件。

隐私与安全问题可通过 GitHub Issues 反馈;包含凭证或漏洞的信息请按 安全政策 私密提交。

diff --git a/tests/MacPulseTests/ProductAnalyticsTests.swift b/tests/MacPulseTests/ProductAnalyticsTests.swift new file mode 100644 index 0000000..84d5e0d --- /dev/null +++ b/tests/MacPulseTests/ProductAnalyticsTests.swift @@ -0,0 +1,72 @@ +import XCTest +@testable import MacPulse + +final class ProductAnalyticsTests: XCTestCase { + func testOptInPersistsAndRevocationErasesUnsentIdentity() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appendingPathComponent("outbox.json") + var queue = AnalyticsOutbox(file: file, cohort: "upgrade") + queue.record(.appStart, at: Date(timeIntervalSince1970: 100)) + XCTAssertTrue(queue.events.isEmpty) + queue.setEnabled(true) + let oldID = queue.profileID + queue.record(.appStart, at: Date(timeIntervalSince1970: 200)) + XCTAssertEqual(queue.events.count, 2) // consent observation + launch + let originalEvent = queue.events.last! + queue = AnalyticsOutbox(file: file, cohort: "new") + XCTAssertTrue(queue.enabled) + XCTAssertEqual(queue.events.last?.id, originalEvent.id) + XCTAssertEqual(queue.events.last?.occurredAt, originalEvent.occurredAt) + XCTAssertEqual(queue.cohort, "upgrade") + queue.setEnabled(false) + XCTAssertTrue(queue.events.isEmpty) + XCTAssertNil(queue.profileID) + queue = AnalyticsOutbox(file: file, cohort: "new") + XCTAssertFalse(queue.enabled) + queue.setEnabled(true) + XCTAssertNotEqual(queue.profileID, oldID) + } + + func testAckOnlyRemovesConfirmedEventsAndQueueIsBounded() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + let queue = AnalyticsOutbox(file: file, cohort: "unknown", limit: 3) + queue.setEnabled(true) + let first = queue.events[0].id + for _ in 0..<5 { queue.record(.appStart) } + XCTAssertEqual(queue.events.count, 3) + // Preserve the cohort origin even when the queue overflows. + XCTAssertEqual(queue.events[0].id, first) + queue.acknowledge("not-the-request") + XCTAssertEqual(queue.events.count, 3) + queue.acknowledge(first) + XCTAssertEqual(queue.events.count, 2) + } + + func testClockExcludesHiddenIdleSleepAndDoesNotMultiplyWindows() { + var clock = AnalyticsClock() + XCTAssertEqual(clock.sample(uptime: 0, running: true, interacting: true), .zero) + XCTAssertEqual(clock.sample(uptime: 5, running: true, interacting: true), .init(runtime: 5, interaction: 5)) + XCTAssertEqual(clock.sample(uptime: 10, running: true, interacting: false), .init(runtime: 5, interaction: 0)) + XCTAssertEqual(clock.sample(uptime: 15, running: false, interacting: false), .zero) + XCTAssertEqual(clock.sample(uptime: 3600, running: true, interacting: true), .zero) + XCTAssertEqual(clock.sample(uptime: 3605, running: true, interacting: true), .init(runtime: 5, interaction: 5)) + } + + func testWirePayloadHasOriginalTimestampAndNoPersonalData() throws { + let file = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: file) } + let queue = AnalyticsOutbox(file: file, cohort: "upgrade") + queue.setEnabled(true) + queue.record(.engagement, at: Date(timeIntervalSince1970: 1_700_000_000), runtime: 30, interaction: 10) + let event = queue.events.last! + let body = try JSONSerialization.jsonObject(with: event.openPanelBody()) as! [String: Any] + let payload = body["payload"] as! [String: Any] + let props = payload["properties"] as! [String: Any] + XCTAssertEqual(props["__timestamp"] as? String, "2023-11-14T22:13:20Z") + XCTAssertEqual(props["event_id"] as? String, event.id) + XCTAssertEqual(props["interaction_seconds"] as? Double, 10) + XCTAssertEqual(Set(props.keys), ["product", "platform", "version", "cohort", "event_id", "__timestamp", "runtime_seconds", "interaction_seconds"]) + } +} diff --git a/tests/test_download_metrics.py b/tests/test_download_metrics.py new file mode 100644 index 0000000..9c18577 --- /dev/null +++ b/tests/test_download_metrics.py @@ -0,0 +1,36 @@ +import importlib.util +import unittest +from pathlib import Path + +spec = importlib.util.spec_from_file_location('metrics', Path(__file__).parents[1] / 'scripts/download-metrics.py') +metrics = importlib.util.module_from_spec(spec) +spec.loader.exec_module(metrics) + + +class DownloadMetricsTests(unittest.TestCase): + def test_only_public_installer_assets_count_and_products_stay_separate(self): + releases = [ + {'tag_name': 'v0.14.0', 'draft': False, 'assets': [ + {'id': 1, 'name': 'TokenMini-0.14.0.dmg', 'download_count': 7}, + {'id': 2, 'name': 'TokenMini-0.14.0.dmg.sha256', 'download_count': 99}, + {'id': 3, 'name': 'TokenMini-0.14.0-dSYM.zip', 'download_count': 5}]}, + {'tag_name': 'v0.10.0', 'assets': [{'id': 4, 'name': 'MacPulse-0.10.0.dmg', 'download_count': 2}]}, + {'tag_name': 'draft', 'draft': True, 'assets': [{'id': 5, 'name': 'TokenMini-9.0.0.dmg', 'download_count': 10}]}] + report = metrics.summarize(releases) + self.assertEqual(report['total_downloads'], 9) + self.assertEqual(report['tokenmini_downloads'], 7) + self.assertEqual(report['legacy_downloads'], 2) + self.assertIsNone(report['active_devices']['dau']) + self.assertEqual(report['active_devices']['status'], 'not_connected') + + def test_html_escapes_external_labels_and_never_calls_downloads_users(self): + report = metrics.summarize([{'tag_name': '', 'assets': [ + {'id': 1, 'name': 'TokenMini-1.0.0.dmg', 'download_count': 3}]}]) + html = metrics.render(report) + self.assertNotIn('', html) + self.assertIn('下载次数', html) + self.assertIn('尚未接通', html) + + +if __name__ == '__main__': + unittest.main() diff --git a/website/.gitignore b/website/.gitignore index 46cbac5..222f5b0 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -43,3 +43,5 @@ next-env.d.ts # Local hosting project metadata; keep account-level IDs out of the public repo. /.openai/ + +*.tsbuildinfo diff --git a/website/app/brand-config.ts b/website/app/brand-config.ts index 5d4c56f..94c6bd0 100644 --- a/website/app/brand-config.ts +++ b/website/app/brand-config.ts @@ -1,3 +1,3 @@ export const sourceUrl = "https://github.com/ai798-Lab/TokenMini"; -export const publicVersion = "0.13.1"; +export const publicVersion = "0.14.0"; export const releaseUrl = `${sourceUrl}/releases/download/v${publicVersion}/TokenMini-${publicVersion}.dmg`; diff --git a/website/app/globals.css b/website/app/globals.css index c0904a1..f979971 100644 --- a/website/app/globals.css +++ b/website/app/globals.css @@ -27,3 +27,10 @@ @media(max-width:760px){.hero-machine-stage{width:510px;height:320px;right:-85px}.hero-machine{background-size:100% auto}} @media(max-width:760px){.hero-machine-stage{width:450px;height:300px;right:-8px}} .motion-toggle:disabled{cursor:default;opacity:.8} + +/* Optional first-party analytics preferences; no blocking consent overlay. */ +.analytics-preferences { position:fixed; bottom:12px; left:12px; z-index:90; max-width:min(350px,calc(100vw - 24px)); font:12px/1.6 system-ui; } +.analytics-preferences button { border:1px solid #ccc; background:#fff; color:#222; border-radius:6px; padding:7px 11px; cursor:pointer; margin:3px; } +.analytics-preferences button:focus-visible { outline:2px solid #14654e; outline-offset:3px; } +.analytics-preferences-panel { background:#fff; color:#222; padding:16px; border:1px solid #ddd; border-radius:10px; box-shadow:0 5px 30px #0002; } +.analytics-preferences-panel p { margin-bottom:10px; } diff --git a/website/app/home-content.tsx b/website/app/home-content.tsx index 31cef9f..2d9771c 100644 --- a/website/app/home-content.tsx +++ b/website/app/home-content.tsx @@ -19,8 +19,8 @@ export default function Home() { return
@@ -34,7 +34,7 @@ export default function Home() {

{t("免费 Mac AI 用量监控工具")}

{t("让消耗,")}
{t("看得见。")}

{t("AI 用量与 Mac 状态,尽在菜单栏。")}

- {t("免费下载 TokenMini")} v{publicVersion} + {t("免费下载 TokenMini")} v{publicVersion} @@ -55,7 +55,7 @@ export default function Home() {
{getFeatures(t).map(({ n, icon: Icon, en, title, copy, tags }) =>
{n}

{en}

{title}

{copy}

{tags.map(tag => {tag})}
)}
- {t("继续了解")} + {t("继续了解")}
@@ -63,25 +63,25 @@ export default function Home() {

{t("02 / 掌控消耗")}

{t("火力全开。")}
{t("掌控消耗。")}

-

{t("让灵感继续,让用量清楚。")}
{t("从今天的消耗,到下一次重置。")}

+

{t("让灵感继续,让用量清楚。")}
{t("从今天的消耗,到下一次重置。")}

CLAUDE CODE + CODEX{t("观察。理解。创造。")}

{t("03 / 默认本地处理 · 自愿加入排行")}

{t("能力放开。")}
{t("隐私守住。")}

-

{t("你的会话,")}
{t("留在你的 Mac。")}

{t("本地监控默认不需要账号。TokenMini 读取本机的会话记录,在本地汇总用量;不会上传你的提示词或对话正文。")}

{t("TokenMini 默认定期从官网获取签名模型价格目录,不上传会话或用量。软件更新、Claude 额度和 Skill 安装会按功能需要联网;社区排行榜目前未开放。")}

{t("阅读完整隐私说明")}
+

{t("你的会话,")}
{t("留在你的 Mac。")}

{t("本地监控默认不需要账号。TokenMini 读取本机的会话记录,在本地汇总用量;不会上传你的提示词或对话正文。")}

{t("TokenMini 默认定期从官网获取签名模型价格目录,不上传会话或用量。软件更新、Claude 额度和 Skill 安装会按功能需要联网;社区排行榜目前未开放。")}

{t("阅读完整隐私说明")}
-

{t("小巧工具,清晰掌控。")}

{t("现在,")}
{t("看个清楚。")}

-

TokenMini {publicVersion} {t("Public Beta · 原名 MacPulse")}
{t("Apple Silicon · macOS 14+ · 免费开源")}

+

{t("小巧工具,清晰掌控。")}

{t("现在,")}
{t("看个清楚。")}

+

TokenMini {publicVersion} {t("Public Beta · 原名 MacPulse")}
{t("Apple Silicon · macOS 14+ · 免费开源")}

  1. 01

    {t("下载")}

    {t("从 GitHub Releases 下载 DMG 安装包。")}

  2. 02

    {t("拖入应用程序")}

    {t("将 TokenMini 拖入 Applications 后打开。")}

  3. 03

    {t("从菜单栏开始")}

    {t("选择主题和提醒偏好,本地读取你的用量。")}

{t("费用显示为 API 等价估算,不是订阅实际扣款。")}

- + ; } diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 1ecabca..5fed282 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import "./kinetic.css"; import "./language.css"; import { LanguageProvider } from "./language"; +import { ProductAnalytics } from "./product-analytics"; import { requestLocale } from "./locale-server"; const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); @@ -17,5 +18,5 @@ export const metadata: Metadata = { export default async function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { const locale = await requestLocale(); - return {children}; + return {children}; } diff --git a/website/app/privacy/privacy-content.tsx b/website/app/privacy/privacy-content.tsx index 97976e9..6ec19ef 100644 --- a/website/app/privacy/privacy-content.tsx +++ b/website/app/privacy/privacy-content.tsx @@ -19,7 +19,7 @@ const getRankingData = (t: ReturnType["t"]) => [ ]; export default function PrivacyPage() { - const { t } = useLanguage(); + const { t, locale } = useLanguage(); return (