diff --git a/skills/devops/hermes-desktop-build/SKILL.md b/skills/devops/hermes-desktop-build/SKILL.md new file mode 100644 index 0000000..79e73da --- /dev/null +++ b/skills/devops/hermes-desktop-build/SKILL.md @@ -0,0 +1,1371 @@ +--- +name: hermes-desktop-build +description: Build and deploy Hermes Desktop Electron app (v2.1.1+ / v3.0.0+) — version bump, build, upload to Tencent COS. Covers both system-Chrome and bundled-Chromium variants. +version: 2.0 +--- + +# Hermes Desktop Build & Deploy + +Project: `/home/ubuntu/AtomK-Desktop` (Electron + electron-vite + electron-builder, Gitea remote `origin`) + +## Architecture Overview + +- **Main process**: `src/main/index.ts` — Electron main, IPC handlers +- **Chrome Bridge**: `src/main/chrome-bridge.ts` — Express relay server (port 3928) + WebSocket, CDP browser auto-launch +- **Preload**: `src/preload/index.ts` + `src/preload/index.d.ts` — bridge API surface +- **Renderer**: `src/renderer/src/` — React frontend +- **Extension**: `resources/extension/` — Chrome extension for page snapshots +- **Relay deps**: `resources/relay/` — Node.js deps for relay server (copied post-build) + +### Chrome Bridge Key Concepts + +- Relay server runs on port 3928 inside Electron main process +- Chrome extension connects via WebSocket to relay +- CDP (Chrome DevTools Protocol) enables browser automation (click/type/navigate) +- CDP browser auto-launch (v2.1.1+): detects system Chrome/Edge, spawns with `--remote-debugging-port=9222 --user-data-dir=C:\\HermesCDP` +- Bundled Chromium (v3.0.0+): built-in Chrome for Testing 131, auto-loads extension via `--load-extension=` flag. No manual extension install needed. +- State: `{ relayRunning, relayPort, extensionConnected, connectedTabs, cdpEnabled, cdpBrowserRunning, cdpBrowserPath, cdpBundled, lastCdpError }` +- IPC channels: `chrome-bridge:start`, `chrome-bridge:stop`, `chrome-bridge:get-state`, `chrome-bridge:install-extension`, `chrome-bridge:start-cdp-browser`, `chrome-bridge:stop-cdp-browser`, `chrome-bridge:check-cdp`, `chrome-bridge:check-cdp-available`, `chrome-bridge:refresh-cdp-state` + +### Bridge WS Protocol Compatibility (v3.8.7+) + +Three bridge server versions exist with different WS handshake requirements: + +| Server | WS Auth | Register Format | Confirmation | +|--------|---------|-----------------|-------------| +| **relay** (v1, Node) | `extension_connect`/`desktop_connect` (no auth) | Identifies client type | None | +| **atomk-bridge** (v3, Python) | None — send `register` immediately | `{type:"register", info:{...}}` | `{type:"registered", client_id:"..."}` | +| **cloud-bridge** (v4, Python) | **Must send `auth` within 5s** → then `register` | `{type:"auth", key:"...", desktop_id:"..."}` | `{type:"registered", slot_id:"..."}` | + +**Safe cross-version pattern:** Always send `auth` message first (if apiKey is available), then `register`. v3 and relay safely ignore unknown message types. Include `user` field for multi-user routing (v3.9.16+): + +```typescript +// In WS on('open') handler: +if (config.apiKey) { + ws.send(JSON.stringify({ type: 'auth', key: config.apiKey, desktop_id: clientId, user: username })) +} +ws.send(JSON.stringify({ type: 'register', info: { name, platform, version, ... } })) +``` + +**Command handling (v3.8.7+):** Desktop must handle `{type:"command"}` messages from Bridge Server (forwarded from HTTP heartbeat responses): +- `update_key` — hot-replace bridge auth key +- `drain` — graceful disconnect after 5s (set intentionalClose=true) +- `kick` — immediate disconnect +- `set_status` — override status report on next heartbeat + +**WS close codes (as of v3.9.16):** +- `4001` — duplicate connection / auth timeout (TRANSIENT — retry with exponential backoff: 1s→2s→4s→8s). Fix in PR #4 (June 2026): was previously treated as fatal, causing permanent disconnect after Bridge restart. +- `4003` — auth failed (invalid key) → FATAL, don't retry, show error in UI + +### Three-Layer Schema Alignment + +Desktop `BridgeInfo` must track Server `BridgeResponse`. When Server adds/removes fields, Desktop must sync across 3 files: +1. `src/main/operation-api.ts` — `BridgeInfo` interface +2. `src/preload/index.d.ts` — `operationFetchBridges` return type +3. `src/renderer/src/screens/ChromeBridge/ChromeBridge.tsx` — state type + JSX rendering + +Common drift: Server stops exposing fields (e.g. `bridge_id`, `active_connections`) but Desktop still references them → TypeScript errors at build time. Always verify against Server `schemas/bridge.py`. + +### ConnectionConfig Fields (v3.9.0+) + +`ConnectionConfig` in `config.ts` includes: +- `mode`: `"remote" | "ssh"` (no `"local"` since v3.7.0) +- `remoteUrl`: string — Bridge server HTTP URL +- `ssh`: SSH config object (host, port, username, password/key) +- `cloudBridgeUrl`: string (v3.9.0+) — WebSocket URL for Cloud Bridge v4, populated from atomlisting instance config after login + +The `cloudBridgeUrl` field is set by Welcome screen login flow: `operationLogin()` → `operationApplyAgent()` (fetches bridges list from server, configures Cloud Bridge WebSocket from `bridges[0].host:port`). **Do NOT use `instance.base_url` as the Bridge URL** — it's the Agent server, not the Bridge. + +See `references/bridges-protocol.md` for full three-layer protocol analysis, WS message formats, close codes, and schema alignment table. + +## Build Steps + +1. **Pre-build check — splash assets for stale versions**: OCR the splash logo image to verify it doesn't have a hardcoded version number burned in (see Splash Screen Version pitfall below). If it does, regenerate the image without a version number BEFORE bumping/building. +2. **Version bump** in `package.json` (`"version"` field) +3. **Build (compile TS → out/)** + ```bash + cd /home/ubuntu/AtomK-Desktop + NODE_ENV=production NODE_OPTIONS="--max-old-space-size=4096" node -e "require('electron-vite').build()" + ``` + If `npx electron-vite build` works (no misdetection), that's fine too — but the JS API is more reliable. +3. **Package (out/ → dist/ installer)**: + ```bash + npx electron-builder --win --x64 + ``` +4. **If `npm run build:win` times out** (the combined command can exceed 300s terminal limit): run steps 2 and 3 separately. The vite build (~10s) produces `out/`, then `npx electron-builder --win --x64` (~2-3 min) packages it. No need to re-run vite if only the packaging step timed out. +4. **Verify output**: + ```bash + ls -lh dist/atomk-desktop--setup.exe + file dist/atomk-desktop--setup.exe + # Without Chromium: ~103MB (INCOMPLETE — resources/chromium/ missing) + # With Chromium: ~234MB (complete local_embedded build) + # Should be "PE32 executable ... Nullsoft Installer" + ``` +4. **Upload to COS**: + ```bash + source /home/ubuntu/.hermes/custom_services.env + coscmd config -a "$COS_SECRET_ID" -s "$COS_SECRET_KEY" -b "9websclub-1251422183" -r ap-hongkong + coscmd upload dist/atomk-desktop--setup.exe atomk-desktop/releases/atomk-desktop--setup.exe + ``` + ⚠️ COS region must be `ap-hongkong` (NOT `ap-hk` — DNS won't resolve). Use `coscmd` (NOT `coscli` — not installed). AppID is `1251422183` (NOT 1254297284). +5. **Download URL**: `https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop/releases/atomk-desktop--setup.exe` + +## Alibaba Cloud OSS Upload (Alternative) + +For the `xuxueli` bucket in `oss-cn-hangzhou`. **Do NOT use `ossutil` CLI** — the official download URL returns XML errors. Use Python `oss2` SDK instead: + +```bash +# Credentials are in /home/ubuntu/.hermes/custom_services.env +source /home/ubuntu/.hermes/custom_services.env +# Vars: OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET, OSS_BUCKET_NAME=xuxueli, OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com + +# Upload via Python oss2 +python3 -c " +import os, oss2 +auth = oss2.Auth(os.environ['OSS_ACCESS_KEY_ID'], os.environ['OSS_ACCESS_KEY_SECRET']) +bucket = oss2.Bucket(auth, os.environ['OSS_ENDPOINT'], os.environ['OSS_BUCKET_NAME']) +bucket.put_object_from_file('atomk-desktop/releases/atomk-desktop--setup.exe', + '/home/ubuntu/AtomK-Desktop/dist/atomk-desktop--setup.exe') +print('Uploaded') +" +``` + +Download URL: `https://xuxueli.oss-cn-hangzhou.aliyuncs.com/atomk-desktop/releases/atomk-desktop--setup.exe` + +## Git & Gitea Workflow + +- **Local branch**: `main` +- **Gitea remote**: `origin` → `AtomK-Desktop.git` +- **Branch policy**: keep only `main` on the Gitea repo. Do not create/push long-lived version branches like `v3.8.0` unless explicitly requested. +- **Push to Gitea main**: `git push origin main` +- **Force overwrite** (if "以你的为主"): `git push origin main --force` +- **Commit + push**: `git add -A && git commit -m "vX.Y.Z: desc" && git push origin ` (may be `release/vX.Y.Z`, not main) +- Code review / test suite / IPC three-way-sync rules: see `references/code-review-test-suite.md` + +## Icon & Branding (v3.4.0+) + +- **App icon files**: `build/icon.ico` (Windows), `build/icon.png`, `build/icon.icns` (macOS) +- **Also needed**: `resources/icon.png` (used as `extraResources` in electron-builder.yml) +- **Renderer sidebar logo**: `src/renderer/src/assets/hermes.png` (256x256 PNG) +- **Renderer icon**: `src/renderer/src/assets/icon.png` (512x512 PNG) +- **Sidebar brand text**: `src/renderer/src/screens/Layout/Layout.tsx` → `AtomK-Agent v${appVersion}` +- **ICO must be ≥256x256**: electron-builder rejects smaller ICO files. Use Pillow with `sizes=[(16,16),(24,24),(32,32),(48,48),(64,64),(128,128),(256,256)]` to create proper multi-size ICO. + +### Replace app icon from a source image + +If the source image is non-square (e.g., 941×1024 JPG), crop to square first: + +```python +from PIL import Image +import struct, io + +# 1. Crop to square + resize to 1024x1024 +img = Image.open('source.jpg') +w, h = img.size +size = min(w, h) +left, top = (w - size) // 2, (h - size) // 2 +img = img.crop((left, top, left + size, top + size)).resize((1024, 1024), Image.LANCZOS) + +# 2. Save PNG (build/icon.png + resources/icon.png) +img.save('build/icon.png', 'PNG') + +# 3. Generate ICO (Windows) — PNG-based entries, modern approach +sizes = [256, 128, 64, 48, 32, 16] +ico = bytearray() +ico += struct.pack('= 256 else s, 0 if s >= 256 else s, len(png_bytes), offset)) + data_parts.append(png_bytes); offset += len(png_bytes) +for w_b, h_b, ds, off in entries: + ico += struct.pack('I', len(png_data) + 8) + png_data +total = len(icns_data) + 4 +icns_data[4:8] = struct.pack('>I', total) +with open('build/icon.icns', 'wb') as f: f.write(icns_data) + +# 5. Copy to resources/ too +import shutil; shutil.copy('build/icon.png', 'resources/icon.png') +``` + +⚠️ `png2icns` and `icnsutil` are NOT available on this server — use the manual ICNS writer above instead. + +### Color-tint an existing icon (PIL hue shift) + +When you need to change the color of an existing app icon (e.g. make it green) without a new source image: + +```python +from PIL import Image +import colorsys + +img = Image.open('build/icon.png').convert('RGBA') +pixels = img.load() +w, h = img.size + +for y in range(h): + for x in range(w): + r, g, b, a = pixels[x, y] + if a < 10: + continue + h_val, s_val, v_val = colorsys.rgb_to_hsv(r/255.0, g/255.0, b/255.0) + if s_val > 0.1: # Only shift colored pixels, skip grays/whites + target_hue = 0.33 # Green. Blue=0.66, Red=0.0, Orange=0.08 + h_val = h_val * 0.3 + target_hue * 0.7 # Blend 70% toward target + s_val = min(1.0, s_val * 1.15) # Boost saturation slightly + r2, g2, b2 = colorsys.hsv_to_rgb(h_val, s_val, v_val) + pixels[x, y] = (int(r2*255), int(g2*255), int(b2*255), a) + +img.save('build/icon.png') +``` + +After tinting, regenerate ICO/ICNS and copy to `resources/icon.png` as described above. + +## Linux → Windows Cross-Compile + +Wine 9.0 is installed on the server, and electron-builder uses it to run NSIS for creating Windows installers: + +```bash +# Wine check +wine --version # wine-9.0 + +# Build Windows installer on Linux (Wine + NSIS automatic) +cd /home/ubuntu/AtomK-Desktop +rm -rf dist/ +npm run build:win # Uses electron-builder --win, auto-detects Wine for NSIS +``` + +No additional setup needed — electron-builder handles NSIS via Wine transparently. + +## Pitfalls + +- **⚠️ CRITICAL: Duplicate IPC handlers cause fatal crash (v3.9.0 incident)**: When adding stub IPC handlers (e.g. for ChromeBridge scripts, cookies, cloud-connect), MUST check whether a real handler for the same channel name already exists elsewhere in `setupIPC()`. Electron throws `Error: Attempted to register a second handler for 'channel-name'` and the app exits immediately — window never appears, no error shown to user, just "click no response". The 5 duplicate channels in v3.9.0 were: `chrome-bridge:set-bridge-list`, `chrome-bridge:cloud-connect`, `chrome-bridge:cloud-disconnect`, `chrome-bridge:cloud-get-state`, `chrome-bridge:cloud-get-config`. **Always grep for the channel name before adding a handler:** `grep -n 'ipcMain.handle("channel-name"' src/main/index.ts`. The real implementations were at lines 1449, 1583, 1591, 1596, 1598; stubs at 1514-1518 — over 80 lines apart, easy to miss. +- **⚠️ CRITICAL: Dynamic `require()` causes MODULE_NOT_FOUND in production (v3.9.8 incident)**: +- **⚠️ CRITICAL: Dynamic `require()` causes MODULE_NOT_FOUND in production (v3.9.18 fix)**: When `require('./atomlisting')` is used inside a function body (e.g. `bridge-manager.ts` connect(), `chrome-bridge.ts` connectCloudBridge()), electron-vite's Rollup does NOT bundle it. In development it works because Node.js resolves at runtime. After `npm run build`, the module is missing from `out/main/` → `config.username` stays undefined → WS auth has no `user` field → Bridge routes to `default`. **This is the root cause of "Desktop shows user=default despite being logged into atomlisting"**. **Fix**: use static ES `import { atomkAPI } from './atomlisting'` at the top of the file. Both `bridge-manager.ts` and `chrome-bridge.ts` had this bug — both fixed in v3.9.18. **Detection**: `grep "require('./atomlisting')" src/main/*.ts` — any hits are bugs. See `references/dynamic-require-atomlisting-bug.md` for full diagnosis. +- **⚠️ CRITICAL: Extension relay-config.json API key desync (v3.9.7 fix)**: When `getConnectionConfig().apiKey` changes (e.g. after login returns a new bridge key), the Chrome Extension's `relay-config.json` is NOT automatically updated. Extension uses a stale key → `[Relay WS] Auth failed: invalid key` in tight reconnect loop. **Fix**: `syncExtensionApiKey()` in `chrome-bridge.ts` writes current apiKey to `relay-config.json` on every CloudBridge registration + `applyAgent` call. Always call after any `setConnectionConfig()` that changes `apiKey`. +- **⚠️ CRITICAL: IPC handler "reply was never sent" (v3.9.7 fix)**: When code inside `ipcMain.handle` throws synchronously (e.g. `getApiUrl()` throws "No remote URL configured"), Electron reports unhelpful `reply was never sent` error. **Fix**: wrap handler body in try-catch, send `chat-error` IPC event on failure, return safe default: + ```ts + ipcMain.handle("send-message", async (event, ...args) => { + try { /* ... */ return result; } + catch (err) { + event.sender.send("chat-error", err.message || String(err)); + return { response: "", sessionId: undefined }; + } + }); + ``` +- **⚠️ CRITICAL: Authenticated users must not be blocked at Welcome when Bridge is unreachable (v3.9.2 fix)**: The startup check in `App.tsx` must NEVER send an authenticated user (one with a valid `operationGetAuth` token) back to the Welcome/login screen just because a Bridge URL is unreachable. The old logic tested `testRemoteConnection(bridgeUrl, "")` and on failure set `next = "welcome"` — trapping the user in a loop where they can see the error but can't get past it. **Correct pattern**: If `auth?.token` is truthy, always set `next = "main"`. Set `error` to a warning message like `"Cannot reach AtomK Bridge at ${bridgeUrl}. You can reconnect from Settings."` and pass it as `bridgeError` prop to `Layout`. Layout renders a dismissible yellow warning banner. Only non-authenticated users (no token, no legacy connection config) should stay on Welcome. +- **⚠️ CRITICAL: `instance.base_url` is NOT the Bridge URL (v3.9.3 fix)**: The login API response contains `instance.base_url` (e.g. `https://us1.atomk.cn`) which is the **Agent server**, NOT the Bridge. The Bridge addresses come from `bridges[0].host:port` (e.g. `49.51.249.171:9228`). Using `instance.base_url` as `remoteUrl` causes health check failures (returns 404 on `/cloud-bridge/health`), blocking the user. **Correct pattern**: After login, call `operationApplyAgent(username, accessToken)` which fetches the bridges list from the server and constructs the Cloud Bridge WebSocket URL correctly. Never manually set `setConnectionConfig("remote", instance.base_url, "")`. +- **⚠️ CRITICAL: Bridge auth key (apiKey) must propagate through entire chain (v3.9.5 fix)**: Cloud Bridge v4 requires `auth` message with `key` within 5s of WS open. `connectCloudBridge()` only sends auth `if (config.apiKey)` — if `apiKey` is `""` or missing, auth is silently skipped → server rejects with 4001/4003. The key originates from `bridges[0].key` in the login API response and must flow through: `operationApplyAgent(username, token)` → save `b.key` to `connectionConfig.apiKey` → call `connectCloudBridge({ serverUrl, apiKey: b.key })` → WS handshake. **Every connection code path must be audited for apiKey**: (1) `operationApplyAgent` (login auto-connect), (2) `handleConnectRecommendedBridge` in Settings (one-click connect), (3) `handleCloudConnect` in BridgeConnection.tsx and ChromeBridge.tsx (manual connect). Manual connect must read saved apiKey from `chromeBridgeCloudGetConfig()` which returns `{ serverUrl, apiKey }`. **`UserBridgeInfo` type must include `key: string`** so UI components can access it. Real incident: v3.9.4 passed `apiKey: ""` → server rejected with 4001 → user saw "Server rejected connection" with no way to proceed. +- **⚠️ CRITICAL: Splash screen `splashtext-w.webp` hardcodes old version (v3.9.22 fix)**: The `splashtext-w.webp` logo image was created/modified in v3.4.1 and has the version number burned INTO the image pixels. Even though the SplashScreen component renders a dynamic `v{APP_VERSION}` below the logo, the image itself shows a different version. After bumping `package.json`, use OCR to verify: `tesseract` the image — if it still says an old version, regenerate it WITHOUT any version text (since the dynamic text handles versions). v3.4.1 was visible in the splash animation until 3.9.22 despite the dynamic version correctly showing the current version. **Verification**: after build, `grep -c '3\.4\.1' out/renderer/assets/index-*.js` should be 0. +- **Debugging "app won't start" on Windows**: Run the built output locally to surface the actual error: `ELECTRON_DISABLE_SANDBOX=1 xvfb-run -a npx electron out/main/index.js 2>&1`. This catches main-process crashes (duplicate handlers, import errors, etc.) that are completely invisible on Windows end-user machines. +- **Splash screen video background**: Use `