From 293931e288ddb213727309a4ae4445e5cc668d67 Mon Sep 17 00:00:00 2001 From: hepinga Date: Thu, 24 Sep 2026 01:05:59 +0800 Subject: [PATCH 1/5] fix: balance menu labels and make full filter buttons clickable --- .gitignore | 4 +++ .../MacPulse/AIUsage/DisplaySettings.swift | 3 +- .../UI/Dashboard/MultiSelectFilter.swift | 3 +- Sources/MacPulse/UI/MenuBarLabel.swift | 34 ++++++++++++++----- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 2518fdc..48405ac 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ release-notes/*.draft.md .local-secrets/ .vercel .env* + +# Local verification captures and private analytics operations never enter public source. +/artifacts/ +/analytics/ 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/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 需要二次换算。 From 37599b37e3ed972fe2c7643bdf1e09a01fa39416 Mon Sep 17 00:00:00 2001 From: hepinga Date: Thu, 24 Sep 2026 01:14:01 +0800 Subject: [PATCH 2/5] feat: prepare opt-in analytics client without private backend or credentials --- PRIVACY.md | 16 +- README.md | 2 +- .../MacPulse/Analytics/AnalyticsOutbox.swift | 124 ++++++++++ .../MacPulse/Analytics/ProductAnalytics.swift | 219 ++++++++++++++++++ Sources/MacPulse/App.swift | 10 +- .../Dashboard/AppearanceSettingsPanel.swift | 1 + .../MacPulse/UI/Dashboard/DashboardRoot.swift | 10 +- scripts/build.sh | 28 ++- .../MacPulseTests/ProductAnalyticsTests.swift | 72 ++++++ website/.gitignore | 2 + website/app/globals.css | 7 + website/app/home-content.tsx | 18 +- website/app/layout.tsx | 3 +- website/app/privacy/privacy-content.tsx | 9 +- website/app/product-analytics.tsx | 120 ++++++++++ website/lib/product-analytics.mjs | 31 +++ website/tests/product-analytics.test.mjs | 30 +++ website/tests/rendered-html.test.mjs | 11 +- website/vite.config.ts | 13 +- 19 files changed, 701 insertions(+), 25 deletions(-) create mode 100644 Sources/MacPulse/Analytics/AnalyticsOutbox.swift create mode 100644 Sources/MacPulse/Analytics/ProductAnalytics.swift create mode 100644 tests/MacPulseTests/ProductAnalyticsTests.swift create mode 100644 website/app/product-analytics.tsx create mode 100644 website/lib/product-analytics.mjs create mode 100644 website/tests/product-analytics.test.mjs 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/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/scripts/build.sh b/scripts/build.sh index d09507c..651b541 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 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/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/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 (