按 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>
85 lines
3.4 KiB
Swift
85 lines
3.4 KiB
Swift
import Foundation
|
||
import TXCore
|
||
|
||
/// 已信任 host key 的展示条目(设置 → Known Hosts)。
|
||
struct KnownHostEntry: Identifiable {
|
||
let id: String // accountKey "egress|host|port"
|
||
let egress: String
|
||
let host: String
|
||
let port: Int
|
||
let fingerprint: String
|
||
}
|
||
|
||
/// known_hosts pin 存储(文件后端)。
|
||
///
|
||
/// 为何用文件而非 Keychain:本 app 当前未签名(CODE_SIGNING_ALLOWED=NO),无 keychain-access-group
|
||
/// 权限 → SecItem 全返 errSecMissingEntitlement(-34018)。而 host key pin 是**公钥完整性数据、非机密**,
|
||
/// 存于 app 沙盒文件对 TOFU 足够(威胁模型是 MITM 换 key,沙盒文件完整性对非越狱设备与 Keychain 相当)。
|
||
/// 生产签名构建可换 Keychain 加固(另留 KnownHostsStore 协议便于替换)。
|
||
///
|
||
/// 格式:JSON `{ "egress|host|port": { "blob": "<base64>", "type": <int> } }`,
|
||
/// 落 Application Support/known-hosts.json。
|
||
final class KeychainKnownHostsStore: KnownHostsStore, @unchecked Sendable {
|
||
private let lock = NSLock()
|
||
private let url: URL
|
||
private var cache: [String: [String: Any]]
|
||
|
||
init() {
|
||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||
try? FileManager.default.createDirectory(atPath: base, withIntermediateDirectories: true)
|
||
url = URL(fileURLWithPath: base).appendingPathComponent("known-hosts.json")
|
||
if let data = try? Data(contentsOf: url),
|
||
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] {
|
||
cache = obj
|
||
} else {
|
||
cache = [:]
|
||
}
|
||
}
|
||
|
||
func lookup(_ triple: HostTriple) -> HostKeyRecord? {
|
||
lock.lock(); defer { lock.unlock() }
|
||
guard let entry = cache[triple.accountKey],
|
||
let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64),
|
||
let type = entry["type"] as? Int else { return nil }
|
||
return HostKeyRecord(blob: blob, keyType: Int32(type))
|
||
}
|
||
|
||
func pin(_ triple: HostTriple, record: HostKeyRecord) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
cache[triple.accountKey] = ["blob": record.blob.base64EncodedString(), "type": Int(record.keyType)]
|
||
persist()
|
||
}
|
||
|
||
func remove(_ triple: HostTriple) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
cache[triple.accountKey] = nil
|
||
persist()
|
||
}
|
||
|
||
/// 已信任 host key 列表(供设置里查看/删除)。
|
||
func entries() -> [KnownHostEntry] {
|
||
lock.lock(); defer { lock.unlock() }
|
||
return cache.compactMap { key, entry in
|
||
guard let b64 = entry["blob"] as? String, let blob = Data(base64Encoded: b64) else { return nil }
|
||
let parts = key.split(separator: "|", maxSplits: 2).map(String.init)
|
||
return KnownHostEntry(
|
||
id: key,
|
||
egress: parts.count > 0 ? parts[0] : "",
|
||
host: parts.count > 1 ? parts[1] : "",
|
||
port: parts.count > 2 ? (Int(parts[2]) ?? 0) : 0,
|
||
fingerprint: HostKey.opensshFingerprint(blob))
|
||
}.sorted { $0.host < $1.host }
|
||
}
|
||
|
||
func removeKey(_ id: String) {
|
||
lock.lock(); defer { lock.unlock() }
|
||
cache[id] = nil
|
||
persist()
|
||
}
|
||
|
||
private func persist() {
|
||
guard let data = try? JSONSerialization.data(withJSONObject: cache) else { return }
|
||
try? data.write(to: url, options: .atomic)
|
||
}
|
||
}
|