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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions .vercelignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*
!site
!site/**
!vercel.json
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.14.0 — 2026-09-24

- 平衡菜单栏图标与数值左右留白,首次扫描时使用紧凑占位,扫描后保持固定宽度。
- 修复多选筛选器只有文字可点的问题,背景、箭头与留白区域统一响应点击。
- 增加默认关闭、主动同意后才可启用的匿名产品统计基础;未配置服务时禁用并说明原因。
- 提供免费的 GitHub 安装包下载报表与 JSON/CSV 导出;日活、周活尚待接通正式服务,不以下载量代替。
- 更新中英文官网、签名安装包与更新源至构建 23。
- 移除发布二进制中的编译机路径和调试记录,增加安装包隐私检查。

## 0.13.1 — 2026-09-23

- 移除 Prism AI 用量页重复的外观与监控台入口,保留统一外观和「打开完整的中控台」。
Expand Down
16 changes: 13 additions & 3 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# TokenMini 隐私说明

更新日期:2026-09-23
更新日期:2026-09-24

TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有用户账号,不发送产品分析、广告数据或自动崩溃报告。只有用户主动使用 Google 登录加入社区排行榜后,才会启用下文说明的排行榜账号与汇总同步。
TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有用户账号,不发送产品分析、广告数据或自动崩溃报告。用户可以分别选择加入产品统计或社区排行榜;两项功能互相独立,默认均不上传相关数据。只有主动使用 Google 登录加入社区排行榜后,才会启用下文说明的排行榜账号与汇总同步。

## 本地读取的数据

Expand All @@ -12,6 +12,16 @@ TokenMini 是本地优先的开源 macOS 菜单栏应用。默认状态下没有

会话正文不会被上传。项目名只在本机显示;启用“隐私模式”后会替换成稳定别名。

## 产品统计(可选,0.14 候选功能)

产品统计默认关闭。仅当安装包配置了正式 HTTPS 接收服务,且用户阅读接收方与采集内容并主动同意后才发送。未配置服务的候选包不会发送产品统计,界面会明确显示未配置状态。

允许发送的字段仅包括随机安装标识、应用版本、固定的功能查看事件、事件发生时间、运行时长与交互时长。不采集提示词、对话、项目名、项目路径、API Key、设备序列号或个人 AI 用量明细。随机安装标识不是实名账号,不代表精确的自然人人数。

统计数据保存在运营者的私有统计服务,报表访问需要服务端身份验证。客户端只包含公开写入标识,不包含读取报表或管理后台的账号、密码、查询密钥或管理员密钥;数据和管理凭据不进入公开 GitHub 仓库或安装包。服务端正常网络请求可能接触 IP 和客户端信息,正式服务发布前需明确其保留与删除规则。

关闭统计会停止后续发送、取消在途任务并清除本地待发队列和随机标识;已被服务端接收的数据不会自动删除。可通过项目的私密反馈渠道申请处理已接收数据。再次同意会产生新的随机标识。网络中断时事件有界保存在本机并在恢复后补传;正式报表必须具备服务端去重,候选包不能作为生产统计已联通的证明。

## Google 登录与社区排行榜(可选)

不登录时,系统监控、AI 用量、费用预估、清理、Skills、通知和更新等本地功能都可正常使用,TokenMini 不会向排行榜服务上传用量。
Expand Down Expand Up @@ -45,7 +55,7 @@ Google ID token 会发送到 TokenMini 服务端验签;TokenMini 的访问令
- 用户打开社区排行榜时:读取公开榜单;只有登录加入后才向 TokenMini 排行榜服务同步上述每日汇总。
- 用户使用 Google 登录时:在系统浏览器打开 Google OAuth,并由 TokenMini 服务端验证 Google 身份。

除以上用户可感知功能外,TokenMini 不发送设备指纹、会话内容或匿名产品分析。
除以上用户主动选择的功能外,TokenMini 不发送产品分析;任何情况下都不会为产品统计发送会话内容或设备指纹。

## 删除与进程操作

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
3 changes: 2 additions & 1 deletion Sources/MacPulse/AIUsage/DisplaySettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 8 additions & 2 deletions Sources/MacPulse/AIUsage/PricingTable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
124 changes: 124 additions & 0 deletions Sources/MacPulse/Analytics/AnalyticsOutbox.swift
Original file line number Diff line number Diff line change
@@ -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 }
}
Loading