diff --git a/CLAUDE.md b/CLAUDE.md index 831c47b..1ed90d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,10 @@ These are absolute rules — never violate them: - **React SPA input masking**: For dialog inputs in React/Vue SPAs, do NOT set `visibility: visible` after masking — the framework overwrites `input.value` but keeps our inline styles, exposing plaintext. Instead, keep inputs hidden by manifest CSS. - **AWS dual-key capture**: AWS has Access Key ID (`AKIA...`, DOM scan) + Secret Access Key (no prefix, 40-char base64, clipboard-only capture via `isAwsConsolePage()` in `handleClipboardText()`). - **Toast stacking**: Multiple consecutive captures show stacked toasts (each calculates top offset from existing toasts) instead of replacing the previous one. +- **NMH dual-path IPC**: NativeMessagingHost supports both `get_config` (direct ipc.json read) and WS relay (`get_state`, `submit_captured_key`, `toggle_demo_mode`). WS relay uses `URLSessionWebSocketTask` short-lived connection (~20-60ms). clientType `"nmh"` — Core skips NMH in broadcast. `sendRequest()` in service-worker.ts tries WS first, then NMH fallback. +- **NMH no plaintext queue**: When both WS and NMH fail for `submit_captured_key`, the key is NOT queued to `chrome.storage.local`. Storing plaintext API keys in browser storage violates security red line #1. The key is lost and must be re-captured. +- **NMH isConnected semantics**: NMH relay success does NOT set `state.isConnected = true` — NMH is a one-shot relay, not a persistent connection. Popup shows "Connected (NMH)" via `connectionPath` field only, while WS reconnect continues independently. +- **NMHInstaller**: Core auto-installs NMH binary + Chrome manifest from app bundle Resources on startup. Uses binary file size comparison for version checking. `install.sh` retained as manual fallback. ## Documentation diff --git a/docs/01-product-spec/implementation-status.md b/docs/01-product-spec/implementation-status.md index 29d6238..a5dbc8b 100644 --- a/docs/01-product-spec/implementation-status.md +++ b/docs/01-product-spec/implementation-status.md @@ -1,6 +1,6 @@ # 實作狀態追蹤 -> 最後更新:2026-03-17 +> 最後更新:2026-03-18 ## 狀態圖例 @@ -19,7 +19,7 @@ | KeychainService | ✅ | store / retrieve / delete 完成 | | ClipboardEngine | ✅ | copy + autoClear + detectKeys 完成 | | MaskingCoordinator | ✅ | isDemoMode / activeContext / pattern 匹配完成 | -| IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode | +| IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode / nmh clientType | | HotkeyManager | ✅ | `⌃⌥⌘D` toggle、`⌃⌥Space` hold 偵測、`⌃⌥[1-9]` paste、flagsChanged 監聽 | | Floating Toolbox (HUD) | ✅ | NSPanel 浮動視窗、hold-to-search、Scheme B 鎖定、↑↓ 導航 | | ToolboxState (ViewModel) | ✅ | 搜尋過濾、選取狀態、release/confirm/dismiss 邏輯 | @@ -67,14 +67,16 @@ | 功能 | 狀態 | 備註 | |------|------|------| -| Background Service Worker | ✅ | WebSocket 連線、state 管理、reconnect | -| Popup UI | ✅ | 連線狀態、Demo Mode、Context、Patterns | +| Background Service Worker | ✅ | WebSocket 連線、NMH fallback 雙路分派、state 管理、reconnect | +| Popup UI | ✅ | 連線狀態(WebSocket/NMH/Offline)、Demo Mode、Context、Patterns | | Toggle Demo Mode | ✅ | Popup → Background → Core → broadcast | | Content Script DOM masking | ✅ | TreeWalker + CSS overlay + MutationObserver | | Content Script unmask | ✅ | 退出 Demo Mode 恢復原始文字 | | Options 頁面 | ✅ | Pattern cache 管理 + Dev IPC Config | | Dev IPC Config (workaround) | ✅ | 替代 Native Messaging Host | -| Native Messaging Host | ❌ | Swift binary 未編譯部署 | +| Native Messaging Host | ✅ | get_config + WS relay(get_state / submit_captured_key / toggle_demo_mode) | +| NMH 雙路 IPC | ✅ | WS primary + NMH fallback,popup 顯示連線路徑 | +| NMHInstaller(Core 自動安裝) | ✅ | Core 啟動時從 bundle Resources 安裝 binary + manifest | | Active Key Capture | ✅ | 4 層偵測:DOM scan → attribute → clipboard → platform selectors | | capture-patterns.ts (SSoT) | ✅ | 11 平台 pattern 定義,單一檔案維護 | | Pre-hide anti-flash | ✅ | 三層:manifest CSS → pre-hide.ts → instant MutationObserver | diff --git a/docs/02-technical-architecture/chrome-extension-architecture.md b/docs/02-technical-architecture/chrome-extension-architecture.md index 29f8aa6..02823de 100644 --- a/docs/02-technical-architecture/chrome-extension-architecture.md +++ b/docs/02-technical-architecture/chrome-extension-architecture.md @@ -1,7 +1,7 @@ # Chrome Extension 架構 -> 狀態:✅ 核心功能完成(WebSocket 連線、Content Script masking、Popup UI) -> 尚未完成:Native Messaging Host 部署、Smart Extract +> 狀態:✅ 核心功能完成(WebSocket 連線、NMH 雙路 IPC、Content Script masking、Popup UI) +> 尚未完成:Smart Extract --- @@ -43,6 +43,7 @@ **職責**: - 維持與 Swift Core 的 WebSocket 連線(含指數退避重連) - 透過 Native Messaging Host 取得 IPC 連線資訊 +- **雙路分派**:WS primary → NMH fallback(get_state / submit_captured_key / toggle_demo_mode) - 接收 Core 事件並轉發至 Content Scripts - 回應 Popup 的狀態查詢 - 持久化 pattern cache 至 `chrome.storage.local` @@ -53,6 +54,12 @@ 3. 發送 handshake(clientType: 'chrome', token, version) 4. 收到 success → 標記 connected,開始接收事件 +**雙路分派(Dual-path Dispatch)**: +- `sendRequest(action, payload)` 統一入口 +- WS 連線中 → 透過 WS 發送(含 request-response correlation + 5s timeout) +- WS 斷線且 action 為 relay 清單 → 透過 NMH fallback +- 雙路皆失敗 → log warning(不將明文 key 存入 chrome.storage,遵守安全紅線) + **Dev Fallback**: - Native Host 不可用時,從 `chrome.storage.local` 讀取手動設定的 port/token - Options 頁面提供 Dev IPC Config 輸入介面 @@ -108,11 +115,12 @@ chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { ### Popup (`popup.ts` + `popup.html`) **顯示資訊**: -- Connection 狀態(綠點 Connected / 紅點 Offline) +- Connection 狀態與路徑(綠點 WebSocket / 藍點 NMH / 紅點 Offline) - Mode(Normal / Demo) - Active Context 名稱 - Pattern 數量 - Toggle Demo Mode 按鈕 +- Capture Mode 控制 ### Options (`options.ts` + `options.html`) @@ -126,31 +134,61 @@ chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { ## Native Messaging Host -### 架構 +### 架構(雙路 IPC) + +NMH 同時支援兩種模式:**config 查詢**和 **WS relay**。 ``` Chrome Extension → chrome.runtime.sendNativeMessage('com.demosafe.nmh', ...) ↓ stdin (4-byte length prefix + JSON) NativeMessagingHost (Swift binary) - ↓ 讀取 ~/.demosafe/ipc.json + ├─ action: get_config → 讀取 ~/.demosafe/ipc.json → 回傳 {port, token} + └─ action: get_state / submit_captured_key / toggle_demo_mode + → 讀取 ipc.json 取得 port/token + → 建立短暫 WS 連線至 Core(URLSessionWebSocketTask) + → handshake (clientType: "nmh") → 送 request → 收 response → 斷線 + → stdout 回傳 Core 的 response ↓ stdout (4-byte length prefix + JSON) -Chrome Extension ← { port: 55535, token: "..." } +Chrome Extension ← response ``` +**WS Relay 特性**: +- 短暫連線:connect → handshake → 1 request → 1 response → close(~20-60ms) +- clientType `"nmh"`:Core 不會對 NMH 連線推送 events(broadcast 時跳過) +- 5 秒 timeout,失敗回傳 `{"error": "core_unreachable" | "auth_failed" | "timeout"}` +- 會自動跳過 handshake 後 Core 推送的 event messages(如 pattern_cache_sync) + +### 支援的 Actions + +| Action | 模式 | 說明 | +|--------|------|------| +| `get_config` | 直讀 ipc.json | 回傳 `{port, token}`,原有行為 | +| `get_state` | WS relay | 回傳 isDemoMode、activeContext、patternCacheVersion | +| `submit_captured_key` | WS relay | 提交截取到的 API key | +| `toggle_demo_mode` | WS relay | 切換 Demo Mode | + ### 安裝路徑 | 檔案 | 路徑 | |------|------| -| Swift binary | `/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh` | +| Swift binary | `~/.demosafe/bin/demosafe-nmh` | | Host manifest | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | +### 自動安裝(NMHInstaller) + +Core 啟動時自動檢查並安裝 NMH: +1. 檢查 `~/.demosafe/bin/demosafe-nmh` 是否存在(比對 binary size) +2. 檢查 Chrome NMH manifest 是否存在且 allowed_origins 正確 +3. 缺少或版本不符 → 從 app bundle Resources 複製 binary + 寫入 manifest +4. `install.sh` 保留作為手動備援 + ### Host Manifest (`com.demosafe.nmh.json`) ```json { "name": "com.demosafe.nmh", - "description": "DemoSafe Native Messaging Host", - "path": "/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh", + "description": "DemoSafe Native Messaging Host — relay for Chrome Extension", + "path": "/Users//.demosafe/bin/demosafe-nmh", "type": "stdio", "allowed_origins": [ "chrome-extension://ACTUAL_EXTENSION_ID/" @@ -158,13 +196,6 @@ Chrome Extension ← { port: 55535, token: "..." } } ``` -### 部署步驟(尚未自動化) - -1. 編譯 `native-host/NativeMessagingHost.swift` 為 binary -2. 放置至 `/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh` -3. 將 `com.demosafe.nmh.json` 複製至 NativeMessagingHosts 目錄 -4. 替換 `EXTENSION_ID_HERE` 為實際 Chrome Extension ID - --- ## Content Script 目標網站 diff --git a/docs/02-technical-architecture/extension-boundaries.md b/docs/02-technical-architecture/extension-boundaries.md index 95d5f3a..7c5d800 100644 --- a/docs/02-technical-architecture/extension-boundaries.md +++ b/docs/02-technical-architecture/extension-boundaries.md @@ -60,20 +60,22 @@ ### 連線架構 - Background Service Worker 維護與 Core 的 WebSocket 連線 -- Native Messaging Host(Swift helper)讀取 `ipc.json` 以輔助探索 +- Native Messaging Host(Swift helper)提供 config 查詢 + WS relay 雙路備援 ### Native Messaging Host 規格 -Chrome Extension 無法直接讀取檔案系統(`~/.demosafe/ipc.json`),因此需要 Native Messaging Host 作為橋接: +Chrome Extension 無法直接讀取檔案系統(`~/.demosafe/ipc.json`),因此需要 Native Messaging Host 作為橋接。NMH 同時作為 WS 斷線時的 fallback relay。 | 項目 | 說明 | |------|------| -| 實作語言 | Swift(macOS helper binary) | -| 安裝位置 | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | -| 職責 | 讀取 `ipc.json` → 回傳 `{port, token}` 給 Background Service Worker | +| 實作語言 | Swift(macOS helper binary,standalone swiftc 編譯,~93KB) | +| 安裝位置 | binary: `~/.demosafe/bin/demosafe-nmh`,manifest: `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | +| 職責 | (1) 讀取 `ipc.json` → 回傳 `{port, token}`;(2) WS relay:get_state / submit_captured_key / toggle_demo_mode | | 通訊協定 | Chrome Native Messaging(stdin/stdout JSON) | -| 觸發時機 | Extension 啟動時呼叫一次取得連線資訊;Core 重啟後重新呼叫 | -| 安全性 | manifest 限定 `allowed_origins` 僅允許本 Extension ID | +| WS Relay | 短暫連線(connect → handshake → 1 request → 1 response → close),clientType: `"nmh"`,5s timeout | +| 觸發時機 | Extension 啟動時取連線資訊;WS 斷線時 fallback relay | +| 安裝方式 | Core 啟動時 NMHInstaller 自動安裝;`install.sh` 手動備援 | +| 安全性 | manifest 限定 `allowed_origins`;NMH 不儲存任何資料,僅轉發 | --- diff --git a/docs/05-ipc-protocol/protocol-spec.md b/docs/05-ipc-protocol/protocol-spec.md index 4daba39..3e34270 100644 --- a/docs/05-ipc-protocol/protocol-spec.md +++ b/docs/05-ipc-protocol/protocol-spec.md @@ -36,7 +36,7 @@ | Action | Payload | Response | |--------|---------|----------| -| `handshake` | `clientType`, `token`, `version` | port, pid, patternCache version, 連線狀態 | +| `handshake` | `clientType`(`vscode` / `chrome` / `accessibility` / `nmh`), `token`, `version` | port, pid, patternCache version, 連線狀態 | | `get_state` | (無) | isDemoMode, activeContext, patternCache, version | | `request_paste` | `keyId` | status (`success` \| `denied` \| `offline`) | | `request_paste_group` | `groupId`, `fieldIndex`(可選) | status, groupId | @@ -159,6 +159,19 @@ Core 維護 `patternCacheVersion`,每次 pattern 變更時遞增。Extension --- +## NMH Relay(Native Messaging Host 短暫連線) + +clientType `"nmh"` 為 Chrome Native Messaging Host 透過短暫 WS 連線轉發請求。 + +| 特性 | 說明 | +|------|------| +| 連線生命週期 | connect → handshake → 1 request → 1 response → close(~20-60ms) | +| 支援的 action | `get_state`、`submit_captured_key`、`toggle_demo_mode` | +| broadcast 排除 | Core broadcast events 時自動跳過 `.nmh` clients | +| 使用場景 | Chrome Extension WS 斷線時的 fallback 路徑 | + +--- + ## 安全約束 | 規則 | 說明 | @@ -166,4 +179,5 @@ Core 維護 `patternCacheVersion`,每次 pattern 變更時遞增。Extension | WebSocket 僅 127.0.0.1 | 禁止綁定外部介面 | | Handshake 認證 | 需要 `ipc.json` 中的 token | | **明文永不經過 IPC** | 僅遮蔽表示和參照流過網路 | +| **明文不存入 chrome.storage** | submit_captured_key 失敗時不 queue,遵守安全紅線 | | `ipc.json` 權限 600 | 僅限使用者讀寫 | diff --git a/docs/en/01-product-spec/implementation-status.md b/docs/en/01-product-spec/implementation-status.md index da5d2d5..a38f7bf 100644 --- a/docs/en/01-product-spec/implementation-status.md +++ b/docs/en/01-product-spec/implementation-status.md @@ -1,6 +1,6 @@ # Implementation Status Tracking -> Last updated: 2026-03-17 +> Last updated: 2026-03-18 ## Status Legend @@ -19,7 +19,7 @@ | KeychainService | ✅ | store / retrieve / delete completed | | ClipboardEngine | ✅ | copy + autoClear + detectKeys completed | | MaskingCoordinator | ✅ | isDemoMode / activeContext / pattern matching completed | -| IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode | +| IPCServer (WebSocket) | ✅ | handshake / state_changed / pattern_cache_sync / toggle_demo_mode / nmh clientType | | 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 | @@ -67,14 +67,16 @@ | Feature | Status | Notes | |---------|--------|-------| -| Background Service Worker | ✅ | WebSocket connection, state management, reconnect | -| Popup UI | ✅ | Connection status, Demo Mode, Context, Patterns | +| Background Service Worker | ✅ | WebSocket connection, NMH fallback dual-path dispatch, state management, reconnect | +| Popup UI | ✅ | Connection status (WebSocket/NMH/Offline), Demo Mode, Context, Patterns | | Toggle Demo Mode | ✅ | Popup → Background → Core → broadcast | | Content Script DOM masking | ✅ | TreeWalker + CSS overlay + MutationObserver | | Content Script unmask | ✅ | Restore original text when exiting Demo Mode | | Options page | ✅ | Pattern cache management + Dev IPC Config | | Dev IPC Config (workaround) | ✅ | Alternative to Native Messaging Host | -| Native Messaging Host | ❌ | Swift binary not compiled/deployed | +| Native Messaging Host | ✅ | get_config + WS relay (get_state / submit_captured_key / toggle_demo_mode) | +| Dual-path IPC (NMH fallback) | ✅ | WS primary + NMH fallback, popup shows connection path | +| NMHInstaller (Core auto-install) | ✅ | Core installs binary + manifest from bundle Resources on startup | | Active Key Capture | ✅ | 4-layer detection: DOM scan → attribute → clipboard → platform selectors | | capture-patterns.ts (SSoT) | ✅ | 11 platform pattern definitions, single file maintenance | | Pre-hide anti-flash | ✅ | 3 layers: manifest CSS → pre-hide.ts → instant MutationObserver | diff --git a/docs/en/02-technical-architecture/chrome-extension-architecture.md b/docs/en/02-technical-architecture/chrome-extension-architecture.md index 6e5544a..84572e3 100644 --- a/docs/en/02-technical-architecture/chrome-extension-architecture.md +++ b/docs/en/02-technical-architecture/chrome-extension-architecture.md @@ -1,7 +1,7 @@ # Chrome Extension Architecture -> Status: ✅ Core features completed (WebSocket connection, Content Script masking, Popup UI) -> Not yet completed: Native Messaging Host deployment, Smart Extract +> Status: ✅ Core features completed (WebSocket connection, NMH dual-path IPC, Content Script masking, Popup UI) +> Not yet completed: Smart Extract --- @@ -43,6 +43,7 @@ **Responsibilities**: - Maintain WebSocket connection to Swift Core (with exponential backoff reconnection) - Obtain IPC connection info via Native Messaging Host +- **Dual-path dispatch**: WS primary → NMH fallback (get_state / submit_captured_key / toggle_demo_mode) - Receive Core events and forward to Content Scripts - Respond to Popup state queries - Persist pattern cache to `chrome.storage.local` @@ -53,6 +54,12 @@ 3. Send handshake (clientType: 'chrome', token, version) 4. Receive success → mark as connected, start receiving events +**Dual-path Dispatch**: +- `sendRequest(action, payload)` — unified entry point +- WS connected → send via WS (with request-response correlation + 5s timeout) +- WS disconnected + action in relay list → fallback via NMH +- Both fail → log warning (plaintext keys are NOT queued to chrome.storage — security red line) + **Dev Fallback**: - When Native Host is unavailable, read manually configured port/token from `chrome.storage.local` - Options page provides Dev IPC Config input interface @@ -108,11 +115,12 @@ chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { ### Popup (`popup.ts` + `popup.html`) **Displayed Information**: -- Connection status (green dot Connected / red dot Offline) +- Connection status and path (green dot WebSocket / blue dot NMH / red dot Offline) - Mode (Normal / Demo) - Active Context name - Pattern count - Toggle Demo Mode button +- Capture Mode control ### Options (`options.ts` + `options.html`) @@ -126,31 +134,61 @@ chrome.runtime.sendMessage({ type: 'get_state' }, (response) => { ## Native Messaging Host -### Architecture +### Architecture (Dual-path IPC) + +NMH supports two modes: **config query** and **WS relay**. ``` Chrome Extension → chrome.runtime.sendNativeMessage('com.demosafe.nmh', ...) ↓ stdin (4-byte length prefix + JSON) NativeMessagingHost (Swift binary) - ↓ reads ~/.demosafe/ipc.json + ├─ action: get_config → read ~/.demosafe/ipc.json → return {port, token} + └─ action: get_state / submit_captured_key / toggle_demo_mode + → read ipc.json for port/token + → open short-lived WS to Core (URLSessionWebSocketTask) + → handshake (clientType: "nmh") → send request → receive response → close + → stdout returns Core's response ↓ stdout (4-byte length prefix + JSON) -Chrome Extension ← { port: 55535, token: "..." } +Chrome Extension ← response ``` +**WS Relay Characteristics**: +- Short-lived connection: connect → handshake → 1 request → 1 response → close (~20-60ms) +- clientType `"nmh"`: Core skips NMH connections when broadcasting events +- 5 second timeout, failure returns `{"error": "core_unreachable" | "auth_failed" | "timeout"}` +- Automatically skips event messages pushed by Core after handshake (e.g., pattern_cache_sync) + +### Supported Actions + +| Action | Mode | Description | +|--------|------|-------------| +| `get_config` | Direct ipc.json read | Returns `{port, token}`, original behavior | +| `get_state` | WS relay | Returns isDemoMode, activeContext, patternCacheVersion | +| `submit_captured_key` | WS relay | Submit captured API key | +| `toggle_demo_mode` | WS relay | Toggle Demo Mode | + ### Installation Paths | File | Path | |------|------| -| Swift binary | `/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh` | +| Swift binary | `~/.demosafe/bin/demosafe-nmh` | | Host manifest | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | +### Auto-installation (NMHInstaller) + +Core automatically checks and installs NMH on startup: +1. Check if `~/.demosafe/bin/demosafe-nmh` exists (compare binary size) +2. Check if Chrome NMH manifest exists with correct allowed_origins +3. Missing or version mismatch → copy binary from app bundle Resources + write manifest +4. `install.sh` retained as manual fallback + ### Host Manifest (`com.demosafe.nmh.json`) ```json { "name": "com.demosafe.nmh", - "description": "DemoSafe Native Messaging Host", - "path": "/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh", + "description": "DemoSafe Native Messaging Host — relay for Chrome Extension", + "path": "/Users//.demosafe/bin/demosafe-nmh", "type": "stdio", "allowed_origins": [ "chrome-extension://ACTUAL_EXTENSION_ID/" @@ -158,13 +196,6 @@ Chrome Extension ← { port: 55535, token: "..." } } ``` -### Deployment Steps (not yet automated) - -1. Compile `native-host/NativeMessagingHost.swift` to binary -2. Place at `/Applications/DemoSafe.app/Contents/Helpers/demosafe-nmh` -3. Copy `com.demosafe.nmh.json` to NativeMessagingHosts directory -4. Replace `EXTENSION_ID_HERE` with actual Chrome Extension ID - --- ## Content Script Target Websites diff --git a/docs/en/02-technical-architecture/extension-boundaries.md b/docs/en/02-technical-architecture/extension-boundaries.md index 6f93a9c..2f596b4 100644 --- a/docs/en/02-technical-architecture/extension-boundaries.md +++ b/docs/en/02-technical-architecture/extension-boundaries.md @@ -60,20 +60,22 @@ File open/change → regex scan (using cached patterns) ### Connection Architecture - Background Service Worker maintains WebSocket connection to Core -- Native Messaging Host (Swift helper) reads `ipc.json` for discovery +- Native Messaging Host (Swift helper) provides config query + WS relay dual-path fallback ### Native Messaging Host Specification -Chrome Extensions cannot directly read the file system (`~/.demosafe/ipc.json`), so a Native Messaging Host is needed as a bridge: +Chrome Extensions cannot directly read the file system (`~/.demosafe/ipc.json`), so a Native Messaging Host is needed as a bridge. NMH also serves as a fallback relay when WebSocket is disconnected. | Item | Description | |------|-------------| -| Implementation language | Swift (macOS helper binary) | -| Installation location | `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | -| Responsibility | Read `ipc.json` → return `{port, token}` to Background Service Worker | +| Implementation language | Swift (macOS helper binary, standalone swiftc compilation, ~93KB) | +| Installation location | binary: `~/.demosafe/bin/demosafe-nmh`, manifest: `~/Library/Application Support/Google/Chrome/NativeMessagingHosts/com.demosafe.nmh.json` | +| Responsibility | (1) Read `ipc.json` → return `{port, token}`; (2) WS relay: get_state / submit_captured_key / toggle_demo_mode | | Communication protocol | Chrome Native Messaging (stdin/stdout JSON) | -| Trigger timing | Called once on Extension startup to get connection info; re-called after Core restart | -| Security | Manifest restricts `allowed_origins` to only this Extension ID | +| WS Relay | Short-lived connection (connect → handshake → 1 request → 1 response → close), clientType: `"nmh"`, 5s timeout | +| Trigger timing | On Extension startup for connection info; as fallback relay when WS is disconnected | +| Installation | NMHInstaller auto-installs on Core startup; `install.sh` as manual fallback | +| Security | Manifest restricts `allowed_origins`; NMH stores no data, only relays | --- diff --git a/docs/en/05-ipc-protocol/protocol-spec.md b/docs/en/05-ipc-protocol/protocol-spec.md index 2ca9208..cbb1cb9 100644 --- a/docs/en/05-ipc-protocol/protocol-spec.md +++ b/docs/en/05-ipc-protocol/protocol-spec.md @@ -36,7 +36,7 @@ All messages follow a JSON envelope structure: | Action | Payload | Response | |--------|---------|----------| -| `handshake` | `clientType`, `token`, `version` | port, pid, patternCache version, connection status | +| `handshake` | `clientType` (`vscode` / `chrome` / `accessibility` / `nmh`), `token`, `version` | port, pid, patternCache version, connection status | | `get_state` | (none) | isDemoMode, activeContext, patternCache, version | | `request_paste` | `keyId` | status (`success` \| `denied` \| `offline`) | | `request_paste_group` | `groupId`, `fieldIndex` (optional) | status, groupId | @@ -159,6 +159,19 @@ Core maintains a `patternCacheVersion` that increments with each pattern change. --- +## NMH Relay (Native Messaging Host Short-lived Connection) + +clientType `"nmh"` is used by Chrome Native Messaging Host to relay requests via short-lived WS connections. + +| Characteristic | Description | +|----------------|-------------| +| Connection lifecycle | connect → handshake → 1 request → 1 response → close (~20-60ms) | +| Supported actions | `get_state`, `submit_captured_key`, `toggle_demo_mode` | +| Broadcast exclusion | Core automatically skips `.nmh` clients when broadcasting events | +| Use case | Fallback path when Chrome Extension WS is disconnected | + +--- + ## Security Constraints | Rule | Description | @@ -166,4 +179,5 @@ Core maintains a `patternCacheVersion` that increments with each pattern change. | WebSocket on 127.0.0.1 only | Binding to external interfaces is prohibited | | Handshake authentication | Token from `ipc.json` is required | | **Plaintext never traverses IPC** | Only masked representations and references flow over the network | +| **Plaintext not stored in chrome.storage** | submit_captured_key failures are not queued — security red line | | `ipc.json` permissions 600 | User read/write only | diff --git a/packages/chrome-extension/native-host/NativeMessagingHost.swift b/packages/chrome-extension/native-host/NativeMessagingHost.swift index db2bbd1..459b003 100644 --- a/packages/chrome-extension/native-host/NativeMessagingHost.swift +++ b/packages/chrome-extension/native-host/NativeMessagingHost.swift @@ -1,7 +1,9 @@ import Foundation /// Native Messaging Host for Chrome Extension. -/// Reads ~/.demosafe/ipc.json and returns {port, token} to the extension. +/// Supports: +/// - get_config: Read ~/.demosafe/ipc.json and return {port, token} +/// - get_state / submit_captured_key / toggle_demo_mode: Relay via short-lived WS to Core /// Protocol: Chrome Native Messaging (stdin/stdout with 4-byte length prefix). struct IPCConfig: Codable { @@ -11,6 +13,8 @@ struct IPCConfig: Codable { let token: String } +// MARK: - Chrome Native Messaging I/O + func readMessage() -> Data? { var lengthBytes = [UInt8](repeating: 0, count: 4) guard fread(&lengthBytes, 1, 4, stdin) == 4 else { return nil } @@ -28,22 +32,205 @@ func writeMessage(_ data: Data) { fflush(stdout) } -func main() { - guard readMessage() != nil else { return } +func writeJSON(_ dict: [String: Any]) { + do { + let data = try JSONSerialization.data(withJSONObject: dict) + writeMessage(data) + } catch { + // Last-resort fallback: write a hardcoded error directly + let fallback = Data(#"{"error":"encode_failed"}"#.utf8) + writeMessage(fallback) + fputs("NMH: writeJSON serialization failed: \(error)\n", stderr) + } +} + +func writeError(_ code: String, message: String = "") { + writeJSON(["error": code, "message": message]) +} + +// MARK: - IPC Config +func loadIPCConfig() -> IPCConfig? { let configPath = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".demosafe/ipc.json") + guard let data = try? Data(contentsOf: configPath), + let config = try? JSONDecoder().decode(IPCConfig.self, from: data) else { + return nil + } + return config +} + +// MARK: - WebSocket Relay + +let relayActions: Set = ["get_state", "submit_captured_key", "toggle_demo_mode"] + +/// Connect to Core WS, handshake, send one request, return one response, then close. +/// Uses DispatchQueue for URLSession callbacks; blocks main thread with semaphore. +func relayToCore(config: IPCConfig, action: String, payload: [String: Any]) -> [String: Any] { + let semaphore = DispatchSemaphore(value: 0) + var result: [String: Any] = ["error": "timeout"] + + // URLSession with delegate queue so callbacks don't need RunLoop + let delegateQueue = OperationQueue() + delegateQueue.name = "nmh.ws" + let session = URLSession(configuration: .default, delegate: nil, delegateQueue: delegateQueue) + + let url = URL(string: "ws://127.0.0.1:\(config.port)")! + let wsTask = session.webSocketTask(with: url) + wsTask.resume() + + let messageId = UUID().uuidString + + // Step 1: Send handshake + let handshake: [String: Any] = [ + "id": UUID().uuidString, + "type": "request", + "action": "handshake", + "payload": ["clientType": "nmh", "token": config.token, "version": "0.1.0"], + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + + guard let handshakeData = try? JSONSerialization.data(withJSONObject: handshake), + let handshakeStr = String(data: handshakeData, encoding: .utf8) else { + return ["error": "encode_failed"] + } + + wsTask.send(.string(handshakeStr)) { error in + if error != nil { + result = ["error": "core_unreachable"] + semaphore.signal() + return + } + + // Step 2: Receive handshake response + wsTask.receive { receiveResult in + guard case .success(let msg) = receiveResult, + case .string(let text) = msg, + let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hsPayload = json["payload"] as? [String: Any], + hsPayload["status"] as? String == "success" else { + result = ["error": "auth_failed"] + wsTask.cancel(with: .goingAway, reason: nil) + semaphore.signal() + return + } + + // Step 3: Send the actual request + let request: [String: Any] = [ + "id": messageId, + "type": "request", + "action": action, + "payload": payload, + "timestamp": ISO8601DateFormatter().string(from: Date()), + ] + + guard let reqData = try? JSONSerialization.data(withJSONObject: request), + let reqStr = String(data: reqData, encoding: .utf8) else { + result = ["error": "encode_failed"] + wsTask.cancel(with: .goingAway, reason: nil) + semaphore.signal() + return + } + + wsTask.send(.string(reqStr)) { error in + if error != nil { + result = ["error": "core_unreachable"] + wsTask.cancel(with: .goingAway, reason: nil) + semaphore.signal() + return + } + + // Step 4: Receive response (skip event messages) + receiveResponse(wsTask: wsTask, expectedId: messageId, attemptsLeft: 5) { response in + result = response + wsTask.cancel(with: .goingAway, reason: nil) + semaphore.signal() + } + } + } + } + + let timeout = DispatchTime.now() + .seconds(5) + if semaphore.wait(timeout: timeout) == .timedOut { + wsTask.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + return ["error": "timeout"] + } + + session.invalidateAndCancel() + return result +} + +func receiveResponse(wsTask: URLSessionWebSocketTask, expectedId: String, attemptsLeft: Int, completion: @escaping ([String: Any]) -> Void) { + guard attemptsLeft > 0 else { + completion(["error": "no_response"]) + return + } + + wsTask.receive { receiveResult in + guard case .success(let msg) = receiveResult, + case .string(let text) = msg, + let data = text.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + completion(["error": "core_unreachable"]) + return + } + + let msgType = json["type"] as? String + let msgId = json["id"] as? String + + // Skip event messages (e.g., pattern_cache_sync sent after handshake) + if msgType == "event" { + receiveResponse(wsTask: wsTask, expectedId: expectedId, attemptsLeft: attemptsLeft - 1, completion: completion) + return + } + + // Check if this is our response + if msgType == "response" && msgId == expectedId { + completion(json) + return + } + + // Not our message, try again + receiveResponse(wsTask: wsTask, expectedId: expectedId, attemptsLeft: attemptsLeft - 1, completion: completion) + } +} + +// MARK: - Main + +func nmhMain() { + guard let messageData = readMessage(), + let json = try? JSONSerialization.jsonObject(with: messageData) as? [String: Any], + let action = json["action"] as? String else { + writeError("invalid_request") + return + } + + // get_config: read ipc.json directly (no WS needed) + if action == "get_config" { + guard let config = loadIPCConfig() else { + writeError("ipc_not_found", message: "ipc.json not found or unreadable") + return + } + writeJSON(["port": config.port, "token": config.token]) + return + } + + // Relay actions: connect to Core WS, forward request, return response + if relayActions.contains(action) { + guard let config = loadIPCConfig() else { + writeError("ipc_not_found", message: "ipc.json not found or unreadable") + return + } - guard let configData = try? Data(contentsOf: configPath), - let config = try? JSONDecoder().decode(IPCConfig.self, from: configData) else { - let error = try! JSONSerialization.data(withJSONObject: ["error": "ipc.json not found"]) - writeMessage(error) + let payload = json["payload"] as? [String: Any] ?? [:] + let response = relayToCore(config: config, action: action, payload: payload) + writeJSON(response) return } - let response: [String: Any] = ["port": config.port, "token": config.token] - let responseData = try! JSONSerialization.data(withJSONObject: response) - writeMessage(responseData) + writeError("unknown_action", message: "Unknown action: \(action)") } -main() +nmhMain() diff --git a/packages/chrome-extension/src/background/service-worker.ts b/packages/chrome-extension/src/background/service-worker.ts index 0b94513..d4d9466 100644 --- a/packages/chrome-extension/src/background/service-worker.ts +++ b/packages/chrome-extension/src/background/service-worker.ts @@ -1,19 +1,23 @@ /** * Background Service Worker — maintains WebSocket connection to DemoSafe Core. * Uses Native Messaging Host to read ipc.json for connection info. + * Falls back to NMH relay when WebSocket is disconnected. * * Responsibilities: * - WebSocket lifecycle (connect, handshake, reconnect with exponential backoff) + * - NMH fallback for critical actions (get_state, submit_captured_key, toggle_demo_mode) * - Forward Core events to content scripts * - Persist pattern cache to chrome.storage.local * - Respond to popup/options requests * - Manage Active Key Capture mode with alarm-based timeout + * - Queue failed submissions for retry on reconnect */ const NATIVE_HOST_ID = 'com.demosafe.nmh'; const MAX_RECONNECT_DELAY = 30000; const CAPTURE_TIMEOUT_MS = 300_000; // 5 minutes const CAPTURE_ALARM_NAME = 'demosafe_capture_timeout'; +const WS_REQUEST_TIMEOUT = 5000; interface IPCConfig { port: number; @@ -28,6 +32,8 @@ interface IPCMessage { timestamp: string; } +type ConnectionPath = 'ws' | 'nmh' | 'offline'; + interface DemoSafeState { isConnected: boolean; isDemoMode: boolean; @@ -36,12 +42,19 @@ interface DemoSafeState { isCaptureMode: boolean; captureTimeoutEnd: number | null; // timestamp ms capturedCount: number; + connectionPath: ConnectionPath; } +// NMH relay actions — these can be forwarded via Native Messaging Host when WS is down +const NMH_RELAY_ACTIONS = new Set(['get_state', 'submit_captured_key', 'toggle_demo_mode']); + let ws: WebSocket | null = null; let reconnectDelay = 1000; let reconnectTimer: ReturnType | null = null; +// Pending WS request tracking (request-response correlation) +const pendingRequests = new Map void; reject: (error: Error) => void; timer: ReturnType }>(); + const state: DemoSafeState = { isConnected: false, isDemoMode: false, @@ -50,6 +63,7 @@ const state: DemoSafeState = { isCaptureMode: false, captureTimeoutEnd: null, capturedCount: 0, + connectionPath: 'offline', }; // MARK: - Native Messaging Host @@ -70,7 +84,10 @@ async function getNativeConfig(): Promise { return new Promise((resolve) => { try { chrome.runtime.sendNativeMessage(NATIVE_HOST_ID, { action: 'get_config' }, (response) => { - if (chrome.runtime.lastError || !response) { + if (chrome.runtime.lastError) { + console.warn('[DemoSafe BG] getNativeConfig error:', chrome.runtime.lastError.message); + resolve(null); + } else if (!response) { resolve(null); } else { resolve(response as IPCConfig); @@ -82,6 +99,109 @@ async function getNativeConfig(): Promise { }); } +// MARK: - NMH Relay (fallback path) + +async function sendViaNMH(action: string, payload: Record): Promise | null> { + return new Promise((resolve) => { + try { + chrome.runtime.sendNativeMessage( + NATIVE_HOST_ID, + { action, payload }, + (response) => { + if (chrome.runtime.lastError) { + console.warn(`[DemoSafe NMH] ${action} native error:`, chrome.runtime.lastError.message); + resolve(null); + } else if (!response) { + console.warn(`[DemoSafe NMH] ${action}: no response`); + resolve(null); + } else if (response.error) { + console.warn(`[DemoSafe NMH] ${action} error:`, response.error, response.message); + resolve(null); + } else { + resolve(response as Record); + } + } + ); + } catch (e) { + console.warn(`[DemoSafe NMH] ${action} exception:`, e); + resolve(null); + } + }); +} + +// MARK: - Unified Request Dispatch + +/** + * Send a request to Core, trying WS first, then NMH fallback for relay-eligible actions. + * For submit_captured_key, queues to storage if both paths fail. + */ +async function sendRequest(action: string, payload: Record): Promise | null> { + // Try WebSocket first + if (ws && ws.readyState === WebSocket.OPEN) { + try { + const response = await sendRequestViaWS(action, payload); + if (response) return response.payload as Record; + } catch (err) { + console.warn(`[DemoSafe BG] WS request '${action}' failed, trying NMH fallback:`, err); + } + } + + // Try NMH fallback for relay-eligible actions + if (NMH_RELAY_ACTIONS.has(action)) { + const nmhResponse = await sendViaNMH(action, payload); + if (nmhResponse && !nmhResponse.error) { + // Extract payload from the full IPC response envelope + const responsePayload = nmhResponse.payload as Record | undefined; + if (responsePayload) { + state.connectionPath = 'nmh'; + return responsePayload; + } + // If response has no payload wrapper, it's already the payload + state.connectionPath = 'nmh'; + return nmhResponse; + } + } + + // Both failed — do NOT queue plaintext keys to chrome.storage (security red line) + if (action === 'submit_captured_key') { + console.warn('[DemoSafe BG] submit_captured_key failed via both WS and NMH — key not stored'); + } + + state.connectionPath = 'offline'; + return null; +} + +// MARK: - WS Request-Response Tracking + +function sendRequestViaWS(action: string, payload: Record): Promise { + return new Promise((resolve, reject) => { + const id = crypto.randomUUID(); + const timer = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error('WS request timeout')); + }, WS_REQUEST_TIMEOUT); + + pendingRequests.set(id, { resolve, reject, timer }); + + sendToCore({ + id, + type: 'request', + action, + payload, + timestamp: new Date().toISOString(), + }); + }); +} + +function resolvePendingRequest(message: IPCMessage) { + const pending = pendingRequests.get(message.id); + if (pending) { + clearTimeout(pending.timer); + pendingRequests.delete(message.id); + pending.resolve(message); + } +} + // MARK: - WebSocket Connection async function connect() { @@ -94,6 +214,7 @@ async function connect() { const config = await getIPCConfig(); if (!config) { state.isConnected = false; + state.connectionPath = 'offline'; scheduleReconnect(); return; } @@ -102,6 +223,7 @@ async function connect() { ws = new WebSocket(`ws://127.0.0.1:${config.port}`); } catch { state.isConnected = false; + state.connectionPath = 'offline'; scheduleReconnect(); return; } @@ -115,13 +237,20 @@ async function connect() { try { const message: IPCMessage = JSON.parse(event.data as string); handleMessage(message); - } catch { - // Ignore malformed messages + } catch (err) { + console.warn('[DemoSafe BG] Failed to parse WS message:', err); } }; ws.onclose = () => { state.isConnected = false; + state.connectionPath = 'offline'; + // Reject all pending WS requests so sendRequest can fall through to NMH + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error('WebSocket closed')); + } + pendingRequests.clear(); broadcastStateToPopup(); scheduleReconnect(); }; @@ -159,9 +288,15 @@ function sendRequestToCore(action: string, payload: Record) { // MARK: - Message Handling (from Core) function handleMessage(message: IPCMessage) { + // Resolve any pending request-response + if (message.type === 'response') { + resolvePendingRequest(message); + } + if (message.type === 'response' && message.action === 'handshake') { if (message.payload.status === 'success') { state.isConnected = true; + state.connectionPath = 'ws'; state.isDemoMode = message.payload.isDemoMode as boolean; broadcastStateToPopup(); } @@ -312,12 +447,30 @@ function broadcastStateToPopup() { // Listen for messages from popup and content scripts chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'get_state') { - sendResponse(state); + // If WS is disconnected, try NMH to get fresh state + if (!state.isConnected) { + sendViaNMH('get_state', {}).then((nmhResponse) => { + if (nmhResponse && !nmhResponse.error) { + const payload = (nmhResponse.payload ?? nmhResponse) as Record; + state.isDemoMode = (payload.isDemoMode as boolean) ?? state.isDemoMode; + // NMH is one-shot relay, not a persistent connection — don't claim isConnected + state.connectionPath = 'nmh'; + } + sendResponse(state); + }); + } else { + sendResponse(state); + } return true; } if (message.type === 'toggle_demo_mode') { - sendRequestToCore('toggle_demo_mode', {}); + sendRequest('toggle_demo_mode', {}).then((result) => { + if (result) { + state.isDemoMode = (result.isDemoMode as boolean) ?? state.isDemoMode; + broadcastStateToPopup(); + } + }); sendResponse({ ok: true }); return true; } @@ -326,21 +479,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { const newState = !state.isCaptureMode; if (newState) state.capturedCount = 0; updateCaptureState(newState); - if (state.isConnected) { - sendRequestToCore('toggle_capture_mode', { isActive: newState }); - } + sendRequest('toggle_capture_mode', { isActive: newState }); sendResponse({ ok: true }); return true; } if (message.type === 'submit_captured_key') { console.log('[DemoSafe BG] submit_captured_key received:', message.payload?.suggestedService, 'connected:', state.isConnected, 'rawValue length:', message.payload?.rawValue?.length); - if (state.isConnected) { - sendRequestToCore('submit_captured_key', message.payload); - console.log('[DemoSafe BG] forwarded to Core'); - } else { - console.log('[DemoSafe BG] NOT connected, cannot forward'); - } + sendRequest('submit_captured_key', message.payload).then((result) => { + if (result) { + console.log('[DemoSafe BG] submit_captured_key success via', state.connectionPath); + } else { + console.log('[DemoSafe BG] submit_captured_key queued for retry'); + } + }); state.capturedCount++; broadcastStateToPopup(); sendResponse({ ok: true }); @@ -368,6 +520,20 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { return false; }); +// DEBUG: uncomment to expose debug functions on SW DevTools console +// Object.assign(self, { +// debugState: () => { console.log(JSON.stringify(state, null, 2)); return state; }, +// debugDisconnectWS: () => { +// if (ws) { ws.onclose = null; ws.close(); ws = null; } +// if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } +// state.isConnected = false; +// state.connectionPath = 'offline'; +// broadcastStateToPopup(); +// console.log('[DEBUG] WS disconnected, reconnect disabled'); +// }, +// debugReconnectWS: () => { reconnectDelay = 1000; connect(); }, +// }); + // MARK: - Reconnection function scheduleReconnect() { diff --git a/packages/chrome-extension/src/popup/popup.html b/packages/chrome-extension/src/popup/popup.html index 83e3f04..317234f 100644 --- a/packages/chrome-extension/src/popup/popup.html +++ b/packages/chrome-extension/src/popup/popup.html @@ -49,6 +49,7 @@ vertical-align: middle; } .dot.connected { background: #22c55e; } + .dot.nmh { background: #3b82f6; } .dot.offline { background: #ef4444; } .dot.capture { background: #f59e0b; } .actions { diff --git a/packages/chrome-extension/src/popup/popup.ts b/packages/chrome-extension/src/popup/popup.ts index d821f76..9c11bdc 100644 --- a/packages/chrome-extension/src/popup/popup.ts +++ b/packages/chrome-extension/src/popup/popup.ts @@ -10,8 +10,15 @@ interface DemoSafeState { isCaptureMode: boolean; captureTimeoutEnd: number | null; capturedCount: number; + connectionPath: 'ws' | 'nmh' | 'offline'; } +const CONNECTION_LABELS: Record = { + ws: 'Connected (WebSocket)', + nmh: 'Connected (NMH)', + offline: 'Offline', +}; + let countdownInterval: ReturnType | null = null; function updateUI(state: DemoSafeState) { @@ -28,13 +35,18 @@ function updateUI(state: DemoSafeState) { const captureDot = document.getElementById('captureDot')!; const captureBtn = document.getElementById('toggleCapture')!; - // Connection - if (state.isConnected) { + // Connection — show path info (NMH is one-shot relay, shown as reachable but not persistent) + const path = state.connectionPath ?? (state.isConnected ? 'ws' : 'offline'); + const isReachable = state.isConnected || path === 'nmh'; + if (path === 'nmh') { + connectionDot.className = 'dot nmh'; + connectionText.textContent = CONNECTION_LABELS.nmh; + } else if (state.isConnected) { connectionDot.className = 'dot connected'; - connectionText.textContent = 'Connected'; + connectionText.textContent = CONNECTION_LABELS.ws; } else { connectionDot.className = 'dot offline'; - connectionText.textContent = 'Offline'; + connectionText.textContent = CONNECTION_LABELS.offline; } // Mode @@ -56,8 +68,8 @@ function updateUI(state: DemoSafeState) { // Patterns patternCount.textContent = String(state.patternCount); - // Capture mode — only show when connected - if (state.isConnected) { + // Capture mode — show when reachable (WS or NMH) + if (isReachable) { captureRow.style.display = ''; captureBtn.style.display = ''; diff --git a/packages/swift-core/DemoSafe/App/AppState.swift b/packages/swift-core/DemoSafe/App/AppState.swift index 887124d..2dd2c55 100644 --- a/packages/swift-core/DemoSafe/App/AppState.swift +++ b/packages/swift-core/DemoSafe/App/AppState.swift @@ -64,6 +64,9 @@ final class AppState: ObservableObject { // Wire hotkey callbacks wireHotkeyCallbacks() + // Install NMH (Chrome Native Messaging Host) if bundled + NMHInstaller.installIfNeeded(extensionId: "cockodmaleagghfbaookajpcpnbdjocj") + // Start IPC Server do { let port = try ipcServer.start() diff --git a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift index 61a7838..251d696 100644 --- a/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift +++ b/packages/swift-core/DemoSafe/Services/IPC/IPCServer.swift @@ -27,6 +27,7 @@ final class IPCServer { case vscode case chrome case accessibility + case nmh } struct ClientInfo: Identifiable { @@ -122,7 +123,8 @@ final class IPCServer { if let type = clientType { targets = connections.values.filter { $0.clientType == type && $0.isAuthenticated } } else { - targets = connections.values.filter { $0.isAuthenticated } + // Skip .nmh clients — they are short-lived relay connections, not event subscribers + targets = connections.values.filter { $0.isAuthenticated && $0.clientType != .nmh } } guard let data = encodeEvent(event) else { return } diff --git a/packages/swift-core/DemoSafe/Services/NMHInstaller.swift b/packages/swift-core/DemoSafe/Services/NMHInstaller.swift new file mode 100644 index 0000000..346580e --- /dev/null +++ b/packages/swift-core/DemoSafe/Services/NMHInstaller.swift @@ -0,0 +1,96 @@ +import Foundation +import os + +private let logger = Logger(subsystem: "com.demosafe", category: "NMHInstaller") + +/// Installs the Native Messaging Host binary and Chrome manifest on app launch. +/// Copies from app bundle Resources → ~/.demosafe/bin/ and writes the Chrome NMH manifest. +enum NMHInstaller { + + private static let binaryName = "demosafe-nmh" + private static let hostName = "com.demosafe.nmh" + private static let installDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".demosafe/bin") + private static let chromeNMHDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/Google/Chrome/NativeMessagingHosts") + + /// Check and install NMH if needed. Call from AppState.init(). + static func installIfNeeded(extensionId: String) { + let binaryDest = installDir.appendingPathComponent(binaryName) + let manifestDest = chromeNMHDir.appendingPathComponent("\(hostName).json") + + let fm = FileManager.default + + // Check if binary exists in app bundle Resources + guard let bundledBinary = Bundle.main.url(forResource: binaryName, withExtension: nil) else { + logger.warning("NMH binary not found in app bundle Resources, skipping auto-install") + return + } + + // Check if binary needs updating (missing or different size) + let needsBinaryUpdate: Bool + if fm.fileExists(atPath: binaryDest.path) { + let bundledSize = (try? fm.attributesOfItem(atPath: bundledBinary.path)[.size] as? Int) ?? 0 + let installedSize = (try? fm.attributesOfItem(atPath: binaryDest.path)[.size] as? Int) ?? -1 + needsBinaryUpdate = bundledSize != installedSize + } else { + needsBinaryUpdate = true + } + + // Install binary if needed + if needsBinaryUpdate { + do { + try fm.createDirectory(at: installDir, withIntermediateDirectories: true) + if fm.fileExists(atPath: binaryDest.path) { + try fm.removeItem(at: binaryDest) + } + try fm.copyItem(at: bundledBinary, to: binaryDest) + // chmod 755 + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryDest.path) + logger.info("NMH binary installed: \(binaryDest.path)") + } catch { + logger.error("Failed to install NMH binary: \(error)") + return + } + } + + // Check if manifest needs updating + let needsManifestUpdate: Bool + if fm.fileExists(atPath: manifestDest.path), + let existingData = try? Data(contentsOf: manifestDest), + let existing = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any], + let origins = existing["allowed_origins"] as? [String], + origins.contains("chrome-extension://\(extensionId)/"), + existing["path"] as? String == binaryDest.path { + needsManifestUpdate = false + } else { + needsManifestUpdate = true + } + + // Write manifest if needed + if needsManifestUpdate { + do { + try fm.createDirectory(at: chromeNMHDir, withIntermediateDirectories: true) + + let manifest: [String: Any] = [ + "name": hostName, + "description": "DemoSafe Native Messaging Host — relay for Chrome Extension", + "path": binaryDest.path, + "type": "stdio", + "allowed_origins": ["chrome-extension://\(extensionId)/"], + ] + + let data = try JSONSerialization.data(withJSONObject: manifest, options: .prettyPrinted) + try data.write(to: manifestDest) + logger.info("NMH manifest installed: \(manifestDest.path)") + } catch { + logger.error("Failed to install NMH manifest: \(error)") + return + } + } + + if !needsBinaryUpdate && !needsManifestUpdate { + logger.info("NMH already installed and up to date") + } + } +} diff --git a/shared/ipc-protocol/src/index.ts b/shared/ipc-protocol/src/index.ts index 3f00a0a..cd40ae2 100644 --- a/shared/ipc-protocol/src/index.ts +++ b/shared/ipc-protocol/src/index.ts @@ -27,7 +27,7 @@ export type RequestAction = | 'resolve_mask'; export interface HandshakePayload { - clientType: 'vscode' | 'chrome' | 'accessibility'; + clientType: 'vscode' | 'chrome' | 'accessibility' | 'nmh'; token: string; version: string; }