按 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>
133 lines
6.4 KiB
Swift
133 lines
6.4 KiB
Swift
import Foundation
|
||
import Security
|
||
import TXCore
|
||
import TXTransport
|
||
|
||
/// SSH publickey 认证的签名器。私钥优先放 Secure Enclave(不可导出);
|
||
/// 模拟器/无 SE 环境回退软件 P-256(可导出,落沙盒文件持久化)——**同一份 SSHSigner 代码路径**,
|
||
/// 差异仅生成参数一行,把真机验证压缩到"SE 生成/签名成功"两行。
|
||
final class SigningKeyProvider: SSHSigner, @unchecked Sendable {
|
||
private let privateKey: SecKey
|
||
let publicKeyBlob: [UInt8]
|
||
let isHardwareBacked: Bool
|
||
|
||
/// authorized_keys 行(复制到服务器即可用本 key 登录)。
|
||
var authorizedKeysLine: String {
|
||
"ecdsa-sha2-nistp256 " + Data(publicKeyBlob).base64EncodedString() + " terminalx@device"
|
||
}
|
||
|
||
private init(privateKey: SecKey, hardware: Bool) {
|
||
self.privateKey = privateKey
|
||
self.isHardwareBacked = hardware
|
||
let pub = SecKeyCopyPublicKey(privateKey)!
|
||
let x963 = SecKeyCopyExternalRepresentation(pub, nil)! as Data // 0x04||X||Y (65B)
|
||
self.publicKeyBlob = ECDSAConv.p256PublicKeyBlob(x963: [UInt8](x963))
|
||
}
|
||
|
||
// MARK: - SSHSigner
|
||
|
||
func signSSH(_ data: [UInt8]) -> [UInt8]? {
|
||
var err: Unmanaged<CFError>?
|
||
guard let der = SecKeyCreateSignature(
|
||
privateKey, .ecdsaSignatureMessageX962SHA256, Data(data) as CFData, &err) as Data? else {
|
||
NSLog("M4DBG SecKeyCreateSignature failed: \(String(describing: err?.takeRetainedValue()))")
|
||
return nil
|
||
}
|
||
return ECDSAConv.derToSSHSignature([UInt8](der)) // DER SEQUENCE{r,s} → SSH mpint r||mpint s
|
||
}
|
||
|
||
// MARK: - 生成 / 加载
|
||
|
||
private static var keyFileURL: URL {
|
||
let base = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)[0]
|
||
return URL(fileURLWithPath: base).appendingPathComponent("ssh-ecdsa-p256.key")
|
||
}
|
||
|
||
/// 从 SecKey 外部表示(x9.63 私钥,生成时存入凭据机密)重建签名器(路径 A)。
|
||
static func from(x963: Data) -> SigningKeyProvider? {
|
||
guard let key = SecKeyCreateWithData(x963 as CFData, [
|
||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||
kSecAttrKeySizeInBits: 256,
|
||
] as CFDictionary, nil) else { return nil }
|
||
return SigningKeyProvider(privateKey: key, hardware: false)
|
||
}
|
||
|
||
/// 生成一把软件 P-256 私钥,返回签名器 + 其外部表示(供凭据机密持久化)。
|
||
static func generateSoftwareP256() -> (provider: SigningKeyProvider, x963: Data)? {
|
||
guard let sw = createSoftwareKey(),
|
||
let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? else { return nil }
|
||
return (SigningKeyProvider(privateKey: sw, hardware: false), ext)
|
||
}
|
||
|
||
static func loadOrCreate() -> SigningKeyProvider? {
|
||
// 1) 软件密钥文件(模拟器持久化,跨启动稳定)。
|
||
if let data = try? Data(contentsOf: keyFileURL),
|
||
let key = SecKeyCreateWithData(data as CFData, [
|
||
kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
|
||
kSecAttrKeyClass: kSecAttrKeyClassPrivate,
|
||
kSecAttrKeySizeInBits: 256,
|
||
] as CFDictionary, nil) {
|
||
return SigningKeyProvider(privateKey: key, hardware: false)
|
||
}
|
||
// 2) Secure Enclave(真机;不可导出)。
|
||
if let se = createSecureEnclaveKey() {
|
||
return SigningKeyProvider(privateKey: se, hardware: true)
|
||
}
|
||
// 3) 回退软件 P-256(模拟器/无 SE),导出落文件。
|
||
guard let sw = createSoftwareKey() else { return nil }
|
||
if let ext = SecKeyCopyExternalRepresentation(sw, nil) as Data? {
|
||
try? ext.write(to: keyFileURL, options: .atomic)
|
||
}
|
||
return SigningKeyProvider(privateKey: sw, hardware: false)
|
||
}
|
||
|
||
/// 运行时探测 Secure Enclave 是否可用(建一把临时非持久 SE key)。
|
||
/// 未签名/模拟器返回 false(驱动 UI 灰化 Face ID 选项);签名真机自动点亮,无需改码。
|
||
static func secureEnclaveAvailable() -> Bool {
|
||
guard let access = SecAccessControlCreateWithFlags(
|
||
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return false }
|
||
let attrs: [String: Any] = [
|
||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||
kSecAttrKeySizeInBits as String: 256,
|
||
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
|
||
kSecPrivateKeyAttrs as String: [
|
||
kSecAttrIsPermanent as String: false,
|
||
kSecAttrAccessControl as String: access,
|
||
],
|
||
]
|
||
return SecKeyCreateRandomKey(attrs as CFDictionary, nil) != nil
|
||
}
|
||
|
||
private static func createSecureEnclaveKey() -> SecKey? {
|
||
guard let access = SecAccessControlCreateWithFlags(
|
||
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil) else { return nil }
|
||
let attrs: [String: Any] = [
|
||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||
kSecAttrKeySizeInBits as String: 256,
|
||
kSecAttrTokenID as String: kSecAttrTokenIDSecureEnclave,
|
||
kSecPrivateKeyAttrs as String: [
|
||
kSecAttrIsPermanent as String: true, // 持久化到 keychain(真机签名构建);未签名/模拟器会失败(-34018)→回退软件+文件
|
||
kSecAttrApplicationTag as String: "ai.athom.terminalx.sshkey".data(using: .utf8)!,
|
||
kSecAttrAccessControl as String: access,
|
||
],
|
||
]
|
||
var err: Unmanaged<CFError>?
|
||
let k = SecKeyCreateRandomKey(attrs as CFDictionary, &err)
|
||
if k == nil { NSLog("M4DBG SE key unavailable (预期于模拟器): \(String(describing: err?.takeRetainedValue()))") }
|
||
return k
|
||
}
|
||
|
||
private static func createSoftwareKey() -> SecKey? {
|
||
let attrs: [String: Any] = [
|
||
kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom,
|
||
kSecAttrKeySizeInBits as String: 256,
|
||
kSecPrivateKeyAttrs as String: [kSecAttrIsPermanent as String: false],
|
||
]
|
||
var err: Unmanaged<CFError>?
|
||
let k = SecKeyCreateRandomKey(attrs as CFDictionary, &err)
|
||
if k == nil { NSLog("M4DBG software key create failed: \(String(describing: err?.takeRetainedValue()))") }
|
||
return k
|
||
}
|
||
}
|