diff --git a/CLAUDE.md b/CLAUDE.md index a12df6c..d5ae1da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,6 +106,9 @@ These are absolute rules — never violate them: - **Terminal masking sync block buffering** (experimental): Shielded Terminal buffers PTY output by detecting DEC 2026 sync block markers (`\x1b[?2026h`/`\x1b[?2026l`). Complete sync blocks are masked atomically. Non-sync data uses 30ms timeout buffer. This matches claude-chill's approach. - **Terminal masking ANSI-aware matching**: `maskTerminalOutput()` strips ANSI escape codes AND all whitespace (spaces, tabs, newlines) before regex matching. Ink word-wraps long keys with `\r\n` + indentation; stripping all whitespace allows regex to match keys across visual line breaks. Structural characters (ANSI + whitespace) within matched ranges are preserved in output via `extractStructural()`. - **Terminal masking node-pty loading**: Triple fallback: (1) `require('node-pty')`, (2) `require(vscode.env.appRoot + '/node_modules.asar.unpacked/node-pty')`, (3) `require(vscode.env.appRoot + '/node_modules/node-pty')`. Falls back to `child_process.spawn` line-mode terminal. +- **System-wide masking** (experimental): Uses `AXObserver` + `kAXFocusedUIElementChangedNotification` / `kAXValueChangedNotification` to monitor focused text elements across all apps. `AXBoundsForRange` provides precise multi-line text coordinates. NSPanel overlay with `ignoresMouseEvents = true` for click-through. Settings toggle (`systemWideMasking` in UserDefaults), only active when both setting AND Demo Mode are ON. +- **System-wide masking performance**: 10ms initial scan delay, 30ms debounce for changes, 500ms polling fallback. Actual processing 2-6ms. Immediate scan on app switch. Text cache (`lastScannedText`) prevents redundant overlay updates. +- **System-wide masking coordinate conversion**: AX API uses top-left origin, AppKit uses bottom-left. Convert via `primaryScreenHeight - axY - height`. Multi-screen: use primary screen height as reference. ## Documentation diff --git a/README.md b/README.md index 9c9fd7c..3d14726 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,8 @@ See the [docs/](docs/) directory for detailed specifications: - [x] Linked Key Groups (sequential paste with ⌘V→Tab automation, Settings UI CRUD, pre-fetch Keychain) - [x] Clipboard Capture (⌃⌥⌘V hotkey, 22 built-in patterns, 3-tier confidence routing, whitespace-tolerant) - [x] 🧪 Terminal masking — Shielded Terminal (node-pty proxy, DEC 2026 sync block buffering, ANSI-aware masking, Claude Code compatible) +- [x] 🧪 System-wide masking — Accessibility overlay (AXObserver + AXBoundsForRange, NSPanel click-through overlay, Settings toggle) - [ ] API Key rotation & deployment sync -- [ ] System-wide masking (Accessibility API) ## Contributing diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index f26652a..e98c5b7 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -35,6 +35,7 @@ | ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~中~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 內建 22 種 pattern 偵測 → confidence 三階路由 → VaultManager 存儲 | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~中~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` 完成、Settings UI 群組管理(CRUD)、`request_paste_group` IPC handler | | ~~Terminal masking (Shielded Terminal)~~ | Spec §3.2 | ~~中~~ | 🧪 實驗性。node-pty proxy + DEC 2026 sync block buffering + ANSI-aware masking。Known limitation: Rewind 確認頁部分洩漏 | +| ~~System-wide masking (Accessibility overlay)~~ | Spec §3.3 | ~~中~~ | 🧪 實驗性。AXObserver + AXBoundsForRange + NSPanel overlay。Settings 可開關,Demo Mode 附加功能。Known limitation: 僅掃描 focused element、copy/paste 不安全 | | Shortcut conflict detection | Spec §4.4 | 低 | | | Import / Export vault | Spec §9.1 | 低 | | diff --git a/docs/14-system-wide-masking-advanced.md b/docs/14-system-wide-masking-advanced.md new file mode 100644 index 0000000..4e84900 --- /dev/null +++ b/docs/14-system-wide-masking-advanced.md @@ -0,0 +1,136 @@ +# System-Wide Masking:進階安全方案(社群貢獻方向) + +## 現況 + +目前的 System-wide masking 使用 **Accessibility overlay**(`AXObserver` + `NSPanel`),是視覺遮蔽方案: +- ✅ 螢幕上看不到 key +- ❌ `⌘C` 複製仍可取得原始 key +- ❌ 螢幕錄影軟體可能錄到 overlay 下方的原始畫面(取決於 window level 順序) + +## 更安全的方向:ScreenCaptureKit + Virtual Camera + +最安全的方案是在**畫面輸出層**攔截,讓原始 key **從未出現在任何輸出中**。 + +### 概念架構 + +``` +[macOS 螢幕] + ↓ ScreenCaptureKit +[擷取畫面 frame (CGImage/IOSurface)] + ↓ +[OCR 偵測 API key 位置] ← 可複用現有的 pattern matching + ↓ +[在 frame 上繪製遮蔽區塊] + ↓ +[輸出到 Virtual Camera / 螢幕分享] +``` + +### 適用場景 + +| 場景 | overlay 方案 | ScreenCaptureKit 方案 | +|------|------------|---------------------| +| OBS 直播 | ⚠️ overlay 可能不在錄製範圍 | ✅ 輸出已遮蔽 | +| Google Meet 螢幕分享 | ⚠️ 取決於 window level | ✅ virtual camera 輸出已遮蔽 | +| 螢幕錄影 | ⚠️ 同上 | ✅ 錄到的就是遮蔽後的 | +| Copy/Paste | ❌ 原始 key 仍可複製 | ❌ 同樣無法防止(不在畫面層) | + +### 技術元件 + +#### 1. ScreenCaptureKit 擷取 + +```swift +import ScreenCaptureKit + +// 取得可擷取的螢幕 +let content = try await SCShareableContent.current +let display = content.displays.first! + +// 建立 filter(擷取整個螢幕) +let filter = SCContentFilter(display: display, excludingWindows: []) + +// 建立串流 +let config = SCStreamConfiguration() +config.width = display.width +config.height = display.height +config.pixelFormat = kCVPixelFormatType_32BGRA + +let stream = SCStream(filter: filter, configuration: config, delegate: self) +try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global()) +try await stream.startCapture() +``` + +#### 2. OCR 偵測 key 位置 + +兩種方案: + +**方案 A:Vision framework(Apple 原生 OCR)** +```swift +import Vision + +func detectKeys(in image: CGImage) -> [(String, CGRect)] { + let request = VNRecognizeTextRequest { request, error in + guard let results = request.results as? [VNRecognizedTextObservation] else { return } + for observation in results { + let text = observation.topCandidates(1).first?.string ?? "" + // 複用 MaskingCoordinator 的 pattern matching + let matches = maskingCoordinator.shouldMask(text: text) + // observation.boundingBox → 螢幕座標 + } + } + request.recognitionLevel = .fast // 即時處理需要 fast mode + let handler = VNImageRequestHandler(cgImage: image) + try? handler.perform([request]) +} +``` + +**方案 B:已知座標直接遮蔽(搭配 AX API)** + +不用 OCR — 複用現有 `SystemMaskingService` 的 `AXBoundsForRange` 座標,直接在擷取的 frame 上繪製遮蔽區塊。這比 OCR 快得多。 + +```swift +// 從 SystemMaskingService 取得已偵測的 key 座標 +let keyRects = systemMaskingService.getActiveOverlayRects() + +// 在 frame 上繪製遮蔽 +let context = CGContext(data: ..., width: ..., height: ...) +for rect in keyRects { + context.setFillColor(CGColor.white) + context.fill(rect) + // 可選:繪製 masked text +} +``` + +#### 3. Virtual Camera 輸出 + +使用 [CoreMediaIO DAL Plugin](https://developer.apple.com/documentation/coremediaio) 或 [OBS Virtual Camera](https://obsproject.com/) 將處理後的 frame 作為虛擬攝影機輸出。 + +第三方選項: +- [mac-virtual-camera](https://github.com/pjb/mac-virtual-camera) — Swift 實作 +- OBS Studio 的 Virtual Camera API + +### 已踩過的坑(省下你的時間) + +從 overlay 方案開發中學到的經驗: + +1. **AXBoundsForRange 是精確的** — 不需要估算字元寬度,直接用 AX API 取得多行文字的精確螢幕座標 +2. **座標轉換** — AX 用左上角原點,AppKit/CoreGraphics 用左下角,轉換公式:`appKitY = primaryScreenHeight - axY - height` +3. **多螢幕** — 用 primary screen height 作為轉換基準 +4. **效能基準** — Pattern matching 只要 0.1-0.3ms,AX 查詢 + overlay 更新 2-6ms。ScreenCaptureKit 方案的瓶頸會在 OCR(如果用的話) +5. **30ms debounce 足夠** — 人眼感知閃爍的閾值約 50ms,30ms debounce 夠快 +6. **App 切換要立即掃描** — 不能等 debounce,否則切回時 key 會閃現 500ms+ +7. **Overlay ID 要唯一** — 同一個 keyId 的多個出現需要不同的 overlay ID(用 keyId + match index) +8. **NSHostingView 要重用** — 每次 `new NSHostingView` 會造成 view 累積 + +### 建議實作順序 + +1. **先做 OBS 插件**(最簡單)— 寫一個 OBS Source Plugin,從 DemoSafe Core 取得 key 座標(via IPC),在 OBS 的畫面上繪製遮蔽區塊 +2. **再做 Virtual Camera**(中等)— 用 ScreenCaptureKit 擷取 + AX 座標繪製遮蔽 + DAL Plugin 輸出 +3. **最後做 OCR**(最複雜)— 如果 AX API 座標不可用(某些 app 不支援),用 Vision framework OCR 作為 fallback + +### 相關檔案 + +| 檔案 | 可複用內容 | +|------|---------| +| `Services/Accessibility/SystemMaskingService.swift` | AXObserver 設定、focused element 掃描、AXBoundsForRange 座標取得 | +| `Services/Masking/MaskingCoordinator.swift` | Pattern matching 引擎(`shouldMask()`) | +| `Views/Overlay/SystemOverlayController.swift` | 座標轉換 (`convertAXToAppKit`) | diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index 5ac827d..14dc01d 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -35,6 +35,7 @@ | ~~`⌃⌥⌘V` capture clipboard~~ | Spec §4.4 | ~~Medium~~ | ✅ HotkeyManager → ClipboardEngine.detectKeysInClipboard() → 22 built-in patterns → 3-tier confidence routing → VaultManager store | | ~~Linked Key Groups (sequential paste)~~ | Spec §6.3 | ~~Medium~~ | ✅ `LinkedGroup`/`GroupEntry`/`SequentialPasteEngine` complete, Settings UI group management (CRUD), `request_paste_group` IPC handler | | ~~Terminal masking (Shielded Terminal)~~ | Spec §3.2 | ~~Medium~~ | 🧪 Experimental. node-pty proxy + DEC 2026 sync block buffering + ANSI-aware masking. Known limitation: Rewind confirmation page partial leak | +| ~~System-wide masking (Accessibility overlay)~~ | Spec §3.3 | ~~Medium~~ | 🧪 Experimental. AXObserver + AXBoundsForRange + NSPanel overlay. Settings toggle, Demo Mode enhancement. Known limitation: focused element only, copy/paste unsafe | | Shortcut conflict detection | Spec §4.4 | Low | | | Import / Export vault | Spec §9.1 | Low | | diff --git a/docs/en/14-system-wide-masking-advanced.md b/docs/en/14-system-wide-masking-advanced.md new file mode 100644 index 0000000..76c85d1 --- /dev/null +++ b/docs/en/14-system-wide-masking-advanced.md @@ -0,0 +1,121 @@ +# System-Wide Masking: Advanced Secure Approach (Community Contribution Guide) + +## Current State + +The current system-wide masking uses **Accessibility overlay** (`AXObserver` + `NSPanel`) — a visual-only approach: +- ✅ Key not visible on screen +- ❌ `⌘C` copy still captures the original key +- ❌ Screen recording software may capture the original beneath the overlay + +## More Secure Direction: ScreenCaptureKit + Virtual Camera + +The most secure approach intercepts at the **frame output layer**, ensuring the original key **never appears in any output**. + +### Architecture + +``` +[macOS Screen] + ↓ ScreenCaptureKit +[Capture frame (CGImage/IOSurface)] + ↓ +[Detect API key positions] ← Reuse existing pattern matching + ↓ +[Draw masking blocks on frame] + ↓ +[Output to Virtual Camera / Screen Share] +``` + +### Use Case Comparison + +| Scenario | Overlay Approach | ScreenCaptureKit Approach | +|----------|-----------------|--------------------------| +| OBS livestream | ⚠️ Overlay may not be in capture | ✅ Output already masked | +| Google Meet screen share | ⚠️ Depends on window level | ✅ Virtual camera output masked | +| Screen recording | ⚠️ Same issue | ✅ Recording captures masked version | +| Copy/Paste | ❌ Original key still copyable | ❌ Same (not in frame layer) | + +### Technical Components + +#### 1. ScreenCaptureKit Capture + +```swift +import ScreenCaptureKit + +let content = try await SCShareableContent.current +let display = content.displays.first! +let filter = SCContentFilter(display: display, excludingWindows: []) + +let config = SCStreamConfiguration() +config.width = display.width +config.height = display.height +config.pixelFormat = kCVPixelFormatType_32BGRA + +let stream = SCStream(filter: filter, configuration: config, delegate: self) +try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: .global()) +try await stream.startCapture() +``` + +#### 2. Key Position Detection + +**Option A: Vision Framework OCR** +```swift +import Vision + +func detectKeys(in image: CGImage) -> [(String, CGRect)] { + let request = VNRecognizeTextRequest { request, error in + guard let results = request.results as? [VNRecognizedTextObservation] else { return } + for observation in results { + let text = observation.topCandidates(1).first?.string ?? "" + let matches = maskingCoordinator.shouldMask(text: text) + // observation.boundingBox → screen coordinates + } + } + request.recognitionLevel = .fast // Real-time needs fast mode +} +``` + +**Option B: AX API Coordinates (Recommended — No OCR needed)** + +Reuse existing `SystemMaskingService`'s `AXBoundsForRange` coordinates to draw masking blocks directly on captured frames. Much faster than OCR. + +```swift +let keyRects = systemMaskingService.getActiveOverlayRects() +let context = CGContext(data: ..., width: ..., height: ...) +for rect in keyRects { + context.setFillColor(CGColor.white) + context.fill(rect) +} +``` + +#### 3. Virtual Camera Output + +Use [CoreMediaIO DAL Plugin](https://developer.apple.com/documentation/coremediaio) or [OBS Virtual Camera](https://obsproject.com/). + +Third-party options: +- [mac-virtual-camera](https://github.com/pjb/mac-virtual-camera) — Swift implementation +- OBS Studio Virtual Camera API + +### Lessons Learned (From Overlay Implementation) + +1. **AXBoundsForRange is precise** — returns exact multi-line text screen coordinates, no estimation needed +2. **Coordinate conversion** — AX uses top-left origin, AppKit/CG uses bottom-left: `appKitY = primaryScreenHeight - axY - height` +3. **Multi-monitor** — use primary screen height as conversion reference +4. **Performance baseline** — pattern matching: 0.1-0.3ms, AX query + overlay: 2-6ms. ScreenCaptureKit bottleneck would be OCR (if used) +5. **30ms debounce is sufficient** — human flicker perception threshold is ~50ms +6. **Immediate scan on app switch** — don't wait for debounce, or key flashes for 500ms+ +7. **Unique overlay IDs** — same keyId with multiple occurrences needs distinct IDs (keyId + match index) +8. **Reuse NSHostingView** — creating new views on every update causes accumulation + +### Suggested Implementation Order + +1. **OBS Plugin first** (easiest) — write an OBS Source Plugin that gets key coordinates from DemoSafe Core via IPC, draw masking on OBS scene +2. **Virtual Camera next** (moderate) — ScreenCaptureKit capture + AX coordinates + DAL Plugin output +3. **OCR last** (complex) — Vision framework OCR as fallback when AX API coordinates unavailable + +### Reusable Files + +| File | Reusable Content | +|------|-----------------| +| `Services/Accessibility/SystemMaskingService.swift` | AXObserver setup, focused element scanning, AXBoundsForRange | +| `Services/Masking/MaskingCoordinator.swift` | Pattern matching engine (`shouldMask()`) | +| `Views/Overlay/SystemOverlayController.swift` | Coordinate conversion (`convertAXToAppKit`) | diff --git a/packages/swift-core/DemoSafe/App/AppState.swift b/packages/swift-core/DemoSafe/App/AppState.swift index d5f6381..61f2798 100644 --- a/packages/swift-core/DemoSafe/App/AppState.swift +++ b/packages/swift-core/DemoSafe/App/AppState.swift @@ -21,6 +21,8 @@ final class AppState: ObservableObject { let sequentialPasteEngine: SequentialPasteEngine let toolboxState: ToolboxState let toolboxController: FloatingToolboxController + let systemOverlayController: SystemOverlayController + let systemMaskingService: SystemMaskingService private var cancellables = Set() @@ -34,6 +36,8 @@ final class AppState: ObservableObject { self.hotkeyManager = HotkeyManager(maskingCoordinator: maskingCoordinator) self.toolboxState = ToolboxState(vaultManager: vaultManager) self.toolboxController = FloatingToolboxController() + self.systemOverlayController = SystemOverlayController() + self.systemMaskingService = SystemMaskingService(maskingCoordinator: maskingCoordinator, overlayController: systemOverlayController) // Sync isDemoMode bidirectionally with MaskingCoordinator maskingCoordinator.$isDemoMode @@ -85,6 +89,14 @@ final class AppState: ObservableObject { isDemoMode.toggle() maskingCoordinator.isDemoMode = isDemoMode maskingCoordinator.broadcastState() + + // Sync system-wide masking with Demo Mode (only if enabled in settings) + let systemMaskingEnabled = UserDefaults.standard.bool(forKey: "systemWideMasking") + if isDemoMode && systemMaskingEnabled { + systemMaskingService.start() + } else { + systemMaskingService.stop() + } } func switchContext(contextId: UUID) { @@ -116,18 +128,20 @@ final class AppState: ObservableObject { // MARK: - Private — Hotkey Wiring private func wireHotkeyCallbacks() { - // Toolbox show (hold start) + // Toolbox show (hold start) — also enables peek mode for system overlays hotkeyManager.onToolboxShow = { [weak self] in guard let self else { return } self.toolboxState.reset() self.toolboxState.isVisible = true + self.systemMaskingService.setPeekMode(true) let mouseLocation = NSEvent.mouseLocation self.toolboxController.show(near: mouseLocation) } - // Toolbox release (hold end) + // Toolbox release (hold end) — also disables peek mode hotkeyManager.onToolboxRelease = { [weak self] in guard let self else { return } + self.systemMaskingService.setPeekMode(false) self.toolboxState.handleRelease { [weak self] keyId in self?.copyKey(keyId: keyId) } diff --git a/packages/swift-core/DemoSafe/Services/Accessibility/SystemMaskingService.swift b/packages/swift-core/DemoSafe/Services/Accessibility/SystemMaskingService.swift new file mode 100644 index 0000000..1db27ed --- /dev/null +++ b/packages/swift-core/DemoSafe/Services/Accessibility/SystemMaskingService.swift @@ -0,0 +1,361 @@ +import Foundation +import AppKit +import os + +private let logger = Logger(subsystem: "com.demosafe", category: "SystemMasking") + +/// System-wide API key masking using macOS Accessibility API. +/// +/// Monitors the focused UI element across all applications. When text containing +/// an API key is detected, creates a floating overlay panel to visually mask it. +/// +/// Requires Accessibility permission (same as HotkeyManager). +final class SystemMaskingService { + private let maskingCoordinator: MaskingCoordinator + private let overlayController: SystemOverlayController + + private var observer: AXObserver? + private var currentAppPid: pid_t = 0 + private var currentAppElement: AXUIElement? + private var debounceTimer: Timer? + private var pollTimer: Timer? + fileprivate(set) var isRunning = false + private var lastScannedText: String? + + /// Tracks active overlays for diff/cleanup + private var activeOverlayIds: Set = [] + + // Processing takes only 2-6ms, so debounce can be very short + private static let initialScanDelay: TimeInterval = 0.01 // 10ms for first scan + private static let debounceInterval: TimeInterval = 0.03 // 30ms for subsequent changes + private var hasScannedSinceAppSwitch = false + + init(maskingCoordinator: MaskingCoordinator, overlayController: SystemOverlayController) { + self.maskingCoordinator = maskingCoordinator + self.overlayController = overlayController + } + + // MARK: - Public API + + func start() { + guard !isRunning else { return } + isRunning = true + logger.info("System masking started") + + // Listen for app activation changes via NSWorkspace + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(activeAppChanged(_:)), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + + // Scan the currently focused app + if let frontApp = NSWorkspace.shared.frontmostApplication { + observeApp(pid: frontApp.processIdentifier) + } + + // Polling fallback: some apps don't fire AX notifications reliably + pollTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.pollScan() + } + } + + func stop() { + guard isRunning else { return } + isRunning = false + logger.info("System masking stopped") + + NSWorkspace.shared.notificationCenter.removeObserver(self) + removeCurrentObserver() + overlayController.removeAllOverlays() + activeOverlayIds.removeAll() + debounceTimer?.invalidate() + debounceTimer = nil + pollTimer?.invalidate() + pollTimer = nil + lastScannedText = nil + } + + /// Temporarily show/hide overlays (peek mode). + func setPeekMode(_ enabled: Bool) { + overlayController.setPeekMode(enabled) + } + + // MARK: - App Change Handling + + @objc private func activeAppChanged(_ notification: Notification) { + guard isRunning else { return } + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return } + + // Don't observe ourselves + if app.processIdentifier == ProcessInfo.processInfo.processIdentifier { return } + + logger.info("Active app changed: \(app.localizedName ?? "unknown") (pid \(app.processIdentifier))") + + // Clear overlays from previous app + overlayController.removeAllOverlays() + activeOverlayIds.removeAll() + hasScannedSinceAppSwitch = false + lastScannedText = nil + + observeApp(pid: app.processIdentifier) + + // Scan IMMEDIATELY on app switch — don't wait for debounce or polling + scanFocusedElement() + } + + // MARK: - AXObserver + + private func observeApp(pid: pid_t) { + removeCurrentObserver() + currentAppPid = pid + currentAppElement = AXUIElementCreateApplication(pid) + + var obs: AXObserver? + let result = AXObserverCreate(pid, systemMaskingCallback, &obs) + guard result == .success, let obs else { + logger.warning("Failed to create AXObserver for pid \(pid): \(result.rawValue)") + return + } + + observer = obs + + // Listen for focus changes and value changes within this app + let notifications: [String] = [ + kAXFocusedUIElementChangedNotification, + kAXValueChangedNotification, + kAXSelectedTextChangedNotification, + ] + + let selfPtr = Unmanaged.passUnretained(self).toOpaque() + + for name in notifications { + AXObserverAddNotification(obs, currentAppElement!, name as CFString, selfPtr) + } + + CFRunLoopAddSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(obs), + .defaultMode + ) + + // Initial scan + scheduleScan() + } + + private func removeCurrentObserver() { + if let obs = observer { + CFRunLoopRemoveSource( + CFRunLoopGetMain(), + AXObserverGetRunLoopSource(obs), + .defaultMode + ) + } + observer = nil + currentAppElement = nil + currentAppPid = 0 + } + + // MARK: - Scanning + + func handleNotification(_ notification: String) { + guard isRunning else { return } + scheduleScan() + } + + private func scheduleScan() { + debounceTimer?.invalidate() + + // First scan after app switch: fast (50ms) + // Subsequent changes: debounce (150ms) + let delay = hasScannedSinceAppSwitch ? Self.debounceInterval : Self.initialScanDelay + + debounceTimer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false) { [weak self] _ in + self?.hasScannedSinceAppSwitch = true + self?.scanFocusedElement() + } + } + + private func scanFocusedElement() { + guard let appElement = currentAppElement else { return } + + // Get the focused UI element + var focusedElement: AnyObject? + let result = AXUIElementCopyAttributeValue( + appElement, + kAXFocusedUIElementAttribute as CFString, + &focusedElement + ) + + guard result == .success, let element = focusedElement else { return } + + // AXUIElement is a CFTypeRef — safe cast check + let axElement = element as! AXUIElement // Always AXUIElement from CopyAttributeValue + + // Get the text value + var valueRef: AnyObject? + let valueResult = AXUIElementCopyAttributeValue( + axElement, + kAXValueAttribute as CFString, + &valueRef + ) + + guard valueResult == .success, let text = valueRef as? String, !text.isEmpty else { + // No text — clear overlays + if !activeOverlayIds.isEmpty { + overlayController.removeAllOverlays() + activeOverlayIds.removeAll() + } + return + } + + // Skip if text hasn't changed (avoids redundant overlay updates) + if text == lastScannedText { return } + lastScannedText = text + + // Run pattern matching + let matches = maskingCoordinator.shouldMask(text: text) + + if matches.isEmpty { + if !activeOverlayIds.isEmpty { + overlayController.removeAllOverlays() + activeOverlayIds.removeAll() + } + return + } + + // Get element position and size for coordinate calculation + guard let elementRect = getElementRect(axElement) else { return } + + // Create overlays for each match (use unique ID per occurrence, not per keyId) + var newActiveIds: Set = [] + + for (i, match) in matches.enumerated() { + // Generate a deterministic UUID from keyId + match index to allow diff/reuse + let overlayId = UUID( + uuid: withUnsafeBytes(of: match.keyId.uuid) { keyBytes in + var bytes = [UInt8](keyBytes) + // Mix in the index to make each occurrence unique + bytes[14] = UInt8(i & 0xFF) + bytes[15] = UInt8((i >> 8) & 0xFF) + return (bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15]) + } + ) + + let overlayRect = estimateKeyRect( + element: axElement, + elementRect: elementRect, + text: text, + matchRange: match.matchedRange + ) + + overlayController.showOverlay( + id: overlayId, + at: overlayRect, + maskedText: match.maskedText + ) + newActiveIds.insert(overlayId) + } + + // Remove stale overlays + overlayController.removeStaleOverlays(activeIds: newActiveIds) + activeOverlayIds = newActiveIds + + logger.info("System masking: \(matches.count) key(s) masked") + } + + /// Polling fallback — check if text changed and re-scan if needed. + /// Does NOT clear cache blindly; reads current text first and compares. + private func pollScan() { + guard isRunning, let appElement = currentAppElement else { return } + + // Quick check: read focused element's text without full scan + var focusedElement: AnyObject? + guard AXUIElementCopyAttributeValue(appElement, kAXFocusedUIElementAttribute as CFString, &focusedElement) == .success else { return } + + var valueRef: AnyObject? + guard AXUIElementCopyAttributeValue(focusedElement as! AXUIElement, kAXValueAttribute as CFString, &valueRef) == .success, + let text = valueRef as? String else { return } + + // Only re-scan if text actually changed + if text != lastScannedText { + lastScannedText = nil // Force rescan + scanFocusedElement() + } + } + + // MARK: - Coordinate Helpers + + private func getElementRect(_ element: AXUIElement) -> CGRect? { + var positionRef: AnyObject? + var sizeRef: AnyObject? + + guard AXUIElementCopyAttributeValue(element, kAXPositionAttribute as CFString, &positionRef) == .success, + AXUIElementCopyAttributeValue(element, kAXSizeAttribute as CFString, &sizeRef) == .success, + positionRef != nil, sizeRef != nil else { + return nil + } + + var point = CGPoint.zero + var size = CGSize.zero + + // AXValue is a CF type — CopyAttributeValue guarantees the correct type + AXValueGetValue(positionRef as! AXValue, .cgPoint, &point) + AXValueGetValue(sizeRef as! AXValue, .cgSize, &size) + + return CGRect(origin: point, size: size) + } + + /// Get the screen rectangle of a matched key within a text element. + /// + /// Uses kAXBoundsForRangeParameterizedAttribute for precise multi-line bounds. + /// Falls back to covering the entire element if the parameterized attribute is unavailable. + private func estimateKeyRect(element: AXUIElement, elementRect: CGRect, text: String, matchRange: Range) -> CGRect { + let startOffset = text.distance(from: text.startIndex, to: matchRange.lowerBound) + let matchLength = text.distance(from: matchRange.lowerBound, to: matchRange.upperBound) + + // Try precise bounds via AX parameterized attribute + var cfRange = CFRange(location: startOffset, length: matchLength) + let rangeValue: AnyObject? = AXValueCreate(.cfRange, &cfRange) + + // kAXBoundsForRangeParameterizedAttribute is not in Swift headers, use string directly + var boundsRef: AnyObject? + if let rangeValue, + AXUIElementCopyParameterizedAttributeValue( + element, + "AXBoundsForRange" as CFString, + rangeValue, + &boundsRef + ) == .success, + boundsRef != nil { + var rect = CGRect.zero + AXValueGetValue(boundsRef as! AXValue, .cgRect, &rect) + return rect + } + + logger.info("AXBoundsForRange unavailable, using element rect: \("\(elementRect.origin.x),\(elementRect.origin.y) \(elementRect.width)x\(elementRect.height)")") + // Fallback: cover the entire element + return elementRect + } +} + +// MARK: - AXObserver C Callback + +private func systemMaskingCallback( + _ observer: AXObserver, + _ element: AXUIElement, + _ notification: CFString, + _ refcon: UnsafeMutableRawPointer? +) { + guard let refcon else { return } + let service = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + // Guard: only dispatch if service is still running (prevents use-after-stop) + guard service.isRunning else { return } + DispatchQueue.main.async { [weak service] in + service?.handleNotification(notification as String) + } +} diff --git a/packages/swift-core/DemoSafe/Views/Overlay/MaskOverlayView.swift b/packages/swift-core/DemoSafe/Views/Overlay/MaskOverlayView.swift new file mode 100644 index 0000000..96dca74 --- /dev/null +++ b/packages/swift-core/DemoSafe/Views/Overlay/MaskOverlayView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +/// Visual overlay that covers an API key with masked text. +/// Fills the entire panel frame with a solid background to fully cover the key. +struct MaskOverlayView: View { + let maskedText: String + + var body: some View { + ZStack { + // Solid background to fully cover the key area + Color(nsColor: .textBackgroundColor) + + // Masked text aligned to top-left + Text(maskedText) + .font(.system(.body, design: .monospaced)) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, 2) + .padding(.vertical, 1) + } + } +} diff --git a/packages/swift-core/DemoSafe/Views/Overlay/SystemOverlayController.swift b/packages/swift-core/DemoSafe/Views/Overlay/SystemOverlayController.swift new file mode 100644 index 0000000..9f5b8ef --- /dev/null +++ b/packages/swift-core/DemoSafe/Views/Overlay/SystemOverlayController.swift @@ -0,0 +1,126 @@ +import AppKit +import SwiftUI +import os + +private let logger = Logger(subsystem: "com.demosafe", category: "SystemOverlay") + +/// Manages floating NSPanel overlays that cover detected API keys on screen. +/// Each overlay is a click-through, non-activating panel positioned over the key. +final class SystemOverlayController { + private var overlayPanels: [UUID: NSPanel] = [:] + private var isPeekMode = false + + // MARK: - Public API + + /// Show or update an overlay at the given screen position. + func showOverlay(id: UUID, at axRect: CGRect, maskedText: String) { + let panel = overlayPanels[id] ?? createOverlayPanel() + overlayPanels[id] = panel + + let screenRect = convertAXToAppKit(axRect) + panel.setFrame(screenRect, display: false) + updateContent(panel, maskedText: maskedText) + + if !isPeekMode { + panel.orderFrontRegardless() + } + } + + /// Remove a specific overlay. + func removeOverlay(id: UUID) { + if let panel = overlayPanels.removeValue(forKey: id) { + panel.orderOut(nil) + } + } + + /// Remove all overlays. + func removeAllOverlays() { + for (_, panel) in overlayPanels { + panel.orderOut(nil) + } + overlayPanels.removeAll() + } + + /// Remove overlays not in the given set of active IDs. + func removeStaleOverlays(activeIds: Set) { + let staleIds = Set(overlayPanels.keys).subtracting(activeIds) + for id in staleIds { + removeOverlay(id: id) + } + } + + /// Temporarily hide all overlays (peek mode). + func setPeekMode(_ enabled: Bool) { + isPeekMode = enabled + for (_, panel) in overlayPanels { + if enabled { + panel.orderOut(nil) + } else { + panel.orderFrontRegardless() + } + } + } + + var overlayCount: Int { + overlayPanels.count + } + + // MARK: - Private + + private func createOverlayPanel() -> NSPanel { + let panel = NSPanel( + contentRect: .zero, + styleMask: [.nonactivatingPanel, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + panel.level = .floating + panel.isFloatingPanel = true + panel.becomesKeyOnlyIfNeeded = true + panel.hidesOnDeactivate = false + panel.isMovableByWindowBackground = false + panel.isReleasedWhenClosed = false + panel.backgroundColor = .clear + panel.isOpaque = false + panel.hasShadow = false + panel.ignoresMouseEvents = true // Click-through + panel.titleVisibility = .hidden + panel.titlebarAppearsTransparent = true + panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + + return panel + } + + private func updateContent(_ panel: NSPanel, maskedText: String) { + let view = MaskOverlayView(maskedText: maskedText) + if let hostingView = panel.contentView as? NSHostingView { + hostingView.rootView = view + } else { + panel.contentView = NSHostingView(rootView: view) + } + } + + /// Convert AX coordinates (top-left origin) to AppKit coordinates (bottom-left origin). + /// + /// AX uses screen coordinates with origin at top-left of primary display. + /// AppKit uses screen coordinates with origin at bottom-left of primary display. + /// Both use the same point scale (logical points, not pixels). + private func convertAXToAppKit(_ axRect: CGRect) -> NSRect { + // Primary screen height is the reference for coordinate flipping + guard let primaryScreen = NSScreen.screens.first else { + return NSRect(origin: .zero, size: axRect.size) + } + + let primaryHeight = primaryScreen.frame.height + let appKitY = primaryHeight - axRect.origin.y - axRect.height + + + return NSRect( + x: axRect.origin.x, + y: appKitY, + width: axRect.width, + height: axRect.height + ) + } +} diff --git a/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift b/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift index 682573d..4664209 100644 --- a/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift +++ b/packages/swift-core/DemoSafe/Views/Settings/SettingsView.swift @@ -644,9 +644,25 @@ struct ContextModeTab: View { struct SecuritySettingsTab: View { @AppStorage("requireTouchID") private var requireTouchID = false + @AppStorage("systemWideMasking") private var systemWideMasking = false + @EnvironmentObject var appState: AppState var body: some View { Form { + Section("Demo Mode Enhancement") { + Toggle("System-wide masking (experimental)", isOn: $systemWideMasking) + .onChange(of: systemWideMasking) { _, enabled in + if enabled && appState.isDemoMode { + appState.systemMaskingService.start() + } else { + appState.systemMaskingService.stop() + } + } + Text("When Demo Mode is active, mask API keys detected in any application using Accessibility overlay.") + .font(.caption) + .foregroundColor(.secondary) + } + Section("Keychain") { Toggle("Require Touch ID for key access", isOn: $requireTouchID) Text("When enabled, Touch ID or password is required each time a key is accessed from the Keychain.")