按 Claude Design 定稿(归档在 docs/design/)重做 UI,并把 tmux 从 「从不自动进」修成「连接前探测 → 让用户选会话」。 设计令牌与导航地基 - Theme 拆三层:TXAccent(恒定 blurple) / TXChrome(中性阶×4 表) / TXFlavor(Catppuccin 终端) - vendor JetBrains Mono 四权重(附 OFL 许可) - AppRouter + SessionManager:多会话并存,路由与会话生命周期解耦 - SessionCanvas 常驻挂载会话 surface(摘除即丢内容,也是缩回动画的前提) 按设计稿落地的屏 - 沉浸轨道页:56pt 轨道 + 浮起标题/状态胶囊 + 侧边栏三态(遮罩不 resize、Pin 各一次) - 首页:活动会话卡(readViewportText 文本镜像)+ 主机网格 + 筛选 chips - 关闭二次确认(tmux 仅断开 / 原生窗口两套文案)、分屏菜单、pane 拖拽条 - 空状态:首次运行 / 无会话 / 搜索无命中 / 连接中·失败·已关闭 tmux 真实链路(真机查出并修掉 4 个 bug) - 全代码库从来没人发起 attach → 连接前探测 + 会话选择器(接回 / 新建 / 原生终端) - format 分隔符 tab 经 PTY 变成下划线 → 改用 |:| - controller 变化不冒泡到 session → Combine 转发(否则数据解析对了 UI 不刷新) - 当前会话名不能靠 session_attached 反推 → 改用 display-message 传输层:SSH connect 加超时(原来阻塞到系统 TCP 超时 75s+) 无头验证设施:假会话 fixture · terminalx://ui/* 驱动 · 横屏截图脚本 · 对拍走查法 TXCore 45 tests 绿(新增 5 个会话探测单测) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
79 lines
3.3 KiB
Swift
79 lines
3.3 KiB
Swift
import Foundation
|
||
|
||
/// 凭据存储抽象:meta 与机密分离。
|
||
/// 让「未签名文件后端」与「签名后 Keychain 后端」可无缝替换——列表只用 `list()`(纯 meta),
|
||
/// 机密经 `secret(for:)` 按需单条取(Keychain 后端天然 meta 在 JSON、机密在 SecItem)。
|
||
@MainActor
|
||
protocol CredentialStore: AnyObject {
|
||
func list() -> [Credential]
|
||
func secret(for id: UUID) -> CredentialSecret?
|
||
func upsert(_ cred: Credential, secret: CredentialSecret?) // secret == nil 时仅改 meta
|
||
func delete(_ id: UUID)
|
||
}
|
||
|
||
/// 未签名构建的文件后端。
|
||
///
|
||
/// 为何不自造加密:无根信任(无 Keychain/SE 兜底存主密钥)则本地对称加密是伪安全——
|
||
/// 能读机密文件者同样能读密钥文件。威胁模型为开发自用工具 / 模拟器 / 非越狱设备,
|
||
/// 沙盒明文与现有 host 明文密码一致即可。做三件几乎免费的加固:
|
||
/// 0600 权限、`isExcludedFromBackup`(防机密进 iCloud/iTunes 备份)、`FileProtectionType.complete`(锁屏加密)。
|
||
/// 信封版本化(`scheme: "plaintext-v1"`)便于签名后按 scheme 分派迁移。
|
||
@MainActor
|
||
final class FileCredentialStore: ObservableObject, CredentialStore {
|
||
@Published private(set) var credentials: [Credential] = []
|
||
|
||
private let url: URL
|
||
private var secrets: [UUID: CredentialSecret] = [:]
|
||
|
||
private struct Entry: Codable { var meta: Credential; var secret: CredentialSecret }
|
||
private struct Envelope: Codable { var scheme: String; var entries: [Entry] }
|
||
private static let scheme = "plaintext-v1"
|
||
|
||
init() {
|
||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
try? FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
|
||
url = base.appendingPathComponent("credentials.json")
|
||
load()
|
||
}
|
||
|
||
func list() -> [Credential] { credentials }
|
||
|
||
func secret(for id: UUID) -> CredentialSecret? { secrets[id] }
|
||
|
||
func upsert(_ cred: Credential, secret: CredentialSecret?) {
|
||
if let i = credentials.firstIndex(where: { $0.id == cred.id }) {
|
||
credentials[i] = cred
|
||
} else {
|
||
credentials.append(cred)
|
||
}
|
||
if let secret { secrets[cred.id] = secret }
|
||
persist()
|
||
}
|
||
|
||
func delete(_ id: UUID) {
|
||
credentials.removeAll { $0.id == id }
|
||
secrets[id] = nil
|
||
persist()
|
||
}
|
||
|
||
// MARK: - 持久化
|
||
|
||
private func load() {
|
||
guard let data = try? Data(contentsOf: url),
|
||
let env = try? JSONDecoder().decode(Envelope.self, from: data) else { return }
|
||
credentials = env.entries.map(\.meta)
|
||
secrets = Dictionary(uniqueKeysWithValues: env.entries.map { ($0.meta.id, $0.secret) })
|
||
}
|
||
|
||
private func persist() {
|
||
let entries = credentials.map { Entry(meta: $0, secret: secrets[$0.id] ?? .none) }
|
||
let env = Envelope(scheme: Self.scheme, entries: entries)
|
||
guard let data = try? JSONEncoder().encode(env) else { return }
|
||
try? data.write(to: url, options: [.atomic, .completeFileProtection])
|
||
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||
var u = url
|
||
var rv = URLResourceValues(); rv.isExcludedFromBackup = true
|
||
try? u.setResourceValues(rv)
|
||
}
|
||
}
|