From 2360a90d71e76ce9cb0280779b06e0f89acec0c2 Mon Sep 17 00:00:00 2001 From: easyvibecoding Date: Sat, 21 Mar 2026 01:12:07 +0800 Subject: [PATCH] feat: implement Linked Key Groups with sequential paste MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add full Linked Key Groups feature allowing users to paste multiple related API keys (e.g., AWS Access Key ID + Secret Key) sequentially with a single shortcut. Core changes: - LinkedGroup model: upgrade from simple keyIds to GroupEntry with fieldLabel and sortOrder - SequentialPasteEngine: pre-fetches all keys from Keychain before paste sequence to avoid auth dialog interruption, simulates ⌘V→Tab between fields with CGEvent modifier isolation - VaultManager: full CRUD (create/read/update/delete) for linked groups - IPCServer: request_paste_group handler (sequential + selectField) - AppState: group-aware paste via ⌃⌥⌘[1-9] hotkey Settings UI: - Keys tab: Linked Groups section with paste mode badge and field flow - Add Group sheet: multi-select keys with ordering and field labels - Edit Group sheet: modify keys, labels, and paste mode - Key rows show linked group membership Shortcut change: - Paste key by index: ⌃⌥[1-9] → ⌃⌥⌘[1-9] (avoid system conflicts) - Updated all docs (zh-TW + en) and CLAUDE.md Tests: - Updated VaultManager tests for GroupEntry API - Added tests: sequential mode, empty group auto-deletion Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 3 + docs/01-product-spec/implementation-status.md | 6 +- docs/01-product-spec/roadmap.md | 2 +- .../swift-core-modules.md | 2 +- docs/04-ui-ux/floating-toolbox.md | 2 +- docs/04-ui-ux/keyboard-shortcuts.md | 2 +- docs/04-ui-ux/smart-key-extraction.md | 17 +- .../01-product-spec/implementation-status.md | 6 +- docs/en/01-product-spec/roadmap.md | 2 +- .../swift-core-modules.md | 2 +- docs/en/04-ui-ux/floating-toolbox.md | 2 +- docs/en/04-ui-ux/keyboard-shortcuts.md | 2 +- .../swift-core/DemoSafe/App/AppState.swift | 26 +- .../DemoSafe/Models/LinkedGroup.swift | 18 +- .../Clipboard/SequentialPasteEngine.swift | 147 +++++++ .../Services/Hotkey/HotkeyManager.swift | 6 +- .../DemoSafe/Services/IPC/IPCServer.swift | 83 +++- .../DemoSafe/Services/VaultManager.swift | 51 ++- .../Views/FloatingToolbox/ToolboxState.swift | 27 +- .../Views/Settings/SettingsView.swift | 361 +++++++++++++++++- .../DemoSafeTests/VaultManagerTests.swift | 54 ++- .../src/commands/paste-key.ts | 2 +- 22 files changed, 770 insertions(+), 53 deletions(-) create mode 100644 packages/swift-core/DemoSafe/Services/Clipboard/SequentialPasteEngine.swift diff --git a/CLAUDE.md b/CLAUDE.md index 4bd7086..13ce447 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,6 +97,9 @@ These are absolute rules — never violate them: - **Turbo navigation pre-hide**: `turbo:before-render` listener in pre-hide.ts hides key elements in incoming body BEFORE Turbo renders. Prevents GitHub PAT flash on partial page transition. - **OpenAI pre-hide scope**: preHideCSS must NOT include `td.api-key-token .api-key-token-value` — these are truncated previews (sk-...QngA) that never match full patterns, causing permanent hidden state. - **Toast duration**: 25 seconds (changed from 10s for better visibility during demos). +- **Sequential paste pre-fetch**: `SequentialPasteEngine` pre-fetches ALL key values from Keychain before starting the paste sequence. This ensures Keychain auth dialogs (if any) appear upfront, not between ⌘V→Tab→⌘V steps. Phase 2 writes directly to `NSPasteboard`, bypassing `ClipboardEngine.copyToClipboard`. +- **CGEvent modifier isolation**: When simulating ⌘V followed by Tab, the Tab `CGEvent` must explicitly set `flags = []` to clear residual Command modifier. Without this, the system interprets Tab as ⌘Tab (app switcher). Use `CGEventSource(stateID: .combinedSessionState)` for isolated event sources. +- **Paste key shortcut**: Changed from `⌃⌥[1-9]` to `⌃⌥⌘[1-9]` to avoid conflicts with system/app shortcuts. All ⌃⌥⌘ shortcuts are consistent: D (demo), V (capture), [1-9] (paste). ## Documentation diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index bdefef7..156beac 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -20,7 +20,7 @@ | ClipboardEngine | ✅ | copy + autoClear + detectKeys 完成 | | MaskingCoordinator | ✅ | isDemoMode / activeContext / pattern 匹配完成 | | IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode / nmh clientType | -| HotkeyManager | ✅ | `⌃⌥⌘D` toggle、`⌃⌥Space` hold 偵測、`⌃⌥[1-9]` paste、flagsChanged 監聽 | +| HotkeyManager | ✅ | `⌃⌥⌘D` toggle、`⌃⌥Space` hold 偵測、`⌃⌥⌘[1-9]` paste、flagsChanged 監聽 | | Floating Toolbox (HUD) | ✅ | NSPanel 浮動視窗、hold-to-search、Scheme B 鎖定、↑↓ 導航 | | ToolboxState (ViewModel) | ✅ | 搜尋過濾、選取狀態、release/confirm/dismiss 邏輯 | | FloatingToolboxController | ✅ | NSPanel 管理、游標定位、鎖定模式 makeKey | @@ -33,7 +33,7 @@ | 功能 | 對應 Spec 章節 | 優先順序 | 備註 | |------|--------------|---------|---------| | `⌃⌥⌘V` capture clipboard | Spec §4.4 | 中 | HotkeyManager 端尚未掛載 ClipboardEngine.detectKeys | -| Linked Key Groups (sequential paste) | Spec §6.3 | 中 | `LinkedGroup`/`GroupEntry` 結構未建立 | +| ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~中~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` 完成、Settings UI 群組管理(CRUD)、`request_paste_group` IPC handler | | Shortcut conflict detection | Spec §4.4 | 低 | | | Import / Export vault | Spec §9.1 | 低 | | @@ -129,7 +129,7 @@ ### Phase 2: 剪貼簿 + 快捷鍵 ✅ 6. ~~ClipboardEngine~~ ✅ 7. ~~HotkeyManager(hold 偵測 + flagsChanged + 字元轉發)~~ ✅ -8. ~~Floating Toolbox HUD(NSPanel + hold-to-search + Scheme B 鎖定 + `⌃⌥[1-9]` paste)~~ ✅ +8. ~~Floating Toolbox HUD(NSPanel + hold-to-search + Scheme B 鎖定 + `⌃⌥⌘[1-9]` paste)~~ ✅ ### Phase 3: IPC + VS Code Extension ✅ 9. ~~IPCServer~~ ✅ diff --git a/docs/01-product-spec/roadmap.md b/docs/01-product-spec/roadmap.md index 520bcd2..8e31de6 100644 --- a/docs/01-product-spec/roadmap.md +++ b/docs/01-product-spec/roadmap.md @@ -7,7 +7,7 @@ - macOS Menu Bar App (Swift/SwiftUI),Key 儲存基於 Keychain - 展示模式切換與視覺指示 - 浮動工具箱,支援按住搜尋與方案 B 鎖定 -- 鍵盤快捷鍵:`⌃⌥Space`、`⌃⌥⌘D`、`⌃⌥[1-9]`、`⌃⌥⌘V` +- 鍵盤快捷鍵:`⌃⌥Space`、`⌃⌥⌘D`、`⌃⌥⌘[1-9]`、`⌃⌥⌘V` - VS Code Extension,使用 Decoration API 遮蔽 - 雙層 Key 階層與手動 Key 輸入 - 基本關聯 Key 群組(順序貼上) diff --git a/docs/02-technical-architecture/swift-core-modules.md b/docs/02-technical-architecture/swift-core-modules.md index 151d2b9..437452c 100644 --- a/docs/02-technical-architecture/swift-core-modules.md +++ b/docs/02-technical-architecture/swift-core-modules.md @@ -125,7 +125,7 @@ DemoSafeApp (SwiftUI) |--------|------|------| | `⌃⌥Space` | `toggleToolbox()` | 顯示/隱藏浮動工具箱 | | `⌃⌥⌘D` | `toggleDemoMode()` | 切換展示/一般模式 | -| `⌃⌥[1-9]` | `pasteKeyByIndex()` | 依快捷鍵索引貼上金鑰 | +| `⌃⌥⌘[1-9]` | `pasteKeyByIndex()` | 依快捷鍵索引貼上金鑰 | | `⌃⌥⌘V` | `captureClipboard()` | 掃描並儲存當前剪貼簿內容 | ### 按住偵測邏輯 diff --git a/docs/04-ui-ux/floating-toolbox.md b/docs/04-ui-ux/floating-toolbox.md index 6b19657..eeb03cd 100644 --- a/docs/04-ui-ux/floating-toolbox.md +++ b/docs/04-ui-ux/floating-toolbox.md @@ -12,7 +12,7 @@ | 搜尋/過濾 | 繼續按住 + 輸入字元 | Key 清單即時過濾 | | 快速貼上(單一結果) | 僅剩 1 個結果時放開 | Key 貼入,工具箱消失 | | 從多個結果中選擇 | 放開 → 工具箱鎖定;用 `↑↓` + `Enter` | 按 Enter 貼入 Key,Esc 取消 | -| 依編號直接貼上 | 按 `⌃⌥[1-9]` | 立即貼入第 N 個 Key,無需工具箱 | +| 依編號直接貼上 | 按 `⌃⌥⌘[1-9]` | 立即貼入第 N 個 Key,無需工具箱 | ## 鎖定行為(方案 B) diff --git a/docs/04-ui-ux/keyboard-shortcuts.md b/docs/04-ui-ux/keyboard-shortcuts.md index 011f21a..acb7fa5 100644 --- a/docs/04-ui-ux/keyboard-shortcuts.md +++ b/docs/04-ui-ux/keyboard-shortcuts.md @@ -6,7 +6,7 @@ |------|-----------|---------| | 切換浮動工具箱 | `⌃⌥Space` | 左手組合鍵,與應用程式衝突極少 | | 切換展示模式 | `⌃⌥⌘D` | 三個修飾鍵防止意外觸發 | -| 直接貼上第 N 個 Key | `⌃⌥[1-9]` | 已知 Key 位置的最快路徑 | +| 直接貼上第 N 個 Key | `⌃⌥⌘[1-9]` | 已知 Key 位置的最快路徑 | | 快速擷取剪貼簿內容 | `⌃⌥⌘V` | 貼上 + 自動解析入管理器 | ## 自訂設定 diff --git a/docs/04-ui-ux/smart-key-extraction.md b/docs/04-ui-ux/smart-key-extraction.md index 0d71aea..689e27d 100644 --- a/docs/04-ui-ux/smart-key-extraction.md +++ b/docs/04-ui-ux/smart-key-extraction.md @@ -136,29 +136,32 @@ struct LinkedGroup: Codable, Identifiable { let id: UUID var label: String // 如 "AWS Production" var entries: [GroupEntry] // 有序的 Key 列表 - var pasteMode: PasteMode // .sequential 或 .fieldSelect + var pasteMode: PasteMode // .sequential 或 .selectField + var createdAt: Date } struct GroupEntry: Codable { let keyId: UUID - let fieldLabel: String // 如 "Access Key ID"、"Secret Key" + var fieldLabel: String // 如 "Access Key ID"、"Secret Key" var sortOrder: Int } enum PasteMode: String, Codable { - case sequential // 按 Tab 自動依序貼入 - case fieldSelect // 顯示選單讓使用者選擇 + case selectField // 使用者選擇要貼上的欄位 + case sequential // 按 Tab 自動依序貼入 } ``` ### 順序貼上模擬 ``` -使用者觸發 LinkedGroup paste(⌃⌥[N] 對應群組) +使用者觸發 LinkedGroup paste(⌃⌥⌘[N] 對應群組) ↓ -1. 貼上 entries[0].value(Access Key ID) +SequentialPasteEngine 預先從 Keychain 取出所有 key(避免中途跳認證窗) + ↓ +1. 寫入 entries[0].value 到剪貼簿 → 模擬 ⌘V 2. 模擬 Tab 鍵 -3. 貼上 entries[1].value(Secret Key) +3. 寫入 entries[1].value 到剪貼簿 → 模擬 ⌘V ↓ 完成,兩個欄位同時填入 ``` diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index b933781..812ca2e 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -20,7 +20,7 @@ | ClipboardEngine | ✅ | copy + autoClear + detectKeys completed | | MaskingCoordinator | ✅ | isDemoMode / activeContext / pattern matching completed | | IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode / nmh clientType | -| HotkeyManager | ✅ | `⌃⌥⌘D` toggle, `⌃⌥Space` hold detection, `⌃⌥[1-9]` paste, flagsChanged listener | +| HotkeyManager | ✅ | `⌃⌥⌘D` toggle, `⌃⌥Space` hold detection, `⌃⌥⌘[1-9]` paste, flagsChanged listener | | Floating Toolbox (HUD) | ✅ | NSPanel floating window, hold-to-search, Scheme B lock, ↑↓ navigation | | ToolboxState (ViewModel) | ✅ | Search filtering, selection state, release/confirm/dismiss logic | | FloatingToolboxController | ✅ | NSPanel management, cursor positioning, locked mode makeKey | @@ -33,7 +33,7 @@ | Feature | Spec Section | Priority | Notes | |---------|-------------|----------|---------| | `⌃⌥⌘V` capture clipboard | Spec §4.4 | Medium | HotkeyManager not yet wired to ClipboardEngine.detectKeys | -| Linked Key Groups (sequential paste) | Spec §6.3 | Medium | `LinkedGroup`/`GroupEntry` structs not yet created | +| ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~Medium~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` complete, Settings UI group management (CRUD), `request_paste_group` IPC handler | | Shortcut conflict detection | Spec §4.4 | Low | | | Import / Export vault | Spec §9.1 | Low | | @@ -129,7 +129,7 @@ Per Spec §9 Roadmap: ### Phase 2: Clipboard + Hotkeys ✅ 6. ~~ClipboardEngine~~ ✅ 7. ~~HotkeyManager (hold detection + flagsChanged + keystroke forwarding)~~ ✅ -8. ~~Floating Toolbox HUD (NSPanel + hold-to-search + Scheme B lock + `⌃⌥[1-9]` paste)~~ ✅ +8. ~~Floating Toolbox HUD (NSPanel + hold-to-search + Scheme B lock + `⌃⌥⌘[1-9]` paste)~~ ✅ ### Phase 3: IPC + VS Code Extension ✅ 9. ~~IPCServer~~ ✅ diff --git a/docs/en/01-product-spec/roadmap.md b/docs/en/01-product-spec/roadmap.md index cbe0cf9..b180ae9 100644 --- a/docs/en/01-product-spec/roadmap.md +++ b/docs/en/01-product-spec/roadmap.md @@ -7,7 +7,7 @@ The minimum viable product focuses on the **VS Code + Menu Bar** experience: - macOS Menu Bar App (Swift/SwiftUI), Keychain-based key storage - Demo mode toggle with visual indicator - Floating toolbox with hold-to-search and Scheme B lock -- Keyboard shortcuts: `⌃⌥Space`, `⌃⌥⌘D`, `⌃⌥[1-9]`, `⌃⌥⌘V` +- Keyboard shortcuts: `⌃⌥Space`, `⌃⌥⌘D`, `⌃⌥⌘[1-9]`, `⌃⌥⌘V` - VS Code Extension with Decoration API masking - Two-tier key hierarchy and manual key entry - Basic linked key groups (sequential paste) diff --git a/docs/en/02-technical-architecture/swift-core-modules.md b/docs/en/02-technical-architecture/swift-core-modules.md index e8d1dc0..73edfef 100644 --- a/docs/en/02-technical-architecture/swift-core-modules.md +++ b/docs/en/02-technical-architecture/swift-core-modules.md @@ -125,7 +125,7 @@ Manages global keyboard shortcuts using `CGEvent.tapCreate` for system-level hot |--------|--------|-------------| | `⌃⌥Space` | `toggleToolbox()` | Show/hide floating toolbox | | `⌃⌥⌘D` | `toggleDemoMode()` | Toggle demo/normal mode | -| `⌃⌥[1-9]` | `pasteKeyByIndex()` | Paste key by shortcut index | +| `⌃⌥⌘[1-9]` | `pasteKeyByIndex()` | Paste key by shortcut index | | `⌃⌥⌘V` | `captureClipboard()` | Scan and save current clipboard contents | ### Hold-to-Detect Logic diff --git a/docs/en/04-ui-ux/floating-toolbox.md b/docs/en/04-ui-ux/floating-toolbox.md index d6f161b..cad4030 100644 --- a/docs/en/04-ui-ux/floating-toolbox.md +++ b/docs/en/04-ui-ux/floating-toolbox.md @@ -12,7 +12,7 @@ Inspired by the macOS `⌘Tab` App Switcher. | Search/filter | Continue holding + type characters | Key list filters in real-time | | Quick paste (single result) | Release when only 1 result remains | Key is pasted, toolbox disappears | | Select from multiple results | Release → toolbox locks; use `↑↓` + `Enter` | Press Enter to paste Key, Esc to cancel | -| Paste directly by number | Press `⌃⌥[1-9]` | Immediately paste the Nth Key, no toolbox needed | +| Paste directly by number | Press `⌃⌥⌘[1-9]` | Immediately paste the Nth Key, no toolbox needed | ## Lock Behavior (Plan B) diff --git a/docs/en/04-ui-ux/keyboard-shortcuts.md b/docs/en/04-ui-ux/keyboard-shortcuts.md index c0d7b8c..2183a8c 100644 --- a/docs/en/04-ui-ux/keyboard-shortcuts.md +++ b/docs/en/04-ui-ux/keyboard-shortcuts.md @@ -6,7 +6,7 @@ |--------|-----------------|------------------| | Toggle floating toolbox | `⌃⌥Space` | Left-hand key combination, very few conflicts with applications | | Toggle demo mode | `⌃⌥⌘D` | Three modifier keys to prevent accidental triggering | -| Paste Nth Key directly | `⌃⌥[1-9]` | Fastest path when Key position is known | +| Paste Nth Key directly | `⌃⌥⌘[1-9]` | Fastest path when Key position is known | | Quick extract clipboard content | `⌃⌥⌘V` | Paste + auto-parse into manager | ## Custom Settings diff --git a/packages/swift-core/DemoSafe/App/AppState.swift b/packages/swift-core/DemoSafe/App/AppState.swift index 163ce99..73eee4e 100644 --- a/packages/swift-core/DemoSafe/App/AppState.swift +++ b/packages/swift-core/DemoSafe/App/AppState.swift @@ -18,6 +18,7 @@ final class AppState: ObservableObject { let maskingCoordinator: MaskingCoordinator let hotkeyManager: HotkeyManager let ipcServer: IPCServer + let sequentialPasteEngine: SequentialPasteEngine let toolboxState: ToolboxState let toolboxController: FloatingToolboxController @@ -28,7 +29,8 @@ final class AppState: ObservableObject { self.vaultManager = VaultManager(keychainService: keychainService) self.maskingCoordinator = MaskingCoordinator(vaultManager: vaultManager) self.clipboardEngine = ClipboardEngine(keychainService: keychainService, maskingCoordinator: maskingCoordinator) - self.ipcServer = IPCServer(maskingCoordinator: maskingCoordinator, clipboardEngine: clipboardEngine, vaultManager: vaultManager) + self.sequentialPasteEngine = SequentialPasteEngine(clipboardEngine: clipboardEngine, keychainService: keychainService) + self.ipcServer = IPCServer(maskingCoordinator: maskingCoordinator, clipboardEngine: clipboardEngine, vaultManager: vaultManager, keychainService: keychainService) self.hotkeyManager = HotkeyManager(maskingCoordinator: maskingCoordinator) self.toolboxState = ToolboxState(vaultManager: vaultManager) self.toolboxController = FloatingToolboxController() @@ -50,8 +52,6 @@ final class AppState: ObservableObject { seedDefaultContextModes() } - // Test key seeding removed — Active Key Capture handles key storage via Chrome Extension. - // Wire settings window controller SettingsWindowController.shared.setAppState(self) @@ -180,12 +180,28 @@ final class AppState: ObservableObject { self?.toggleDemoMode() } - // Paste key by index + // Paste key by index (group-aware) hotkeyManager.onPasteKeyByIndex = { [weak self] index in guard let self else { return } let allKeys = self.vaultManager.getAllKeys() guard index >= 1, index <= allKeys.count else { return } - self.copyKey(keyId: allKeys[index - 1].id) + let key = allKeys[index - 1] + + // If key belongs to a sequential group, paste the entire group + if let groupId = key.linkedGroupId, + let group = self.vaultManager.getLinkedGroup(groupId: groupId), + group.pasteMode == .sequential { + let autoClear = self.activeContext?.clipboardClearSeconds + Task { + do { + try await self.sequentialPasteEngine.pasteGroupSequentially(group, autoClearSeconds: autoClear) + } catch { + logger.error("Sequential paste failed: \(error.localizedDescription)") + } + } + } else { + self.copyKey(keyId: key.id) + } } } diff --git a/packages/swift-core/DemoSafe/Models/LinkedGroup.swift b/packages/swift-core/DemoSafe/Models/LinkedGroup.swift index 805c90e..90c6925 100644 --- a/packages/swift-core/DemoSafe/Models/LinkedGroup.swift +++ b/packages/swift-core/DemoSafe/Models/LinkedGroup.swift @@ -1,15 +1,27 @@ import Foundation +/// A single entry within a LinkedGroup, representing one key with its field label. +struct GroupEntry: Codable { + let keyId: UUID + var fieldLabel: String // e.g. "Access Key ID", "Secret Key" + var sortOrder: Int +} + /// An ordered group of keys for batch paste operations. struct LinkedGroup: Codable, Identifiable { let id: UUID var label: String - var keyIds: [UUID] + var entries: [GroupEntry] var pasteMode: PasteMode var createdAt: Date + + /// Key IDs in sort order, for convenience. + var sortedKeyIds: [UUID] { + entries.sorted { $0.sortOrder < $1.sortOrder }.map(\.keyId) + } } enum PasteMode: String, Codable { - case selectField // MVP: user selects which field to paste - case sequential // Future: paste keys in order + case selectField // User selects which field to paste + case sequential // Paste keys in order with Tab between fields } diff --git a/packages/swift-core/DemoSafe/Services/Clipboard/SequentialPasteEngine.swift b/packages/swift-core/DemoSafe/Services/Clipboard/SequentialPasteEngine.swift new file mode 100644 index 0000000..df509aa --- /dev/null +++ b/packages/swift-core/DemoSafe/Services/Clipboard/SequentialPasteEngine.swift @@ -0,0 +1,147 @@ +import Foundation +import AppKit +import CoreGraphics +import Carbon.HIToolbox +import os + +private let logger = Logger(subsystem: "com.demosafe", category: "SequentialPasteEngine") + +/// Orchestrates pasting multiple keys from a LinkedGroup. +/// +/// Sequential mode: copy each key to clipboard → simulate ⌘V → simulate Tab → repeat. +/// SelectField mode: copy a single specified field to clipboard. +final class SequentialPasteEngine { + private let clipboardEngine: ClipboardEngine + private let keychainService: KeychainService + + enum PasteError: Error, LocalizedError { + case fieldIndexOutOfRange(Int, count: Int) + case emptyGroup + case keychainRetrieveFailed(UUID) + + var errorDescription: String? { + switch self { + case .fieldIndexOutOfRange(let idx, let count): + return "Field index \(idx) out of range (group has \(count) entries)" + case .emptyGroup: + return "LinkedGroup has no entries" + case .keychainRetrieveFailed(let id): + return "Failed to retrieve key from Keychain: \(id)" + } + } + } + + init(clipboardEngine: ClipboardEngine, keychainService: KeychainService) { + self.clipboardEngine = clipboardEngine + self.keychainService = keychainService + } + + // MARK: - Public API + + /// Paste all keys in the group sequentially: ⌘V → Tab → ⌘V → Tab → ... + /// + /// Pre-fetches ALL key values from Keychain upfront so that any confirmation + /// dialogs appear before the paste sequence begins. This prevents Keychain + /// prompts from interrupting the Tab → Paste flow. + func pasteGroupSequentially(_ group: LinkedGroup, autoClearSeconds: Int?) async throws { + let sorted = group.entries.sorted { $0.sortOrder < $1.sortOrder } + guard !sorted.isEmpty else { throw PasteError.emptyGroup } + + logger.info("Sequential paste: group=\(group.label) entries=\(sorted.count)") + + // Phase 1: Pre-fetch all key values from Keychain (may trigger auth dialogs) + var prefetched: [(entry: GroupEntry, value: String)] = [] + for entry in sorted { + var plaintext = try keychainService.retrieveKey(keyId: entry.keyId) + defer { plaintext.resetBytes(in: 0..= 0, fieldIndex < sorted.count else { + throw PasteError.fieldIndexOutOfRange(fieldIndex, count: sorted.count) + } + + let entry = sorted[fieldIndex] + logger.info("Field paste: group=\(group.label) field=\(entry.fieldLabel) index=\(fieldIndex)") + try clipboardEngine.copyToClipboard(keyId: entry.keyId, autoClearSeconds: autoClearSeconds) + } + + // MARK: - Private — Key Simulation + + /// Simulate ⌘V (Paste) keystroke via CGEvent. + /// Uses a dedicated event source to isolate modifier state. + private func simulatePaste() { + let keyCode = CGKeyCode(kVK_ANSI_V) + let source = CGEventSource(stateID: .combinedSessionState) + + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else { + logger.error("Failed to create CGEvent for ⌘V") + return + } + + keyDown.flags = .maskCommand + keyUp.flags = [] // Explicitly release Command on keyUp + + keyDown.post(tap: .cghidEventTap) + keyUp.post(tap: .cghidEventTap) + } + + /// Simulate Tab keystroke via CGEvent. + /// Explicitly clears all modifier flags to prevent ⌘Tab (app switch). + private func simulateTab() { + let keyCode = CGKeyCode(kVK_Tab) + let source = CGEventSource(stateID: .combinedSessionState) + + guard let keyDown = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true), + let keyUp = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false) else { + logger.error("Failed to create CGEvent for Tab") + return + } + + // Clear ALL modifiers — critical to avoid ⌘Tab triggering app switcher + keyDown.flags = [] + keyUp.flags = [] + + keyDown.post(tap: .cghidEventTap) + keyUp.post(tap: .cghidEventTap) + } +} diff --git a/packages/swift-core/DemoSafe/Services/Hotkey/HotkeyManager.swift b/packages/swift-core/DemoSafe/Services/Hotkey/HotkeyManager.swift index 48b5817..e6c4130 100644 --- a/packages/swift-core/DemoSafe/Services/Hotkey/HotkeyManager.swift +++ b/packages/swift-core/DemoSafe/Services/Hotkey/HotkeyManager.swift @@ -7,7 +7,7 @@ import Carbon.HIToolbox /// Registered shortcuts: /// - ⌃⌥Space (hold): Show floating toolbox with search /// - ⌃⌥⌘D: Toggle demo mode -/// - ⌃⌥[1-9]: Paste key by index +/// - ⌃⌥⌘[1-9]: Paste key by index /// - ⌃⌥⌘V: Capture clipboard final class HotkeyManager { private let maskingCoordinator: MaskingCoordinator @@ -165,8 +165,8 @@ final class HotkeyManager { return nil } - // ⌃⌥[1-9] — Paste key by index - if hasControl && hasOption && !hasCommand { + // ⌃⌥⌘[1-9] — Paste key by index + if hasControl && hasOption && hasCommand { let numberKeyCodes: [Int] = [ kVK_ANSI_1, kVK_ANSI_2, kVK_ANSI_3, kVK_ANSI_4, kVK_ANSI_5, kVK_ANSI_6, diff --git a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift index 6f99967..8acdc1c 100644 --- a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift +++ b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift @@ -35,10 +35,13 @@ final class IPCServer { let clientType: ClientType } - init(maskingCoordinator: MaskingCoordinator, clipboardEngine: ClipboardEngine, vaultManager: VaultManager) { + private let sequentialPasteEngine: SequentialPasteEngine + + init(maskingCoordinator: MaskingCoordinator, clipboardEngine: ClipboardEngine, vaultManager: VaultManager, keychainService: KeychainService) { self.maskingCoordinator = maskingCoordinator self.clipboardEngine = clipboardEngine self.vaultManager = vaultManager + self.sequentialPasteEngine = SequentialPasteEngine(clipboardEngine: clipboardEngine, keychainService: keychainService) let home = FileManager.default.homeDirectoryForCurrentUser self.ipcDir = home.appendingPathComponent(".demosafe") self.ipcFileURL = ipcDir.appendingPathComponent("ipc.json") @@ -230,6 +233,8 @@ final class IPCServer { handleSubmitCapturedKey(messageId: messageId, payload: payload, clientId: clientId) case "toggle_capture_mode": handleToggleCaptureMode(messageId: messageId, payload: payload, clientId: clientId) + case "request_paste_group": + handleRequestPasteGroup(messageId: messageId, payload: payload, clientId: clientId) default: sendError(messageId: messageId, action: action, code: "INVALID_PAYLOAD", message: "Unknown action: \(action)", clientId: clientId) } @@ -519,6 +524,82 @@ final class IPCServer { sendJSON(response, clientId: clientId) } + private func handleRequestPasteGroup(messageId: String, payload: [String: Any], clientId: UUID) { + guard connections[clientId]?.isAuthenticated == true else { + sendError(messageId: messageId, action: "request_paste_group", code: "AUTH_FAILED", message: "Not authenticated", clientId: clientId) + return + } + + guard let groupIdStr = payload["groupId"] as? String, + let groupId = UUID(uuidString: groupIdStr) else { + sendError(messageId: messageId, action: "request_paste_group", code: "INVALID_PAYLOAD", message: "Missing or invalid groupId", clientId: clientId) + return + } + + guard let group = vaultManager.getLinkedGroup(groupId: groupId) else { + sendError(messageId: messageId, action: "request_paste_group", code: "GROUP_NOT_FOUND", message: "Group not found: \(groupId)", clientId: clientId) + return + } + + let fieldIndex = payload["fieldIndex"] as? Int + + DispatchQueue.main.async { [weak self] in + guard let self else { return } + + if let fieldIndex = fieldIndex { + // SelectField mode: paste single field + do { + try self.sequentialPasteEngine.pasteField(group, fieldIndex: fieldIndex, autoClearSeconds: nil) + let response: [String: Any] = [ + "id": messageId, + "type": "response", + "action": "request_paste_group", + "payload": ["status": "success", "groupId": groupId.uuidString, "fieldIndex": fieldIndex], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + self.sendJSON(response, clientId: clientId) + } catch { + self.sendError(messageId: messageId, action: "request_paste_group", code: "KEYCHAIN_ERROR", message: error.localizedDescription, clientId: clientId) + } + } else { + // Full group paste based on pasteMode + switch group.pasteMode { + case .sequential: + Task { + do { + try await self.sequentialPasteEngine.pasteGroupSequentially(group, autoClearSeconds: nil) + let response: [String: Any] = [ + "id": messageId, + "type": "response", + "action": "request_paste_group", + "payload": ["status": "success", "groupId": groupId.uuidString], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + self.sendJSON(response, clientId: clientId) + } catch { + self.sendError(messageId: messageId, action: "request_paste_group", code: "KEYCHAIN_ERROR", message: error.localizedDescription, clientId: clientId) + } + } + case .selectField: + // SelectField without fieldIndex: paste first entry as fallback + do { + try self.sequentialPasteEngine.pasteField(group, fieldIndex: 0, autoClearSeconds: nil) + let response: [String: Any] = [ + "id": messageId, + "type": "response", + "action": "request_paste_group", + "payload": ["status": "success", "groupId": groupId.uuidString, "fieldIndex": 0], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + self.sendJSON(response, clientId: clientId) + } catch { + self.sendError(messageId: messageId, action: "request_paste_group", code: "KEYCHAIN_ERROR", message: error.localizedDescription, clientId: clientId) + } + } + } + } + } + // MARK: - Private — Sending private func sendJSON(_ json: [String: Any], clientId: UUID) { diff --git a/packages/swift-core/DemoSafe/Services/VaultManager.swift b/packages/swift-core/DemoSafe/Services/VaultManager.swift index 6a00b22..5d9494c 100644 --- a/packages/swift-core/DemoSafe/Services/VaultManager.swift +++ b/packages/swift-core/DemoSafe/Services/VaultManager.swift @@ -132,10 +132,10 @@ final class VaultManager { // Remove from any linked groups for i in vault.linkedGroups.indices { - vault.linkedGroups[i].keyIds.removeAll { $0 == keyId } + vault.linkedGroups[i].entries.removeAll { $0.keyId == keyId } } // Clean up empty groups - vault.linkedGroups.removeAll { $0.keyIds.isEmpty } + vault.linkedGroups.removeAll { $0.entries.isEmpty } vault.patternCacheVersion += 1 try persist() @@ -164,10 +164,11 @@ final class VaultManager { // MARK: - LinkedGroup CRUD - func createLinkedGroup(label: String, keyIds: [UUID], pasteMode: PasteMode) throws -> LinkedGroup { + func createLinkedGroup(label: String, entries: [GroupEntry], pasteMode: PasteMode) throws -> LinkedGroup { // Validate all keyIds exist + let entryKeyIds = entries.map(\.keyId) let existingIds = Set(vault.keys.map { $0.id }) - let invalid = keyIds.filter { !existingIds.contains($0) } + let invalid = entryKeyIds.filter { !existingIds.contains($0) } guard invalid.isEmpty else { throw VaultError.invalidKeyIds(invalid) } @@ -175,7 +176,7 @@ final class VaultManager { let group = LinkedGroup( id: UUID(), label: label, - keyIds: keyIds, + entries: entries, pasteMode: pasteMode, createdAt: Date() ) @@ -183,7 +184,7 @@ final class VaultManager { vault.linkedGroups.append(group) // Update keys with linkedGroupId - for keyId in keyIds { + for keyId in entryKeyIds { if let idx = vault.keys.firstIndex(where: { $0.id == keyId }) { vault.keys[idx].linkedGroupId = group.id } @@ -198,6 +199,44 @@ final class VaultManager { return vault.linkedGroups.first { $0.id == groupId } } + func updateLinkedGroup(groupId: UUID, label: String, entries: [GroupEntry], pasteMode: PasteMode) throws { + guard let idx = vault.linkedGroups.firstIndex(where: { $0.id == groupId }) else { + throw VaultError.groupNotFound(groupId) + } + + // Validate all new keyIds exist + let entryKeyIds = entries.map(\.keyId) + let existingIds = Set(vault.keys.map { $0.id }) + let invalid = entryKeyIds.filter { !existingIds.contains($0) } + guard invalid.isEmpty else { + throw VaultError.invalidKeyIds(invalid) + } + + // Clear linkedGroupId on old keys that are no longer in the group + let oldKeyIds = Set(vault.linkedGroups[idx].entries.map(\.keyId)) + let newKeyIds = Set(entryKeyIds) + for i in vault.keys.indices { + if oldKeyIds.contains(vault.keys[i].id) && !newKeyIds.contains(vault.keys[i].id) { + vault.keys[i].linkedGroupId = nil + } + } + + // Update group + vault.linkedGroups[idx].label = label + vault.linkedGroups[idx].entries = entries + vault.linkedGroups[idx].pasteMode = pasteMode + + // Set linkedGroupId on new keys + for keyId in entryKeyIds { + if let ki = vault.keys.firstIndex(where: { $0.id == keyId }) { + vault.keys[ki].linkedGroupId = groupId + } + } + + try persist() + broadcastChange() + } + func deleteLinkedGroup(groupId: UUID) throws { guard vault.linkedGroups.contains(where: { $0.id == groupId }) else { throw VaultError.groupNotFound(groupId) diff --git a/packages/swift-core/DemoSafe/Views/FloatingToolbox/ToolboxState.swift b/packages/swift-core/DemoSafe/Views/FloatingToolbox/ToolboxState.swift index 54f88ad..c0c2437 100644 --- a/packages/swift-core/DemoSafe/Views/FloatingToolbox/ToolboxState.swift +++ b/packages/swift-core/DemoSafe/Views/FloatingToolbox/ToolboxState.swift @@ -18,6 +18,8 @@ final class ToolboxState: ObservableObject { let key: KeyEntry let serviceName: String let index: Int // 1-based display index + let groupLabel: String? // LinkedGroup label if key belongs to a group + let fieldLabel: String? // GroupEntry fieldLabel if key belongs to a group var id: UUID { key.id } } @@ -25,16 +27,33 @@ final class ToolboxState: ObservableObject { self.vaultManager = vaultManager } - /// All keys with service names, filtered by search text. + /// All keys with service names and group info, filtered by search text. var filteredKeys: [KeyItem] { let services = vaultManager.getAllServices() var items: [KeyItem] = [] var idx = 1 + // Build group lookup for efficiency + var groupLookup: [UUID: LinkedGroup] = [:] + for group in vaultManager.vault.linkedGroups { + groupLookup[group.id] = group + } + for service in services { let keys = vaultManager.getKeys(serviceId: service.id) for key in keys { - items.append(KeyItem(key: key, serviceName: service.name, index: idx)) + var groupLabel: String? + var fieldLabel: String? + + if let groupId = key.linkedGroupId, let group = groupLookup[groupId] { + groupLabel = group.label + fieldLabel = group.entries.first(where: { $0.keyId == key.id })?.fieldLabel + } + + items.append(KeyItem( + key: key, serviceName: service.name, index: idx, + groupLabel: groupLabel, fieldLabel: fieldLabel + )) idx += 1 } } @@ -44,7 +63,9 @@ final class ToolboxState: ObservableObject { let query = searchText.lowercased() return items.filter { $0.key.label.lowercased().contains(query) || - $0.serviceName.lowercased().contains(query) + $0.serviceName.lowercased().contains(query) || + ($0.groupLabel?.lowercased().contains(query) ?? false) || + ($0.fieldLabel?.lowercased().contains(query) ?? false) } } diff --git a/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift b/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift index 5ab4287..682573d 100644 --- a/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift +++ b/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift @@ -58,23 +58,70 @@ struct KeyManagementTab: View { @EnvironmentObject var appState: AppState @State private var showingAddSheet = false @State private var showingAddServiceSheet = false + @State private var showingAddGroupSheet = false + @State private var editingGroup: LinkedGroup? var body: some View { VStack { List { + // Keys by service ForEach(appState.vaultManager.getAllServices()) { service in Section(service.name) { ForEach(appState.vaultManager.getKeys(serviceId: service.id)) { key in HStack { VStack(alignment: .leading) { Text(key.label) - Text(appState.maskingCoordinator.maskedDisplay(keyId: key.id)) + HStack(spacing: 4) { + Text(appState.maskingCoordinator.maskedDisplay(keyId: key.id)) + if let groupId = key.linkedGroupId, + let group = appState.vaultManager.getLinkedGroup(groupId: groupId) { + Text("· \(group.label)") + .foregroundColor(.accentColor) + } + } + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Button(role: .destructive) { + try? appState.vaultManager.deleteKey(keyId: key.id) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + } + } + } + + // Linked Groups + if !appState.vaultManager.vault.linkedGroups.isEmpty { + Section("Linked Groups") { + ForEach(appState.vaultManager.vault.linkedGroups) { group in + HStack { + VStack(alignment: .leading) { + HStack(spacing: 6) { + Text(group.label) + Text(group.pasteMode == .sequential ? "Sequential" : "Select Field") + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(group.pasteMode == .sequential ? Color.blue.opacity(0.15) : Color.gray.opacity(0.15)) + .cornerRadius(4) + } + Text(group.entries.map(\.fieldLabel).joined(separator: " → ")) .font(.caption) .foregroundColor(.secondary) } Spacer() + Button { + editingGroup = group + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) Button(role: .destructive) { - try? appState.vaultManager.deleteKey(keyId: key.id) + try? appState.vaultManager.deleteLinkedGroup(groupId: group.id) } label: { Image(systemName: "trash") } @@ -90,6 +137,10 @@ struct KeyManagementTab: View { Button("Add Service...") { showingAddServiceSheet = true } + Button("Add Group...") { + showingAddGroupSheet = true + } + .disabled(appState.vaultManager.getAllKeys().count < 2) Spacer() Button("Add Key...") { showingAddSheet = true @@ -103,6 +154,12 @@ struct KeyManagementTab: View { .sheet(isPresented: $showingAddServiceSheet) { AddServiceSheet() } + .sheet(isPresented: $showingAddGroupSheet) { + AddGroupSheet() + } + .sheet(item: $editingGroup) { group in + EditGroupSheet(group: group) + } } } @@ -244,6 +301,304 @@ struct AddServiceSheet: View { } } +// MARK: - Add Group Sheet + +struct AddGroupSheet: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + @State private var label = "" + @State private var pasteMode: PasteMode = .sequential + @State private var selectedEntries: [GroupEntryDraft] = [] + @State private var errorMessage: String? + + struct GroupEntryDraft: Identifiable { + let id = UUID() + let keyId: UUID + let keyLabel: String + let serviceName: String + var fieldLabel: String + } + + /// All keys not already in a group. + private var availableKeys: [(KeyEntry, String)] { + let services = appState.vaultManager.getAllServices() + var result: [(KeyEntry, String)] = [] + for service in services { + for key in appState.vaultManager.getKeys(serviceId: service.id) { + if key.linkedGroupId == nil { + result.append((key, service.name)) + } + } + } + return result + } + + var body: some View { + VStack(spacing: 16) { + Text("Create Linked Group") + .font(.headline) + + Form { + TextField("Group Name", text: $label) + .textFieldStyle(.roundedBorder) + + Picker("Paste Mode", selection: $pasteMode) { + Text("Sequential (Tab between fields)").tag(PasteMode.sequential) + Text("Select Field (choose one)").tag(PasteMode.selectField) + } + + Section("Select Keys (in order)") { + ForEach(availableKeys, id: \.0.id) { key, serviceName in + let isSelected = selectedEntries.contains(where: { $0.keyId == key.id }) + Button { + if isSelected { + selectedEntries.removeAll { $0.keyId == key.id } + } else { + selectedEntries.append(GroupEntryDraft( + keyId: key.id, + keyLabel: key.label, + serviceName: serviceName, + fieldLabel: key.label + )) + } + } label: { + HStack { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : .secondary) + VStack(alignment: .leading) { + Text(key.label) + Text(serviceName) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if isSelected, let idx = selectedEntries.firstIndex(where: { $0.keyId == key.id }) { + Text("#\(idx + 1)") + .font(.caption) + .foregroundColor(.accentColor) + } + } + } + .buttonStyle(.plain) + } + } + + if !selectedEntries.isEmpty { + Section("Field Labels") { + ForEach($selectedEntries) { $entry in + HStack { + Text("#\(selectedEntries.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? 0)") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 24) + TextField("Field label", text: $entry.fieldLabel) + .textFieldStyle(.roundedBorder) + } + } + } + } + + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Create") { createGroup() } + .keyboardShortcut(.defaultAction) + .disabled(label.isEmpty || selectedEntries.count < 2) + } + } + .padding() + .frame(width: 450, height: 480) + } + + private func createGroup() { + let entries = selectedEntries.enumerated().map { idx, draft in + GroupEntry(keyId: draft.keyId, fieldLabel: draft.fieldLabel, sortOrder: idx) + } + + do { + let _ = try appState.vaultManager.createLinkedGroup( + label: label, + entries: entries, + pasteMode: pasteMode + ) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} + +// MARK: - Edit Group Sheet + +struct EditGroupSheet: View { + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + let group: LinkedGroup + + @State private var label: String + @State private var pasteMode: PasteMode + @State private var selectedEntries: [AddGroupSheet.GroupEntryDraft] + @State private var errorMessage: String? + + init(group: LinkedGroup) { + self.group = group + _label = State(initialValue: group.label) + _pasteMode = State(initialValue: group.pasteMode) + _selectedEntries = State(initialValue: group.entries + .sorted { $0.sortOrder < $1.sortOrder } + .map { AddGroupSheet.GroupEntryDraft(keyId: $0.keyId, keyLabel: "", serviceName: "", fieldLabel: $0.fieldLabel) } + ) + } + + /// All keys: either not in any group, or already in this group. + private var availableKeys: [(KeyEntry, String)] { + let services = appState.vaultManager.getAllServices() + var result: [(KeyEntry, String)] = [] + for service in services { + for key in appState.vaultManager.getKeys(serviceId: service.id) { + if key.linkedGroupId == nil || key.linkedGroupId == group.id { + result.append((key, service.name)) + } + } + } + return result + } + + var body: some View { + VStack(spacing: 16) { + Text("Edit Linked Group") + .font(.headline) + + Form { + TextField("Group Name", text: $label) + .textFieldStyle(.roundedBorder) + + Picker("Paste Mode", selection: $pasteMode) { + Text("Sequential (Tab between fields)").tag(PasteMode.sequential) + Text("Select Field (choose one)").tag(PasteMode.selectField) + } + + Section("Select Keys (in order)") { + ForEach(availableKeys, id: \.0.id) { key, serviceName in + let isSelected = selectedEntries.contains(where: { $0.keyId == key.id }) + Button { + if isSelected { + selectedEntries.removeAll { $0.keyId == key.id } + } else { + selectedEntries.append(AddGroupSheet.GroupEntryDraft( + keyId: key.id, + keyLabel: key.label, + serviceName: serviceName, + fieldLabel: key.label + )) + } + } label: { + HStack { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : .secondary) + VStack(alignment: .leading) { + Text(key.label) + Text(serviceName) + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + if isSelected, let idx = selectedEntries.firstIndex(where: { $0.keyId == key.id }) { + Text("#\(idx + 1)") + .font(.caption) + .foregroundColor(.accentColor) + } + } + } + .buttonStyle(.plain) + } + } + + if !selectedEntries.isEmpty { + Section("Field Labels") { + ForEach($selectedEntries) { $entry in + HStack { + Text("#\(selectedEntries.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? 0)") + .font(.caption) + .foregroundColor(.secondary) + .frame(width: 24) + TextField("Field label", text: $entry.fieldLabel) + .textFieldStyle(.roundedBorder) + } + } + } + } + + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.caption) + } + } + + HStack { + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("Save") { saveGroup() } + .keyboardShortcut(.defaultAction) + .disabled(label.isEmpty || selectedEntries.count < 2) + } + } + .padding() + .frame(width: 450, height: 480) + .onAppear { populateKeyLabels() } + } + + /// Fill in keyLabel/serviceName from vault (init only has keyId + fieldLabel). + private func populateKeyLabels() { + let services = appState.vaultManager.getAllServices() + for i in selectedEntries.indices { + if let key = appState.vaultManager.getKey(keyId: selectedEntries[i].keyId) { + let svcName = services.first(where: { $0.id == key.serviceId })?.name ?? "" + selectedEntries[i] = AddGroupSheet.GroupEntryDraft( + keyId: key.id, + keyLabel: key.label, + serviceName: svcName, + fieldLabel: selectedEntries[i].fieldLabel + ) + } + } + } + + private func saveGroup() { + let entries = selectedEntries.enumerated().map { idx, draft in + GroupEntry(keyId: draft.keyId, fieldLabel: draft.fieldLabel, sortOrder: idx) + } + + do { + try appState.vaultManager.updateLinkedGroup( + groupId: group.id, + label: label, + entries: entries, + pasteMode: pasteMode + ) + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } +} + // MARK: - Context Mode struct ContextModeTab: View { @@ -327,7 +682,7 @@ struct ShortcutsTab: View { Section("Registered Shortcuts") { LabeledContent("Toggle Toolbox") { Text("⌃⌥Space") } LabeledContent("Toggle Demo Mode") { Text("⌃⌥⌘D") } - LabeledContent("Paste Key [1-9]") { Text("⌃⌥[1-9]") } + LabeledContent("Paste Key [1-9]") { Text("⌃⌥⌘[1-9]") } LabeledContent("Capture Clipboard") { Text("⌃⌥⌘V") } } } diff --git a/packages/swift-core/DemoSafeTests/VaultManagerTests.swift b/packages/swift-core/DemoSafeTests/VaultManagerTests.swift index 9ad7f69..6fc1d40 100644 --- a/packages/swift-core/DemoSafeTests/VaultManagerTests.swift +++ b/packages/swift-core/DemoSafeTests/VaultManagerTests.swift @@ -116,25 +116,31 @@ final class VaultManagerTests: XCTestCase { let key1 = try addTestKey(serviceId: service.id, label: "Key 1") let key2 = try addTestKey(serviceId: service.id, label: "Key 2") - let group = try sut.createLinkedGroup(label: "AWS Pair", keyIds: [key1.id, key2.id], pasteMode: .selectField) + let entries = [ + GroupEntry(keyId: key1.id, fieldLabel: "Access Key ID", sortOrder: 0), + GroupEntry(keyId: key2.id, fieldLabel: "Secret Key", sortOrder: 1), + ] + let group = try sut.createLinkedGroup(label: "AWS Pair", entries: entries, pasteMode: .selectField) XCTAssertEqual(sut.getLinkedGroup(groupId: group.id)?.label, "AWS Pair") - XCTAssertEqual(sut.getLinkedGroup(groupId: group.id)?.keyIds.count, 2) + XCTAssertEqual(sut.getLinkedGroup(groupId: group.id)?.entries.count, 2) // Keys should reference the group XCTAssertEqual(sut.getKey(keyId: key1.id)?.linkedGroupId, group.id) XCTAssertEqual(sut.getKey(keyId: key2.id)?.linkedGroupId, group.id) } func testCreateLinkedGroup_invalidKeyIdsThrows() throws { + let entries = [GroupEntry(keyId: UUID(), fieldLabel: "Bad Key", sortOrder: 0)] XCTAssertThrowsError( - try sut.createLinkedGroup(label: "Bad", keyIds: [UUID()], pasteMode: .selectField) + try sut.createLinkedGroup(label: "Bad", entries: entries, pasteMode: .selectField) ) } func testDeleteLinkedGroup_clearsKeyReferences() throws { let service = try addTestService() let key = try addTestKey(serviceId: service.id) - let group = try sut.createLinkedGroup(label: "Group", keyIds: [key.id], pasteMode: .selectField) + let entries = [GroupEntry(keyId: key.id, fieldLabel: "API Key", sortOrder: 0)] + let group = try sut.createLinkedGroup(label: "Group", entries: entries, pasteMode: .selectField) try sut.deleteLinkedGroup(groupId: group.id) @@ -146,15 +152,49 @@ final class VaultManagerTests: XCTestCase { let service = try addTestService() let key1 = try addTestKey(serviceId: service.id, label: "Key 1") let key2 = try addTestKey(serviceId: service.id, label: "Key 2") - let group = try sut.createLinkedGroup(label: "Pair", keyIds: [key1.id, key2.id], pasteMode: .selectField) + + let entries = [ + GroupEntry(keyId: key1.id, fieldLabel: "Access Key", sortOrder: 0), + GroupEntry(keyId: key2.id, fieldLabel: "Secret Key", sortOrder: 1), + ] + let group = try sut.createLinkedGroup(label: "Pair", entries: entries, pasteMode: .selectField) try sut.deleteKey(keyId: key1.id) createdKeyIds.removeAll { $0 == key1.id } // Group should still exist with remaining key let updatedGroup = sut.getLinkedGroup(groupId: group.id) - XCTAssertEqual(updatedGroup?.keyIds.count, 1) - XCTAssertEqual(updatedGroup?.keyIds.first, key2.id) + XCTAssertEqual(updatedGroup?.entries.count, 1) + XCTAssertEqual(updatedGroup?.entries.first?.keyId, key2.id) + } + + func testCreateLinkedGroup_sequential() throws { + let service = try addTestService() + let key1 = try addTestKey(serviceId: service.id, label: "Key 1") + let key2 = try addTestKey(serviceId: service.id, label: "Key 2") + + let entries = [ + GroupEntry(keyId: key1.id, fieldLabel: "Access Key ID", sortOrder: 0), + GroupEntry(keyId: key2.id, fieldLabel: "Secret Key", sortOrder: 1), + ] + let group = try sut.createLinkedGroup(label: "AWS Sequential", entries: entries, pasteMode: .sequential) + + XCTAssertEqual(group.pasteMode, .sequential) + XCTAssertEqual(group.sortedKeyIds, [key1.id, key2.id]) + } + + func testDeleteKey_removesEmptyGroup() throws { + let service = try addTestService() + let key = try addTestKey(serviceId: service.id) + + let entries = [GroupEntry(keyId: key.id, fieldLabel: "Only Key", sortOrder: 0)] + let group = try sut.createLinkedGroup(label: "Solo", entries: entries, pasteMode: .selectField) + + try sut.deleteKey(keyId: key.id) + createdKeyIds.removeAll { $0 == key.id } + + // Group should be auto-removed since it's now empty + XCTAssertNil(sut.getLinkedGroup(groupId: group.id)) } // MARK: - ContextMode Tests diff --git a/packages/vscode-extension/src/commands/paste-key.ts b/packages/vscode-extension/src/commands/paste-key.ts index edefa20..587c613 100644 --- a/packages/vscode-extension/src/commands/paste-key.ts +++ b/packages/vscode-extension/src/commands/paste-key.ts @@ -42,7 +42,7 @@ export async function pasteKeyCommand(cache: PatternCache, ipcClient: IPCClient) /** * Command: demosafe.pasteKeyByIndex - * Paste key by position index (1-9), matching ⌃⌥[1-9] hotkey in Core. + * Paste key by position index (1-9), matching ⌃⌥⌘[1-9] hotkey in Core. */ export async function pasteKeyByIndexCommand(index: number, cache: PatternCache, ipcClient: IPCClient) { if (!ipcClient.isConnected) return;