状态: 代码已合入 (2026-05) (PR-11a/b/c 全落). 现状回写 ../v1/GUI.md "加密 VM GUI 集成" 节.
父设计稿: ENCRYPTION.md (代码已合入) 关联 TODO: TODO.md #10 — 已 Done
PR-1 ~ PR-10b + PR-A/B/C 落地后, CLI 加密路径全闭环: create / start / encrypt / decrypt / rekey / clone / snapshot / status 都通. 但 GUI 还按"明文 VM"假设, 加密 VM 在 GUI 上完全用不了:
refreshList()走BundleIO.load→ 加密 VM 是config.yaml.enc, load fileNotFound 抛错被try?吞了 → 加密 VM 在 GUI 列表里直接不出现VMListItem强制要config: VMConfig→ 加密 VM 没解密前没 config, 整个数据模型不兼容AppModel.start(item, password: nil)默认 nil → 加密 VM 启动撞 stdin EOF → host 进程 exit 40- CreateVMDialog 没加密选项 → GUI 没法创建加密 VM
- CloneVMDialog 已显式传
password: nil(PR-B 时改的) → 加密源被 CloneManager 拒 - 没有 encrypt / decrypt / rekey 入口 → 只能跑 CLI
做 (PR-11 完整范围):
- 数据模型:
VMListItem兼容加密 VM (config 可选 / 加密 VM 走 routing JSON 拿基础字段) refreshList(): 加密 VM 走EncryptedBundleIO.detectScheme+RoutingJSON.read, 不解密- 列表显示: 侧边栏 / 详情头部 显示加密标记 (锁图标 / 字样)
- 创建加密 VM: CreateVMDialog 加 "加密" toggle + 双密码框
- 启动加密 VM: Start 路径检测加密 → prompt 密码 modal → 透传到
start(item, password) - clone 加密 VM: CloneVMDialog 检测加密源 → prompt 密码 + warning + 透传到
CloneManager - encrypt / decrypt / rekey: 详情页 actions / 菜单加三个入口 + 三个新 dialog
- 错误处理: 错密码 / 加密事务进行中 / 不可中断 — 全走 ErrorDialog
不做 (out of scope, 推后):
- VZ-sparsebundle 加密 (推后跟 ENCRYPTION.md v2.4 一致)
- 密码强度评估 / Keychain 缓存 (v2.2 决策不缓存, 强制每次输)
- import-disk + encrypt 一步到位 (TODO #16, 单独项)
- secure-erase delete (TODO #11, 单独项)
| 方案 | 实现 | 优点 | 缺点 | 选用 |
|---|---|---|---|---|
| A. config 改可选 + 加 isEncrypted/displayName/guestOS 兜底字段 | config: VMConfig? + 加密 VM 从 routing 派生显示用字段 |
改动最小, 兼容现有 view 逻辑 | views 处处 if let config 打补丁 |
✅ |
| B. 改 enum: plaintext(VMConfig) / encrypted(RoutingMetadata) | 类型严格, 编译器逼 view 处理两种 case | 重构面大 | 现有 30+ 处 view 全改 | ✗ |
| C. encrypted VM 不进列表, 单独 sidebar section | 不混合 | UX 割裂 | 用户总数 / sort 不一致 | ✗ |
选 A. 理由:
- 加密 VM 在 GUI 上的"基础信息" (displayName / guestOS / id) 都能从 routing JSON 拿到, 走兜底
- 大部分 view 关心
displayName/runState/isEncrypted, 这几个字段无 config 也能给 - 需要 config 的视图 (CPU / 内存 / 磁盘大小) 显式
if let cfg = item.config处理 — 加密 VM 显示 "解锁后查看" 占位
| 方案 | 实现 | UX | 选用 |
|---|---|---|---|
| A. HVMModal 弹层 + HVMTextField (secure) | 走自家组件 (CLAUDE.md UI 约束) | 跟其他 dialog 一致 | ✅ |
| B. NSAlert + 输入框 | macOS 原生 | 违反 CLAUDE.md "禁止 NSAlert" | ✗ |
| C. inline 在详情页内嵌 | 不弹层 | 用户可能没看到密码框 / 多 VM 同时输混乱 | ✗ |
选 A. 重用现有 HVMModal + HVMTextField (要 isSecure: Bool 参数, 现有支持).
| 方案 | 实现 | 选用 |
|---|---|---|
| A. 详情页 "加密" 子区 + 三个按钮 (条件显示) | 加密 VM 显示 decrypt + rekey; 明文显示 encrypt | ✅ |
| B. 全局菜单栏 / context menu | 隐蔽, 用户找不到 | ✗ |
| C. 装机向导第二步 | 跟 lifecycle 不一致 (encrypt 是 in-place 老 VM 转换) | ✗ |
选 A. 加密区跟 "网络" / "磁盘" 子区平行. 三个按钮分别打开 EncryptDialog / DecryptDialog / RekeyDialog.
CLI 走 SignalGuard 拦 Ctrl-C; GUI 没 Ctrl-C 概念, 但用户可能:
- 点 X 关 dialog → 中断事务?
- 关闭 HVM 主窗口 / Cmd-Q
主张: encrypt / decrypt / rekey dialog 强制不可关 (HVMModal closeAction = nil 隐藏 X), 文案 "操作进行中, 请等待结束". 用户必须等. 跟 CLI SignalGuard 第一次警告等同.
后台跑 (Task.detached), 完成回主线程切 dialog 状态 → done / error.
public struct VMListItem: Identifiable, Sendable {
public let id: UUID // routing.vmId 或 config.id
public let bundleURL: URL
public let displayName: String // routing.displayName 或 config.displayName
public let guestOS: GuestOSType // 加密 VM 走 routing 兜底默认 .linux (因 routing 不存 guestOS — 见下)
public let config: VMConfig? // ← 改可选; 加密 VM 解锁前 nil
public let encryptionScheme: EncryptionSpec.EncryptionScheme? // ← 新增; nil = 明文
public var runState: String
public var isEncrypted: Bool { encryptionScheme != nil }
}注: routing JSON 不存 guestOS (因 v2 schema 没包). 兜底策略:
- 加密 VM 解锁前: 显示 "encrypted" placeholder, 详情页提示"启动后/解锁后显示"
- 加密 VM 运行中: 走 IPC 拿
IPCStatusPayload.guestOS(PR-10b 已支持) - routing JSON 加 guestOS 字段升 v3 schema (跟 ENCRYPTION.md 升级链); 或干脆不补, 让用户接受"加密 VM 列表显示 'encrypted'"
主张不补 guestOS 字段 (D27): UX 上加锁图标 + "加密 VM" 标识比 guestOS 类型重要. 后续若要 guestOS, 跑 IPC 拿运行时数据.
for u in urls {
let scheme = EncryptedBundleIO.detectScheme(at: u)
if let scheme = scheme {
// 加密路径
let routingURL: URL = ... // 按 scheme 拿
guard let routing = try? RoutingJSON.read(from: routingURL) else { continue }
let item = VMListItem(
bundleURL: u,
id: routing.vmId,
displayName: routing.displayName,
guestOS: .linux, // 占位; 详情页显示 "启动后查看"
config: nil,
encryptionScheme: scheme,
runState: busy ? "running" : "stopped"
)
items.append(item)
} else {
// 现有明文路径 BundleIO.load
...
}
}新文件 app/Sources/HVM/UI/Dialogs/EncryptionPasswordDialog.swift:
struct EncryptionPasswordDialog: View {
let displayName: String
let onSubmit: (String) -> Void
let onCancel: () -> Void
@State private var password: String = ""
@State private var error: String? = nil
var body: some View {
HVMModal(title: "解锁加密 VM", closeAction: onCancel) {
VStack(spacing: HVMSpace.md) {
Text("VM \"\(displayName)\" 已加密. 输入密码继续.")
HVMTextField("密码", text: $password, isSecure: true)
if let err = error { Text(err).foregroundColor(.red) }
}
} footer: {
HStack {
Button("取消", action: onCancel)
.buttonStyle(GhostButtonStyle())
Spacer()
Button("解锁") { onSubmit(password) }
.buttonStyle(PrimaryButtonStyle())
.disabled(password.isEmpty)
}
}
}
}AppModel.start(item) 改:
public func start(_ item: VMListItem) async throws {
if item.isEncrypted {
// 弹密码 modal, 等待用户输入 (绑 @Published var pendingPasswordPrompt: ...)
let password = await promptPassword(for: item)
guard let pw = password else { return } // 用户取消
try await startInternal(item, password: pw)
} else {
try await startInternal(item, password: nil)
}
}加 @State var enableEncryption: Bool + @State var password / passwordConfirm: String. Toggle 显示双密码框. 校验:
- 密码长度 ≥ 4
- 两次一致
提交时:
enableEncryption == false→ 现有BundleIO.create + DiskFactory.create路径enableEncryption == true→EncryptedBundleIO.create(parentDir, displayName, password, baseConfig, scheme: .qemuPerfile)+QcowLuksFactory.create用 sub.qcow2Disk +OVMFVarsLuksFactory.create(Win) +EncryptedConfigIO.save
参考 CLI CreateCommand.swift 的 createEncryptedVM 函数.
之前显式 password: nil 改成: 检测源是否加密 → 加密则 prompt + warning, 跟 CloneCommand.swift 加密分支同款 UX.
DetailContainerView 加 "加密" 子节, 平行于 "网络" / "磁盘" / "TPM":
- 明文 VM: 显示 "明文" +
[加密 → 加密这台 VM]按钮 - 加密 VM: 显示 scheme + KDF 参数 (从 routing JSON) + 两按钮
[改密][转明文]
按钮分别打开:
EncryptVMDialog(新): prompt 双密码 + warning, 跑EncryptVMOperation.encrypt后台 taskDecryptVMDialog(新): prompt 密码 + warning, 跑DecryptVMOperation.decryptRekeyVMDialog(新): prompt 旧密 + 双新密 + warning (TPM 重置), 跑RekeyVMOperation.rekey
三个 dialog 共用 progress modal 模式: 提交后 closeAction = nil + spinner + 实时打 progressLog 行 (类似 InstallDialog).
- 错密码 →
HVMError.encryption(.wrongPassword)在 dialog 内 inline 显示, 让用户重试 (不退出 dialog) - 加密事务跑中 (rekey 等) crash → 重启 GUI 后, refreshList 仍能识别加密 VM, 用户可重试
| 编号 | 风险 | 验证 | 阻断 |
|---|---|---|---|
| R7 | refreshList 加密分支频繁读 routing JSON I/O 开销 | mtime 缓存复用 (跟现状明文路径一样); routing JSON < 1KB, 实测可忽略 | 已论证 |
| R8 | 加密 VM 解锁中 GUI 主窗口被关 → password modal dismiss 但 unlock 已 in-flight | promptPassword 用 continuation, dismiss → cancel; unlock 抛错 → ErrorDialog | P1 |
| R9 | encrypt/decrypt/rekey 后台 Task 跑期间 GUI 进程被 Cmd-Q | atexit cleanup (PR-C 已加) 跑掉临时目录; 但 GUI 强 quit 路径不调用 atexit (exit(_) 才走). NSApplication 走 terminate: 默认会调 atexit |
P1, 实测 |
| R10 | 多 VM 同时并发加密事务 GUI 性能 | Task.detached 后台跑, 不阻 UI; SignalGuard 嵌套 reentrant 计数 OK | 已论证 |
| PR | 内容 | 时间盒 | 状态 |
|---|---|---|---|
| PR-11a | 数据模型重构: VMListItem.config 改可选 + 加 encryptionScheme. refreshList 加密分支走 routing JSON. 现有 view 用 config 处加 if let 兜底. SidebarView / DetailBars 显示加密标记 (锁图标) |
0.5 天 | 待开 |
| PR-11b | 启动加密 VM: 新 EncryptionPasswordDialog. AppModel.start 检测 → prompt → password 透传到 spawnExternalHost. 错密码 inline 显示 |
0.5 天 | 待开 |
| PR-11c | CreateVMDialog 加密: toggle + 双密码框 + 校验 + 提交走 EncryptedBundleIO.create + 各加密 factory | 0.5 天 | 待开 |
| PR-11d | CloneVMDialog 加密源: prompt + warning + 透传 password. 复用 EncryptionPasswordDialog | 0.3 天 | 待开 |
| PR-11e | 详情页加密区 + EncryptVMDialog / DecryptVMDialog / RekeyVMDialog. 后台 Task + progress 显示 | 1 天 | 待开 |
| PR-11f | 真机 e2e: 创建加密 VM → 启动 → encrypt 老 VM → decrypt → rekey → clone → snapshot. 错密码 / 强 quit 测试 | 0.5 天 | 待开 |
| PR-11g | docs 回写: TODO #10 标 Done; CLAUDE.md GUI 加密约束节; v1/GUI.md 同步 | 0.2 天 | 与 PR-11f 合 |
合计 ~3.5 天 / 1 人. PR-11a 是基础, 其他 b/c/d/e 可在 a 之上并行起草 (但实际单人串行).
每个 PR make build 通过 + 真机点一遍涉及功能.
| 编号 | 问题 | 主张 | 决策时机 |
|---|---|---|---|
| D23 | VMListItem 数据模型 | A: config 改可选 + isEncrypted 字段 | 本稿 |
| D24 | 启动密码 modal 形态 | A: HVMModal + HVMTextField (secure) | 本稿 |
| D25 | encrypt/decrypt/rekey 入口 | A: 详情页加密区 + 三按钮 | 本稿 |
| D26 | 加密事务期 dialog 不可关 | 主张: closeAction = nil, 跟 CLI SignalGuard 等价 | 本稿 |
| D27 | routing JSON 是否补 guestOS (升 v3 schema) | 不补 — 显示 "加密 VM" 占位; 启动后 IPC 拿真实 guestOS | 本稿 |
| D28 | 加密事务跑期 GUI 主窗口 Cmd-Q 行为 | NSApplicationDelegate applicationShouldTerminate 检事务进行中 → 弹 confirm "操作进行中, 强退可能损坏数据"; 用户选 "等待" 或 "强退" |
本稿 |
| D29 | 列表锁图标用 SF Symbol 还是自绘 | lock.fill SF Symbol; 解锁运行中可加点动画 |
已决 |
初稿. 关键决策:
- D23 选 A (config 可选 + 兜底字段) — 改动最小
- D24/25/26 都走 HVMModal + 详情页内嵌, 跟 CLAUDE.md UI 约束一致
- D27 不补 routing JSON guestOS, 维持 schema v2
- 父稿 ENCRYPTION.md v2.4 PR-11
- TODO 索引 TODO.md #10
- 实现参考 (CLI 已落):
- CreateCommand.swift (createEncryptedVM)
- StartCommand.swift (PasswordPrompt + spawn)
- EncryptCommand.swift / DecryptCommand / RekeyCommand
- CloneCommand.swift (加密源 prompt)
- UI 约束: CLAUDE.md "UI 控件使用约束" 节