Files
wuguanganandClaude Opus 5 e3b9153b66 feat: 拦住 AI 把密钥搬出去;README 补 AI 时代的隐私那一节
## 密钥泄漏闸

把活交给 agent 之后,泄密的形态变了。以前是人手滑,现在是**每一步都很合理的
一串操作**:git add . 顺手把 .env 提了、把真实 token 内联进配置文件当"示例"、
为了确认配置 cat 一下密钥(值就进了 transcript,0644、永久、每轮重发给 API)。

**没有一条能靠「这条命令危不危险」判断出来。** 它们都不危险。唯一可靠的信号是
这次调用的内容里有没有出现真实的密钥值。

所以加了一道闸,默认开:参数里出现库里的真实值 → 拒;git add/commit 会把
.env 一类文件或含真实值的文件带进仓库 → 拒。拒绝理由里写明正确做法
(用 $KEY、先加 .gitignore),否则模型只会换个写法再试一次。

**它跑在 hubctl 本地,不依赖 app 在不在跑。** 仓库其它拦截都是「app 没开就放行」
(一个监控工具不该让你的命令跑不起来),这一条反过来 —— 密钥漏出去不可逆。

几条守住的边界,判宽了比判窄了危险:值短于 8 字符不参与匹配(abc/true/test
会命中一切);.example/.template/.sample 结尾放行(模板文件本来就该提交,
误拦一次用户就把整个功能关掉了);只认 EnvRenderer 写出来的格式,不做通用
dotenv 解析;不是 git add/commit 就一个子进程都不起。

拒了之后事件照发给 app(不等应答),进审批日志 —— 一道看不见的闸,
用户第一次被拦时只会觉得 Claude 抽风。开关在密钥页底部,
落成 ~/.vibe-foreman/guard.json,且**只在关掉时才写**:
默认开,「文件不存在 = 开着」,这样没打开过密钥页的人也受保护。

明确写了它挡不住什么:python -c 读、软链、base64 转一道、库外手写的值。
它是一道减少手滑的闸,不是沙箱。

## 截图

主窗口现在也能自动截了:HUB_WINDOW_SECTION 钉住打开哪一页,
配 DemoFixtures.seed 造的假密钥和假账号(刻意编得一眼假 —— 一个像真的
token 混进去,读者会去试)。

两个坑:find-window 按面积取第一个会截到岛(它比主窗口先出现一瞬),
改成按宽度 ≥900 筛;.accessory 的进程从后台 shell 起来抢不到焦点,
而非活跃窗口里的系统控件会被去饱和 —— 截出来开关全是灰的,
正好把要展示的东西说反了,所以截图模式下改用 .regular。

顺带:进密钥页自动选中第一组。有组却什么都不选,右边一大片空白,
那是"还没建组"的样子而不是"有四组等你挑"的样子。

646 个测试全过(原 625)。真 hubctl 二进制端到端验过四种情况:
写入真值被拒、约定的 source 用法放行、git add . 带 .env 被拒、普通命令放行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 10:46:46 +08:00

1229 lines
55 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import HubCore
import HubIPC
import HubProbe
import HubProjects
import SwiftUI
/// 主窗口的分区。
public enum DashboardSection: String, CaseIterable, Identifiable, Sendable {
case sessions = "会话"
case acceptance = "验收"
case projects = "项目"
// 两个密钥相关的分区**刻意分开两行**,不合成一个「凭据」页:
// 一个的值会写成明文文件给 AI 用,一个绝不给任何自动化看到。
// 放进同一个页面只会让人以为它们是一回事,然后把线上库的密码填错地方。
case secrets = "共用密钥"
case credentials = "账号密码"
case usage = "用量"
case approvals = "审批日志"
case settings = "设置"
public var id: String { rawValue }
/// 给脚本用的英文短名 —— rawValue 是中文,写进 shell 脚本会很难看,
/// 而且 `HUB_ISLAND_STATE` 那批已有的形态名也全是英文。
static func fromShortName(_ name: String?) -> DashboardSection? {
switch name {
case "sessions": return .sessions
case "acceptance": return .acceptance
case "projects": return .projects
case "secrets": return .secrets
case "credentials": return .credentials
case "usage": return .usage
case "approvals": return .approvals
case "settings": return .settings
default: return nil
}
}
var symbol: String {
switch self {
case .sessions: return "bolt.horizontal.circle"
case .acceptance: return "checklist"
case .projects: return "folder"
case .secrets: return "key.fill"
case .credentials: return "lock.fill"
case .usage: return "chart.bar"
case .approvals: return "checkmark.shield"
case .settings: return "gearshape"
}
}
}
/// 主窗口。
///
/// 分工:**岛 = 一瞥**(零点击获取状态);**主窗口 = 深挖**(用量趋势、
/// 项目管理、审批日志)。两者共享同一个 `SessionStore` 和同一套原子组件
/// (`PixelSprite` / `StatusDot` / `GitChip`),这是设计一致性的技术保障 ——
/// 维护两套组件的话它们必然会漂移。
///
/// 视觉上和岛刻意相反:岛强制暗色(因为要贴合恒为黑的刘海),
/// 主窗口完全跟随系统。岛是"设备的一部分",窗口是"一个 app"。
public struct MainWindowView: View {
@Bindable var store: SessionStore
@Bindable var projects: ProjectStore
@Bindable var approvals: ApprovalCoordinator
@Bindable var placement: IslandPlacementStore
@Bindable var acceptance: AcceptanceStore
@Bindable var verifierSettings: VerifierSettings
let verifier: AcceptanceVerifier
@Bindable var watchdog: SessionWatchdog
@Bindable var closedSessions: ClosedSessionStore
@Bindable var reaper: SessionReaper
@Bindable var secrets: SharedSecretStore
@Bindable var credentials: CredentialStore
let channels: HookChannelMonitor
let onJump: (String) -> Void
let onLaunch: (Project, LaunchMode) -> Void
/// 截图脚本用 `HUB_WINDOW_SECTION` 钉死打开哪一页 ——
/// 没有它就只能去控制鼠标点侧栏,那在 CI 和别人机器上都不可靠。
@State private var section: DashboardSection = DashboardSection(
rawValue: ProcessInfo.processInfo.environment["HUB_WINDOW_SECTION"] ?? ""
) ?? DashboardSection.fromShortName(
ProcessInfo.processInfo.environment["HUB_WINDOW_SECTION"]
) ?? .sessions
@State private var search = ""
/// 当前在「验收」页看的是哪个项目。会话行的「清单」按钮会写它。
@State private var ledgerPath: String?
/// Git 账号/仓库列表。挂在窗口这一层:克隆表单每次打开复用同一份数据
/// (带磁盘缓存),只做后台静默刷新,不再让用户每次干等加载。
@State private var git = GitAccountStore()
public init(
store: SessionStore,
projects: ProjectStore,
approvals: ApprovalCoordinator,
placement: IslandPlacementStore,
acceptance: AcceptanceStore,
verifierSettings: VerifierSettings,
verifier: AcceptanceVerifier,
watchdog: SessionWatchdog,
closedSessions: ClosedSessionStore,
reaper: SessionReaper,
secrets: SharedSecretStore,
credentials: CredentialStore,
channels: HookChannelMonitor,
onJump: @escaping (String) -> Void,
onLaunch: @escaping (Project, LaunchMode) -> Void
) {
self.store = store
self.projects = projects
self.approvals = approvals
self.placement = placement
self.acceptance = acceptance
self.verifierSettings = verifierSettings
self.verifier = verifier
self.watchdog = watchdog
self.closedSessions = closedSessions
self.reaper = reaper
self.secrets = secrets
self.credentials = credentials
self.channels = channels
self.onJump = onJump
self.onLaunch = onLaunch
}
public var body: some View {
NavigationSplitView {
List(DashboardSection.allCases, selection: $section) { item in
Label(item.rawValue, systemImage: item.symbol)
.tag(item)
}
.navigationSplitViewColumnWidth(min: 190, ideal: 210, max: 260)
} detail: {
detail
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
// 单一强调色,和岛里 busy 的蓝一致,形成跨形态的一致性。
.tint(IslandTheme.busy)
}
@ViewBuilder
private var detail: some View {
switch section {
case .sessions:
SessionsPane(
store: store, projects: projects, onJump: onJump,
onOpenLedger: { session in
ledgerPath = AcceptanceStore.projectPath(
forCWD: session.cwd, projects: projects
)
section = .acceptance
}
)
case .acceptance:
AcceptancePane(
acceptance: acceptance, projects: projects, store: store,
settings: verifierSettings, verifier: verifier,
watchdog: watchdog, selection: $ledgerPath
)
case .projects:
ProjectsPane(
projects: projects, store: store, git: git,
closedSessions: closedSessions, secrets: secrets, onLaunch: onLaunch
)
case .secrets:
SecretsPane(secrets: secrets, projects: projects)
case .credentials:
CredentialsPane(credentials: credentials, projects: projects)
case .usage:
UsagePane()
case .approvals:
ApprovalLogPane(approvals: approvals)
case .settings:
SettingsPane(
approvals: approvals, projects: projects, git: git,
placement: placement, channels: channels, verifierSettings: verifierSettings,
reaper: reaper
)
}
}
}
// MARK: - 通用零件
/// 页面大标题。28pt rounded 是 iOS 26 的大标题语言。
struct PaneHeader: View {
let title: String
let subtitle: String?
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 28, weight: .bold, design: .rounded))
if let subtitle {
Text(subtitle)
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.top, 20)
.padding(.bottom, 12)
}
}
/// git 状态芯片。
struct GitChip: View {
let info: GitInfo?
var body: some View {
guard let info else { return AnyView(EmptyView()) }
return AnyView(
HStack(spacing: 6) {
if let branch = info.branch {
Label(branch, systemImage: "arrow.triangle.branch")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(1)
}
if info.changeCount > 0 {
Text("+\(info.changeCount)")
.font(.system(size: 11, weight: .medium)).monospacedDigit()
.foregroundStyle(IslandTheme.waiting)
}
if info.ahead > 0 {
Text("↑\(info.ahead)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if info.behind > 0 {
Text("↓\(info.behind)")
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(IslandTheme.busy)
}
if !info.hasUpstream {
Text("无远端")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
)
}
}
/// 项目行最左边那颗状态点。
///
/// 三态,不是两态:没会话(灰实心)、有会话且终端连着(蓝实心)、
/// 有会话但终端全关了(蓝空心)。第三态以前和第二态长得一模一样 ——
/// 用户关掉终端后仍看到亮着的蓝点,认定界面在骗人。它没骗人,
/// 进程确实还在跑,但**「在跑」和「你还找得到它」是两件事**,
/// 一颗点表达不了两件事。
struct SessionDot: View {
let running: Int
let detached: Int
private var allDetached: Bool { running > 0 && detached == running }
var body: some View {
Group {
if running == 0 {
Circle().fill(Color.secondary.opacity(0.3))
} else if allDetached {
Circle().strokeBorder(IslandTheme.busy.opacity(0.65), lineWidth: 1.6)
} else {
Circle().fill(IslandTheme.busy)
}
}
.frame(width: 8, height: 8)
.help(SessionDot.tooltip(running: running, detached: detached))
}
static func tooltip(running: Int, detached: Int) -> String {
guard running > 0 else { return "没有会话在跑" }
guard detached > 0 else { return "\(running) 个会话在跑" }
if detached == running {
return "\(running) 个会话还在后台跑,但终端已经关了。tmux 里还留着,重新打开即可接上。"
}
return "\(running) 个会话在跑,其中 \(detached) 个的终端已经关了(进程仍在后台)。"
}
}
/// 内容区卡片。**实心,不用玻璃** —— 大面积玻璃在长时间阅读区会疲劳,
/// 而且每张卡片都要采样一次背景,9 张就是 9 次。玻璃留给 sidebar 和 toolbar
/// (那是系统自己给的)。
struct Card<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
.background.secondary,
in: .rect(cornerRadius: 14, style: .continuous)
)
}
}
// MARK: - 会话
struct SessionsPane: View {
let store: SessionStore
let projects: ProjectStore
let onJump: (String) -> Void
let onOpenLedger: (AgentSession) -> Void
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "会话",
subtitle: "\(store.sessions.count) 个运行中 · \(store.waitingCount) 个等待你"
)
ScrollView {
LazyVStack(spacing: 8) {
ForEach(store.sessions) { session in
Card {
HStack(spacing: 12) {
SpriteScreen(cornerRadius: 8) {
PixelSprite(session: session, pixelSize: 2, gridSize: 16)
}
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
StatusDot(status: session.status, size: 7)
Text(session.name ?? session.sessionId)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
.lineLimit(1)
.truncationMode(.middle)
if session.kind == .bg {
Text("后台")
.font(.system(size: 10))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.quaternary, in: .capsule)
}
}
Text(projectLabel(for: session))
.font(.system(size: 12))
.foregroundStyle(.secondary)
if let waitingFor = session.waitingFor {
Text(waitingFor)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(IslandTheme.waiting)
}
}
Spacer(minLength: 8)
GitChip(info: gitInfo(for: session))
Button("清单") { onOpenLedger(session) }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
Button("跳转") { onJump(session.sessionId) }
.buttonStyle(.borderless)
.font(.system(size: 12, weight: .medium))
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
private func projectLabel(for session: AgentSession) -> String {
projects.projectName(forPath: session.cwd) ?? session.fallbackProjectName
}
private func gitInfo(for session: AgentSession) -> GitInfo? {
guard let name = projects.projectName(forPath: session.cwd),
let project = projects.projects.first(where: { $0.name == name })
else { return nil }
return projects.git(for: project)
}
}
// MARK: - 项目
struct ProjectsPane: View {
let projects: ProjectStore
let store: SessionStore
let git: GitAccountStore
let closedSessions: ClosedSessionStore
let secrets: SharedSecretStore
let onLaunch: (Project, LaunchMode) -> Void
@State private var search = ""
@State private var showNewProject = false
@State private var showClone = false
private var filtered: [Project] {
let base: [Project]
if search.isEmpty {
base = projects.projects
} else {
let needle = search.lowercased()
base = projects.projects.filter {
$0.name.lowercased().contains(needle)
|| $0.path.lowercased().contains(needle)
|| $0.aliases.contains { $0.lowercased().contains(needle) }
|| ($0.description?.lowercased().contains(needle) ?? false)
}
}
// 置顶 > 正在开发 > 最近提交 > 名字。38 个项目按 yaml 顺序排的话,
// 找一个要翻半屏 —— 而人真正会回去的就是在跑的和最近动过的那几个。
return ProjectOrdering.rank(
base,
pinned: projects.pinned,
runningCount: { runningCount($0) },
lastCommitAt: { projects.git(for: $0)?.lastCommitAt }
)
}
/// 这个项目下有没有正在跑的会话。
private func runningCount(_ project: Project) -> Int {
sessions(of: project).count
}
/// 其中有多少是「终端已经关了、进程还在后台跑」的。
private func detachedCount(_ project: Project) -> Int {
sessions(of: project).count { store.detachedSessionIds.contains($0.sessionId) }
}
private func sessions(of project: Project) -> [AgentSession] {
let root = project.expandedPath
return store.sessions.filter { $0.cwd == root || $0.cwd.hasPrefix(root + "/") }
}
/// 选一个已有目录登记成项目。
///
/// **只登记,不创建、不改动目录本身。**「新建」是另一个按钮。
private func addExistingDirectory() {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = true
panel.prompt = "添加"
panel.message = "选择要加入清单的项目目录(不限于 git 仓库)"
panel.directoryURL = URL(
fileURLWithPath: NSString(string: "~/Documents/code").expandingTildeInPath
)
guard panel.runModal() == .OK else { return }
for url in panel.urls { projects.addExisting(path: url.path) }
}
/// 这一行的菜单内容。▶ 按钮和右键共用。
private func menuItems(for project: Project) -> [RowMenu.Item?] {
ProjectMenu.items(
isPinned: projects.isPinned(project),
resumable: runningCount(project) == 0
? closedSessions.latest(forProject: project.expandedPath)
: nil,
isMissing: projects.isMissing(project),
onLaunch: { onLaunch(project, $0) },
onTogglePin: { projects.togglePin(project) },
onRemove: { projects.remove([project]) },
onCopySecretPath: secrets.groups(for: project).isEmpty ? nil : {
Clipboard.copy(secrets.envFilePath(for: project))
}
)
}
/// 一行的会话说明。
///
/// **全部会话都断开时不能只写「N 个会话」** —— 用户已经把窗口关了,
/// 看到一个亮着的蓝点只会认为界面在撒谎(实机上就是这么被指出来的)。
/// 真相是进程还在跑,得原样说出来。
static func sessionSummary(running: Int, detached: Int) -> String? {
guard running > 0 else { return nil }
if detached == 0 { return "\(running) 个会话" }
if detached == running {
return running == 1 ? "1 个会话 · 终端已关" : "\(running) 个会话 · 终端都已关"
}
return "\(running) 个会话 · \(detached) 个终端已关"
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "项目", subtitle: "\(projects.projects.count) 个项目")
HStack(spacing: 10) {
TextField("搜索项目…", text: $search)
.textFieldStyle(.roundedBorder)
Button {
showNewProject = true
} label: {
Label("新建", systemImage: "plus")
}
Button {
showClone = true
} label: {
Label("克隆", systemImage: "arrow.down.circle")
}
Button {
projects.scan()
} label: {
Label("扫描", systemImage: "arrow.clockwise")
}
.disabled(projects.isScanning)
// 扫描只认有 .git 的目录 —— monorepo 里的子目录、还没 git init
// 的目录永远扫不进来(实机 40 条里有 7 条是这种)。
// 这是"能加载进去"真正缺的那个入口。
Button {
addExistingDirectory()
} label: {
Label("添加目录", systemImage: "folder.badge.plus")
}
if !projects.missingProjects.isEmpty {
Button(role: .destructive) {
projects.remove(projects.missingProjects)
} label: {
Label(
"清理失效 \(projects.missingProjects.count)",
systemImage: "trash"
)
}
.help("这些项目的目录已经不在了。只从清单里移除,不动磁盘上的任何文件。")
}
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
.sheet(isPresented: $showNewProject) {
NewProjectSheet(projects: projects)
}
.sheet(isPresented: $showClone) {
CloneProjectSheet(projects: projects, git: git)
}
if projects.projects.isEmpty {
emptyState
Spacer()
}
ScrollView {
LazyVStack(spacing: 8) {
ForEach(filtered) { project in
Card {
HStack(spacing: 12) {
// 全断开时用空心点:一眼能和「真的在跑」区分开,
// 又不至于和「没有会话」混成一样。
SessionDot(
running: runningCount(project),
detached: detachedCount(project)
)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 8) {
Text(project.name)
.font(.system(size: 14, weight: .semibold,
design: .rounded))
if projects.isPinned(project) {
Image(systemName: "pin.fill")
.font(.system(size: 9))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(45))
}
if projects.isMissing(project) {
Label("目录已不存在", systemImage:
"exclamationmark.triangle.fill")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(IslandTheme.waiting)
}
if let summary = Self.sessionSummary(
running: runningCount(project),
detached: detachedCount(project)
) {
Text(summary)
.font(.system(size: 10))
.foregroundStyle(
detachedCount(project) == runningCount(project)
? Color.secondary : IslandTheme.busy
)
}
}
if let description = project.description {
Text(description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
// 「关窗即结束」只有在还能接回来时才成立。
// 藏在 ▶ 菜单第三层的入口等于没有 ——
// 用户关掉窗口后第一眼看的就是这一行。
if runningCount(project) == 0,
let last = closedSessions.latest(forProject:
project.expandedPath) {
Button {
onLaunch(project, .resumeSession)
} label: {
Label(
"接着上次(\(RelativeTime.short(from: last.endedAt))前结束)",
systemImage: "arrow.uturn.backward.circle"
)
.font(.system(size: 11))
}
.buttonStyle(.link)
.help("恢复 \(last.name),接着上次的上下文往下走")
}
Text(project.path)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.strikethrough(projects.isMissing(project))
.lineLimit(1)
.truncationMode(.head)
}
Spacer(minLength: 8)
GitChip(info: projects.git(for: project))
Menu {
// 和右键、和岛上那份是同一批菜单项。
// 各写一遍必然漂移,见 ProjectMenu 的说明。
let items = menuItems(for: project)
ForEach(items.indices, id: \.self) { index in
if let item = items[index] {
Button(item.title, action: item.action)
} else {
Divider()
}
}
} label: {
Image(systemName: "play.circle")
}
.menuStyle(.borderlessButton)
.fixedSize()
}
// 右键整行 = 同一个菜单。每次都得瞄准行尾那个小按钮
// 才能选开法,是这一版之前最鸡肋的地方。
.overlay(RightClickCatcher {
RowMenu.present(menuItems(for: project))
})
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
/// 一个项目都没有时的空状态。
///
/// **必须说清楚在读哪个文件。** 这次的事故是 app 读的 projects.yaml 和
/// 脚本维护的那份不是同一个,而界面上只有一个孤零零的"0 个项目",
/// 完全没有线索。空状态要给出路径、原因和下一步动作。
private var emptyState: some View {
Card {
VStack(alignment: .leading, spacing: 10) {
Label("没有读到项目", systemImage: "exclamationmark.triangle")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(IslandTheme.waiting)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 3) {
Text("数据文件")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
}
HStack(spacing: 8) {
if let url = projects.sourceURL {
Button("在 Finder 中显示") {
NSWorkspace.shared.activateFileViewerSelecting([url])
}
}
Button("重新加载") { projects.load() }
Button("扫描 ~/Documents/code") { projects.scan() }
.disabled(projects.isScanning)
}
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding(.horizontal, 20)
}
}
// MARK: - 用量
struct UsagePane: View {
/// 默认 7 天而不是全部。
///
/// `~/.claude/projects` 实测有近 1 GB / 341 个 jsonl,全量首扫要好几十秒。
/// 打开页面就卡住不出数,用户读到的是"这个功能没做"。7 天视图靠 mtime
/// 过滤掉绝大多数文件,首屏两秒内出结果;想看全量再自己切。
@State private var range: UsageRange = .week7
@State private var summary: UsageSummary = .empty
@State private var loading = false
@State private var scanned = 0
@State private var total = 0
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "用量",
subtitle: "\(summary.totalSessions) 个会话 · \(summary.totalMessages) 条消息"
)
Picker("", selection: $range) {
ForEach(UsageRange.allCases, id: \.self) { Text($0.rawValue).tag($0) }
}
.pickerStyle(.segmented)
.labelsHidden()
.padding(.horizontal, 20)
.padding(.bottom, 12)
// 全量首扫要读近 1 GB。没有进度的话用户没法区分"在算"和"坏了"。
if loading {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(total > 0 ? "正在统计 \(scanned)/\(total) 个会话文件…" : "正在统计…")
.font(.system(size: 11))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 20)
.padding(.bottom, 10)
}
HStack(spacing: 10) {
statTile("输入", summary.totalInput, IslandTheme.busy)
statTile("输出", summary.totalOutput, IslandTheme.shell)
}
.padding(.horizontal, 20)
.padding(.bottom, 12)
ScrollView {
LazyVStack(spacing: 6) {
ForEach(summary.projects) { usage in
Card {
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(usage.projectName)
.font(.system(size: 13, weight: .semibold,
design: .rounded))
.lineLimit(1)
Spacer()
Text(format(usage.totalTokens))
.font(.system(size: 12)).monospacedDigit()
.foregroundStyle(.secondary)
}
GeometryReader { proxy in
let maxTokens = summary.projects.first?.totalTokens ?? 1
let ratio = max(
0.01, Double(usage.totalTokens) / Double(max(maxTokens, 1))
)
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(
LinearGradient(
colors: [
IslandTheme.busy.opacity(0.85),
IslandTheme.busy.opacity(0.35),
],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: proxy.size.width * ratio)
}
.frame(height: 6)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.task(id: range) { await reload() }
}
private func statTile(_ label: String, _ value: Int, _ color: Color) -> some View {
Card {
VStack(alignment: .leading, spacing: 2) {
Text(label).font(.system(size: 11)).foregroundStyle(.secondary)
Text(format(value))
.font(.system(size: 22, weight: .semibold, design: .rounded))
.monospacedDigit()
.foregroundStyle(color)
}
}
}
private func format(_ value: Int) -> String {
if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000) }
if value >= 1_000 { return String(format: "%.1fk", Double(value) / 1_000) }
return "\(value)"
}
private func reload() async {
loading = true
scanned = 0
total = 0
let range = self.range
// 解析要扫近 1 GB 的 jsonl,必须放后台;进度回调也从后台线程来,
// 所以要显式跳回 MainActor 再更新 @State。
let result = await Task.detached(priority: .userInitiated) {
UsageStats.collect(range: range) { done, count in
Task { @MainActor in
scanned = done
total = count
}
}
}.value
summary = result
loading = false
}
}
// MARK: - 审批日志
struct ApprovalLogPane: View {
let approvals: ApprovalCoordinator
var body: some View {
VStack(spacing: 0) {
PaneHeader(
title: "审批日志",
subtitle: "记录所有被识别为高风险的操作。看一周就知道该不该开启拦截。"
)
if approvals.log.isEmpty {
ContentUnavailableView(
"还没有记录",
systemImage: "checkmark.shield",
description: Text("高风险操作发生时会记在这里")
)
} else {
ScrollView {
LazyVStack(spacing: 6) {
ForEach(approvals.log) { entry in
Card {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 8) {
Text(entry.risk == .irreversible ? "不可逆" : "危险")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.black)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(
entry.risk == .irreversible
? IslandTheme.danger : IslandTheme.waiting,
in: .capsule
)
Text(entry.projectName)
.font(.system(size: 13, weight: .semibold))
Text(entry.toolName)
.font(.system(size: 11))
.foregroundStyle(.secondary)
Spacer()
Text(verdictLabel(entry))
.font(.system(size: 11, weight: .medium))
.foregroundStyle(verdictColor(entry))
Text(entry.timestamp, style: .time)
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(.tertiary)
}
Text(entry.command)
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.secondary)
.lineLimit(2)
.textSelection(.enabled)
}
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
}
}
private func verdictLabel(_ entry: ApprovalLogEntry) -> String {
switch entry.verdict {
case "allow": return "已允许"
case "deny": return "已拒绝"
case "timeout": return "超时拒绝"
default: return entry.intercepted ? entry.verdict : "未拦截"
}
}
private func verdictColor(_ entry: ApprovalLogEntry) -> Color {
switch entry.verdict {
case "deny", "timeout": return IslandTheme.danger
case "allow": return IslandTheme.shell
default: return .secondary
}
}
}
// MARK: - 设置
struct SettingsPane: View {
@Bindable var approvals: ApprovalCoordinator
let projects: ProjectStore
let git: GitAccountStore
@Bindable var placement: IslandPlacementStore
let channels: HookChannelMonitor
@Bindable var verifierSettings: VerifierSettings
@Bindable var reaper: SessionReaper
@State private var hookStatus: String = "检查中…"
/// hook 装好了没有。启动时 app 已经自动装过一遍,这里是复查 + 手动修复入口。
@State private var hookInstalled: HookInstaller.Outcome = .alreadyCorrect
/// 「关掉终端窗口就结束会话」。
///
/// **必须能关。** 这是全案第二个会主动杀进程的组件,一个杀错了没法
/// 阻止的自动行为不该存在。文案要把两侧的代价都说清楚:
/// 开着 = ⌘W 手滑会弄丢正在跑的活;关着 = 会话在后台留着,
/// 界面上标成"终端已关"但不动它。
@ViewBuilder
private var reaperCard: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle("关掉终端窗口时结束会话", isOn: $reaper.enabled)
.font(.system(size: 14, weight: .semibold))
Text("""
tmux 默认的行为是关窗口只 detach,claude 进程照跑 —— 于是界面上一直\
显示"在跑"而你已经把窗口关了。打开这个开关后,检测到没有终端连着\
就结束它,**结束前会把 sessionId 记下来**,项目行上会出现「接着上次」\
一键接回原来的上下文。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text("""
代价:⌘W 手滑会结束正在跑的活(连续两轮、共 30 秒确认,\
会话起来不足 90 秒不动)。关掉这个开关则什么都不杀,\
只在项目行上标成"终端已关"。
""")
.font(.system(size: 11))
.foregroundStyle(reaper.enabled ? IslandTheme.waiting : Color.secondary.opacity(0.6))
.fixedSize(horizontal: false, vertical: true)
}
}
/// 「Hub 自己跑验收命令」的总开关。
///
/// 文案上刻意把三道闸都说出来。这是全案唯一会执行命令的功能,
/// 用户有权知道边界在哪 —— 只写一句"自动验证"然后让他猜,
/// 结果只会是他不敢开,或者开了但不知道自己开了什么。
@ViewBuilder
private var verifierCard: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle("自动跑验收命令(实际功能证明)", isOn: $verifierSettings.enabled)
.font(.system(size: 14, weight: .semibold))
Text("""
关掉时 Vibe Foreman 一条命令都不会执行。打开后也仍有两道限制:\
只放行构建/测试类命令(`swift test`、`npm run`、`bash scripts/*` 等),\
含 shell 元字符的一律拒绝;而且**每条命令首次执行前要你在验收页点「允许」**。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
Text("这是 Vibe Foreman 唯一会在你机器上执行命令的功能。")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(
verifierSettings.enabled ? IslandTheme.waiting : Color.secondary.opacity(0.6)
)
}
}
/// hook 通道健康度。
///
/// `hubctl doctor` 只能回答"socket 通不通"。但链路断掉的方式远不止这一种:
/// settings.json 被别的工具覆盖、event 名字拼错、Claude Code 改了名 ——
/// 这几种情况下 socket 是通的、doctor 是绿的,而那一类事件一条都收不到,
/// 验收清单会安静地不工作。
///
/// **只有真收到过事件才能证明通了。** 所以这里显示的是实际到达数,不是配置项。
@ViewBuilder
private var hookChannels: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Hook 通道")
.font(.system(size: 14, weight: .semibold))
Text("显示的是**实际收到过的事件数**,不是配置里写了什么 —— 配置对而链路断是最常见的故障。")
.font(.system(size: 11))
.foregroundStyle(.secondary)
ForEach(HookChannelMonitor.expected, id: \.self) { kind in
let count = channels.counts[kind] ?? 0
HStack(spacing: 8) {
Circle()
.fill(count > 0 ? IslandTheme.shell : Color.secondary.opacity(0.35))
.frame(width: 6, height: 6)
Text(HookChannelMonitor.label(for: kind))
.font(.system(size: 12))
Spacer()
if let last = channels.lastSeen[kind] {
Text(last, style: .relative)
.font(.system(size: 11)).monospacedDigit()
.foregroundStyle(.tertiary)
}
Text(count > 0 ? "\(count)" : "未收到")
.font(.system(size: 11, weight: count > 0 ? .medium : .regular))
.monospacedDigit()
.foregroundStyle(count > 0 ? .secondary : .tertiary)
}
}
Text("计数从 Vibe Foreman 启动时开始。刚装完 hook 的话,新开一个会话才会亮。")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.padding(.top, 2)
}
}
var body: some View {
VStack(spacing: 0) {
PaneHeader(title: "设置", subtitle: nil)
ScrollView {
VStack(spacing: 12) {
Card { reaperCard }
Card { verifierCard }
Card { hookChannels }
Card {
VStack(alignment: .leading, spacing: 10) {
Text("灵动岛位置").font(.system(size: 14, weight: .semibold))
Toggle("跟随光标去外接屏", isOn: $placement.followsCursor)
.toggleStyle(.checkbox)
Text("""
默认固定在有刘海的那块屏。开了之后岛会跟着光标跑到外接屏 —— \
但外接屏没有刘海,岛会变成悬浮在屏幕顶部的胶囊,正好压在\
浏览器标签栏那一带,底下的东西会点不动。想在外接屏上也看到\
状态再开。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 10) {
Text("审批拦截").font(.system(size: 14, weight: .semibold))
Picker("拦截门槛", selection: $approvals.interceptThreshold) {
Text("只拦不可逆操作(推荐)").tag(RiskLevel.irreversible)
Text("危险操作也拦").tag(RiskLevel.dangerous)
}
.pickerStyle(.radioGroup)
Text("""
你全局用的是 auto 权限模式 + --dangerously-skip-permissions,\
图的就是不被打断。把「危险操作」一档也拦上,按日常用量每天会弹 3–8 次。\
建议先看一周审批日志再决定。
""")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Git 账号").font(.system(size: 14, weight: .semibold))
Spacer()
Button {
git.refresh()
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(git.isRefreshing)
}
if git.accounts.isEmpty {
Text(git.diagnostic ?? (git.isRefreshing ? "检查中…" : "未检查"))
.font(.system(size: 12))
.foregroundStyle(.secondary)
} else {
ForEach(git.accounts) { account in
HStack(spacing: 6) {
Image(systemName: account.active
? "person.crop.circle.badge.checkmark"
: "person.crop.circle")
.foregroundStyle(account.active ? .green : .secondary)
Text("\(account.login) · \(account.host)")
.font(.system(size: 12, design: .monospaced))
if account.active {
Text("当前")
.font(.system(size: 10))
.foregroundStyle(.green)
if !git.repos.isEmpty {
Text("\(git.repos.count) 个仓库")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
} else {
Button("切换到这个") { git.switchTo(account) }
.font(.system(size: 11))
.disabled(git.isSwitching || git.isRefreshing)
}
}
}
}
Text("克隆用的是标「当前」的那个账号的凭据 —— 拉另一个账号的私有仓要先切过去。新增账号在终端跑:")
.font(.system(size: 11))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Text("gh auth login")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
Button("复制命令") {
Clipboard.copy("gh auth login")
}
.font(.system(size: 11))
}
}
}
.onAppear { git.refresh() }
Card {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Hook 安装").font(.system(size: 14, weight: .semibold))
Spacer()
Button("重新检查并修复") { hookInstalled = HookInstaller().install() }
.font(.system(size: 11))
}
// 自动装好之后必须**说出来**。一个默默做完的自动步骤
// 和一个默默失败的自动步骤,在界面上长得一模一样;
// 而后者的表现是"app 开着但一条事件都收不到"。
switch hookInstalled {
case .alreadyCorrect:
Label("9 类 hook 已装好,无需手动操作", systemImage: "checkmark.seal")
.font(.system(size: 12))
.foregroundStyle(IslandTheme.shell)
case .installed(let count):
Label("已自动装好 \(count) 类 hook", systemImage: "wand.and.stars")
.font(.system(size: 12))
.foregroundStyle(IslandTheme.busy)
case .failed(let reason):
Label(reason, systemImage: "exclamationmark.triangle.fill")
.font(.system(size: 12))
.foregroundStyle(IslandTheme.waiting)
.fixedSize(horizontal: false, vertical: true)
}
Text("装到 ~/.claude/settings.json,只动 Hub 自己的那几条,你手写的 hook 不受影响。")
.font(.system(size: 11))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
Divider().padding(.vertical, 2)
Text("Hook 连接").font(.system(size: 14, weight: .semibold))
Text(hookStatus)
.font(.system(size: 12, design: .monospaced))
.foregroundStyle(.secondary)
Text("socket:\(HubIPCPath.display)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
}
}
Card {
VStack(alignment: .leading, spacing: 8) {
Text("项目配置").font(.system(size: 14, weight: .semibold))
Text("\(projects.projects.count) 个项目")
.font(.system(size: 12)).foregroundStyle(.secondary)
// 把实际读的文件路径摆出来。踩过的坑:脚本维护的是
// 仓库里那份 projects.yaml,app 读的却是
// Application Support 里另一份旧的,界面上完全没有线索。
Text(projects.sourceURL?.path ?? "(找不到 projects.yaml)")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(.tertiary)
.textSelection(.enabled)
.lineLimit(1)
.truncationMode(.head)
if let diagnostic = projects.loadDiagnostic {
Text(diagnostic)
.font(.system(size: 11))
.foregroundStyle(IslandTheme.waiting)
}
Button("重新加载 projects.yaml") { projects.load() }
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 20)
}
}
.onAppear {
hookStatus = "✓ socket 正在监听,hook 事件会实时送达"
// 复查而不是重装:进设置页只是想看看状态,不该顺手改文件。
let installer = HookInstaller()
hookInstalled = installer.isHealthy()
? .alreadyCorrect
: .failed("有 hook 没装上或指向了旧的 hubctl —— 点右上角修复")
}
}
}
/// 给设置页显示 socket 路径用。放这里避免 HubUI 直接暴露 HubIPC 的类型给外部。
enum HubIPCPath {
static var display: String {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Application Support/claude-hub/hub.sock")
.path
}
}