Files
wuguanganandClaude Opus 5 a9363e43c9 feat: 共用密钥中心 + 项目账号密码库
两个功能来自同一个痛点,但威胁模型正好相反,这是整个设计的支点。

**共用密钥**:平台密钥(飞书、领星、OpenAI)在十几个项目里要各配一遍,
而最省事的做法——直接粘进聊天框告诉 AI——是最糟的那个。现在按平台建组、
勾给项目,物化成 ~/.vibe-foreman/env/by-project/<项目>-<哈希>.env
(目录 0700、文件 0600),复制路径给 AI。这份数据**故意是明文、故意给 AI 用**。

**账号密码**:本地/测试/线上分开记,只给人看。AES-GCM 加密,
查看和复制过 Touch ID。这份数据**绝不能被任何自动化读到**。

两者不共用存储、不共用目录、不共用界面,并用一条哨兵测试钉死
「密码的值不会出现在给 AI 的那个目录里」。

三个决定了做法的发现:

1. 「让 AI Read 那个文件」会把密钥写进两个明文文件——
   ~/.claude/projects/*.jsonl(0644、永久、每轮重发给 API),
   以及 Hub 自己的验收清单(Evidence.ran 会存命令和输出尾巴,同样不过期)。
   所以「复制给 AI」给的不是裸路径,是带 `set -a; source` 用法和
   「不要用 Read/cat/grep」禁令的三句话。换成 source 之后,
   transcript 里只剩路径和变量名,值一次都不出现。

2. 按项目分文件**是分装不是隔离**——同 uid 下 cat ../别的.env 照样拿得到。
   README 和代码注释都按分装写,不吹成边界。

3. Keychain 的授权弹窗**就是那道防线**。SecItemAdd 不传 kSecAttrAccess 时,
   默认 ACL 只允许创建它的 app 无提示读取,而在「攻击者 = 同 uid 的自动化」
   这个模型下,能区分「Hub 在读」和「security 命令在读」的只有它——
   文件权限做不到(同 uid),Touch ID 也做不到(LAContext 不参与取密钥)。
   为了消弹窗放宽 ACL,等于一条 Bash 就能取走主密钥。

几个不写下来一定会再踩的坑(已进 NOTES.md):
- .atomic 写换 inode,chmod 必须每次都做,只在创建时设一次从第二次写起就是 0644
- GCM 的 AAD 必须用文件里存下来的那段字节,重新序列化会让历史文件永久打不开
- 「钥匙不对」和「文件坏了」处置完全相反,靠 header 里的 keyID 分辨;
  EncryptedFile.save() 的 guard state.canWrite 是这套区分唯一的落地点
- LAContext 复用才有 reuse duration,每次新建等于每条密码按一次指纹
- 剪贴板定时清空必须比对 changeCount,否则用户复制的别的东西会被莫名清掉
- .env 的引号:shell / node dotenv / python-dotenv 三家的交集只有单引号;
  多行值用字面换行不能用 \n;含单引号的值没有共同解,改写成 _B64 显式降级

617 个测试全过(原 530)。真 bash `set -a; source` 验过含空格、$、反引号、
#、多行 PEM 和含单引号的值。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:45:20 +08:00

428 lines
17 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 HubJump
import HubProbe
import HubProjects
import SwiftUI
/// 用哪个编辑器打开会话目录。
///
/// 按用户实际会用的顺序探测,**只在 app 真的装了的时候才显示按钮** ——
/// 点了没反应比没有这个按钮更糟。
enum EditorLauncher {
struct Editor {
let name: String
let bundleId: String
func open(_ path: String) {
guard let app = NSWorkspace.shared
.urlForApplication(withBundleIdentifier: bundleId) else { return }
NSWorkspace.shared.open(
[URL(fileURLWithPath: path)],
withApplicationAt: app,
configuration: NSWorkspace.OpenConfiguration()
)
}
}
private static let candidates: [Editor] = [
Editor(name: "Cursor", bundleId: "com.todesktop.230313mzl4w4u92"),
Editor(name: "VS Code", bundleId: "com.microsoft.VSCode"),
Editor(name: "Zed", bundleId: "dev.zed.Zed"),
Editor(name: "Xcode", bundleId: "com.apple.dt.Xcode"),
]
/// 首个已安装的编辑器。查一次就缓存 —— `urlForApplication` 会打 LaunchServices。
static let preferred: Editor? = candidates.first {
NSWorkspace.shared.urlForApplication(withBundleIdentifier: $0.bundleId) != nil
}
}
/// 展开态顶部的会话详情卡。
///
/// 这是「点击小人显示具体信息以及运作状态」的落点:把一个会话**当下**的全部
/// 可知事实摊开 —— 它是谁、在哪个目录、哪条分支、最近提交了什么、干了多久、
/// 人在哪个终端能找到它。
///
/// 目录和分支不是装饰:**同一个项目经常多开**(几个会话跑在不同 worktree 或
/// 不同分支上),只显示项目名的话它们看起来一模一样,根本分不清点哪个。
///
/// 数据全部来自已经采集好的缓存,卡片本身不发起任何子进程调用,
/// 所以切换选中不会有任何卡顿。
struct SessionDetailCard: View {
let session: AgentSession
var workspace: WorkspaceGit?
var terminalLabel: String?
var jumpOutcome: JumpOutcome?
/// 这个会话卡住了吗、为什么。
///
/// 之前滞留信息只活在 `.nudge` 形态和折叠态的跑马灯里 —— 用户一展开列表
/// (也就是他真正要动手的地方)它就消失了,反而在最该看到的位置看不到。
var stall: StallFinding?
let onJump: () -> Void
/// 进应答态直接回答。只有 `askedQuestion` 才给这个入口。
var onAnswer: (() -> Void)?
private var accent: Color { IslandTheme.color(for: session.status) }
var body: some View {
HStack(alignment: .top, spacing: 12) {
portrait
VStack(alignment: .leading, spacing: 5) {
titleRow
locationRow
factsRow
Spacer(minLength: 2)
// 卡住时用滞留横幅**顶掉**提交记录。
// 两者都要位置,而"这件事在等你"永远比"三天前提交了什么"更急。
if stall != nil {
stallBanner
} else {
commitLog
}
}
Spacer(minLength: 4)
actions
}
.padding(12)
.frame(height: IslandMetrics.expandedDetailHeight)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(0.06))
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.stroke(accent.opacity(session.status == .idle ? 0.18 : 0.34), lineWidth: 1)
}
}
}
// MARK: 滞留横幅
/// 卡住的原因 + 能做什么。
///
/// 三段式:图标、原因短语、具体内容。短语("在问你")负责扫一眼就懂,
/// 具体内容(问题原文 / 下一件任务 / 错误摘要)负责不用跳过去就知道是什么事。
@ViewBuilder
private var stallBanner: some View {
if let stall {
let tone = StallPalette.color(for: stall.reason)
HStack(alignment: .top, spacing: 7) {
Text(stall.reason.symbol)
.font(.system(size: 11))
VStack(alignment: .leading, spacing: 2) {
Text(stall.reason.shortLabel)
.font(IslandTheme.label(11, .bold))
.foregroundStyle(tone)
Text(stallDetail(stall.reason))
.font(IslandTheme.label(10.5, .regular))
.foregroundStyle(.white.opacity(0.72))
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 4)
// 只有"在问你"给这个入口 —— 断线、待验收没有一句话就能回应的动作,
// 硬做成按钮是假的。
if case .askedQuestion = stall.reason, let onAnswer {
Button {
onAnswer()
} label: {
Text("回答").padding(.horizontal, 8)
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: tone, height: 22
))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(tone.opacity(0.14))
.overlay {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(tone.opacity(0.30), lineWidth: 1)
}
}
}
}
private func stallDetail(_ reason: StallReason) -> String {
switch reason {
case .interrupted(let text):
return StallDetector.condense(text, limit: 70)
case .askedQuestion(let question):
return question
case .awaitingDecision(let why):
return why ?? "在等你授权或选择"
case .unfinishedTasks(let pending, let running, let next):
let head = next.map { "下一件:\($0)" } ?? "还有没做完的"
return "\(head)(待办 \(pending) · 进行中 \(running))"
case .finishedAwaitingReview(let summary, let worked):
let time = worked >= 60 ? "干了 \(Int(worked / 60)) 分钟," : ""
return time + (summary ?? "完成了一轮,等你验收")
}
}
// MARK: 大号小人
/// 详情卡用 3pt/px(48pt)而不是把 32pt 的放大 —— `pixelSize` 必须是整数,
/// 非整数会产生半像素,边缘发灰,像素风立刻变成"低分辨率图片"。
private var portrait: some View {
VStack(spacing: 6) {
SpriteScreen(cornerRadius: 10) {
PixelSprite(session: session, pixelSize: 3, gridSize: 16)
}
Text(session.kind == .bg ? "后台" : "交互")
.font(IslandTheme.label(9, .medium))
.foregroundStyle(.white.opacity(0.4))
}
}
// MARK: 标题
private var titleRow: some View {
HStack(spacing: 8) {
Text(session.name ?? session.sessionId)
.font(IslandTheme.label(14, .semibold))
.foregroundStyle(.white)
.lineLimit(1)
.truncationMode(.middle)
statusPill
}
}
private var statusPill: some View {
HStack(spacing: 4) {
StatusDot(status: session.status, size: 6)
Text(statusText)
.font(IslandTheme.label(10, .semibold))
.foregroundStyle(accent)
}
.padding(.horizontal, 6)
.frame(height: 18)
.background(accent.opacity(0.14), in: .capsule)
}
private var statusText: String {
switch session.status {
case .busy: return "工作中"
case .waiting: return session.waitingFor ?? "等你输入"
case .shell: return "跑命令"
case .idle: return "空闲"
case .unknown: return session.rawStatus
}
}
// MARK: 位置:目录 + 分支
/// 当前目录和分支放同一行,因为它们回答的是同一个问题:
/// **这个会话到底在哪个工作区上干活。** 一个项目多开时这是唯一的区分手段。
private var locationRow: some View {
HStack(spacing: 8) {
HStack(spacing: 3) {
Image(systemName: "folder")
.font(.system(size: 8))
Text(displayPath)
.font(IslandTheme.mono(10))
.lineLimit(1)
// 路径截中间:截尾的话最有信息量的那一段(项目名)正好没了。
.truncationMode(.middle)
}
.foregroundStyle(.white.opacity(0.58))
// 路径比分支胶囊更该拿到宽度:分支名通常短且完整,
// 路径被截中间的话"到底是哪个 worktree"就看不出来了。
.layoutPriority(1)
.help(session.cwd)
if let branch = workspace?.info.branch {
HStack(spacing: 3) {
Image(systemName: "arrow.triangle.branch")
.font(.system(size: 8, weight: .semibold))
Text(branch)
.font(IslandTheme.mono(10, .medium))
if let changes = workspace?.info.changeCount, changes > 0 {
Text("\(changes)△")
.font(IslandTheme.mono(10, .semibold))
.foregroundStyle(IslandTheme.waiting)
}
if let ahead = workspace?.info.ahead, ahead > 0 {
Text("↑\(ahead)")
.font(IslandTheme.mono(10, .semibold))
.foregroundStyle(IslandTheme.busy)
}
}
.foregroundStyle(.white.opacity(0.7))
.padding(.horizontal, 6)
.frame(height: 17)
.background(.white.opacity(0.08), in: .capsule)
.fixedSize()
}
}
}
/// cwd 相对仓库根的位置。
///
/// 会话开在子目录时,`~/code/foo/packages/api` 里真正有区分度的是尾部;
/// 直接显示全路径会被中间那一大段吃掉宽度。仓库根之外的部分单独标出来。
private var displayPath: String {
let cwd = (session.cwd as NSString).abbreviatingWithTildeInPath
guard let root = workspace?.root, session.cwd != root else { return cwd }
let rootName = (root as NSString).lastPathComponent
let suffix = session.cwd.hasPrefix(root)
? String(session.cwd.dropFirst(root.count))
: ""
return suffix.isEmpty ? cwd : rootName + suffix
}
// MARK: 事实行
private var factsRow: some View {
HStack(spacing: 12) {
fact("运行", RelativeTime.duration(since: session.startedAt))
fact("最后活动", RelativeTime.short(from: session.updatedAt) + "前")
fact("PID", "\(session.pid)")
if let terminalLabel {
VStack(alignment: .leading, spacing: 1) {
Text("终端")
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
Text(terminalLabel)
.font(IslandTheme.label(11, .medium))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
.truncationMode(.middle)
}
.help(terminalLabel)
}
}
}
private func fact(_ label: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.38))
Text(value)
.font(IslandTheme.label(11, .medium))
.monospacedDigit()
.foregroundStyle(.white.opacity(0.82))
}
.fixedSize()
}
// MARK: 最近提交
/// 最近三条提交。
///
/// 这是判断"这个会话到底干出了什么"最直接的证据 —— 状态只说它在忙,
/// 提交历史说它忙出了什么。同项目多开时也是区分谁在推进哪条线的依据。
@ViewBuilder
private var commitLog: some View {
if let commits = workspace?.recent, !commits.isEmpty {
VStack(alignment: .leading, spacing: 2) {
ForEach(commits.prefix(2)) { commit in
HStack(spacing: 6) {
Text(commit.hash)
.font(IslandTheme.mono(9, .semibold))
.foregroundStyle(IslandTheme.busy.opacity(0.85))
Text(commit.subject)
.font(IslandTheme.label(10))
.foregroundStyle(.white.opacity(0.6))
.lineLimit(1)
Text(commit.relativeDate)
.font(IslandTheme.label(9))
.foregroundStyle(.white.opacity(0.3))
.fixedSize()
}
}
}
}
}
// MARK: 操作
private var actions: some View {
VStack(alignment: .trailing, spacing: 6) {
Button(action: onJump) {
Label("跳转终端", systemImage: "arrow.up.forward.app")
}
.buttonStyle(IslandButtonStyle(
emphasis: .prominent, tint: accent, width: 96, height: 28
))
HStack(spacing: 5) {
iconButton("folder", help: "在 Finder 中显示") {
NSWorkspace.shared.selectFile(
nil, inFileViewerRootedAtPath: session.cwd
)
}
if let editor = EditorLauncher.preferred {
iconButton("chevron.left.forwardslash.chevron.right",
help: "在 \(editor.name) 中打开") {
editor.open(session.cwd)
}
}
// 复制的是**能直接跑的一整条命令**,不是裸的 sessionId。
//
// 原来这里是"复制 sessionId",实际没有用处:sessionId 是 UUID,
// 唯一的消费者就是 `claude --resume`,而没人会把 UUID 抄进命令行。
// 直接给出可粘贴执行的命令,才真的省掉一步。
iconButton("terminal", help: "复制恢复这个会话的命令") {
copy("cd \(session.cwd) && claude --resume \(session.sessionId)")
}
}
jumpFeedback
}
}
/// 跳转结果回显。
///
/// 跳转是异步的(AppleScript 跨进程),不回显的话用户点完只能自己扭头
/// 去看终端有没有动。尤其是 `bg` 会话根本不在任何终端里,必须说清楚。
@ViewBuilder
private var jumpFeedback: some View {
if let jumpOutcome {
switch jumpOutcome {
case .focused:
feedback("已切到该会话", IslandTheme.shell, "checkmark.circle.fill")
case .terminalActivatedOnly:
feedback("只前置了终端", IslandTheme.waiting, "exclamationmark.circle.fill")
case .failed:
feedback("没能定位", IslandTheme.danger, "xmark.circle.fill")
}
}
}
private func feedback(_ text: String, _ color: Color, _ symbol: String) -> some View {
HStack(spacing: 3) {
Image(systemName: symbol).font(.system(size: 8))
Text(text).font(IslandTheme.label(9, .medium))
}
.foregroundStyle(color.opacity(0.9))
}
private func copy(_ value: String) {
Clipboard.copy(value)
}
private func iconButton(
_ symbol: String, help: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: symbol)
.font(.system(size: 10, weight: .medium))
}
.buttonStyle(IslandButtonStyle(emphasis: .secondary, width: 28, height: 22))
.help(help)
}
}