Files
atomk-hermes-skills/skills/devops/hermes-desktop-build/SKILL.md
T

99 KiB
Raw Blame History

name, description, version
name description version
hermes-desktop-build 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. 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+):

// 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.tsBridgeInfo interface
  2. src/preload/index.d.tsoperationFetchBridges 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/)
    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.
  4. Package (out/ → dist/ installer):
    npx electron-builder --win --x64
    
  5. 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.
  6. Verify output:
    ls -lh dist/atomk-desktop-<VERSION>-setup.exe
    file dist/atomk-desktop-<VERSION>-setup.exe
    # Without Chromium: ~103MB (INCOMPLETE — resources/chromium/ missing)
    # With Chromium: ~234MB (complete local_embedded build)
    # Should be "PE32 executable ... Nullsoft Installer"
    
  7. Upload to COS:
    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-<VERSION>-setup.exe atomk-desktop/releases/atomk-desktop-<VERSION>-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).
  8. Download URL: https://9websclub-1251422183.cos.ap-hongkong.myqcloud.com/atomk-desktop/releases/atomk-desktop-<VERSION>-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:

# 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-<VERSION>-setup.exe',
    '/home/ubuntu/AtomK-Desktop/dist/atomk-desktop-<VERSION>-setup.exe')
print('Uploaded')
"

Download URL: https://xuxueli.oss-cn-hangzhou.aliyuncs.com/atomk-desktop/releases/atomk-desktop-<VERSION>-setup.exe

Git & Gitea Workflow

  • Local branch: main
  • Gitea remote: originAtomK-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 <current-branch> (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.tsxAtomK-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:

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('<HHH', 0, 1, len(sizes))  # header
header_size = 6 + len(sizes) * 16
offset = header_size
entries, data_parts = [], []
for s in sizes:
    r = img.resize((s, s), Image.LANCZOS).convert('RGBA')
    buf = io.BytesIO(); r.save(buf, 'PNG'); png_bytes = buf.getvalue()
    entries.append((0 if s >= 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('<BBBBHHII', w_b, h_b, 0, 0, 1, 32, ds, off)
for part in data_parts: ico += part
with open('build/icon.ico', 'wb') as f: f.write(ico)

# 4. Generate ICNS (macOS) — embed PNGs in icns container
icns_data = bytearray(b'icns')
icns_types = {
    b'ic04': 32, b'ic05': 64, b'ic07': 128, b'ic08': 256,
    b'ic09': 512, b'ic10': 1024, b'ic11': 32, b'ic12': 64,
    b'ic13': 256, b'ic14': 512,
}
for itype, px in icns_types.items():
    r = img.resize((px, px), Image.LANCZOS)
    r.save(f'/tmp/icon_{px}.png', 'PNG')
    with open(f'/tmp/icon_{px}.png', 'rb') as f: png_data = f.read()
    icns_data += itype + 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:

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:

# 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:

    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 <span className="splash-version">v{APP_VERSION}</span> 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 <video autoPlay loop muted playsInline> with an MP4 imported as a Vite asset. Place in src/renderer/src/assets/ and import like import splashVideo from "../../assets/splash-video.mp4". Video replaces static hermesbg.webp. Keep .splash-bg CSS class with object-fit: cover.

  • Skills Registry tab (Gitea integration, v3.9.19+): Skills page now has three tabs: Installed / Registry / Browse. Registry fetches from Gitea API (/api/v1/repos/admin9webs/atomk-hermes-skills/contents/skills), parses directory structure, reads SKILL.md frontmatter. Install downloads raw SKILL.md from Gitea and writes to ~/.hermes/skills/<category>/<name>/SKILL.md. Files: src/main/skills.ts (+listRegistrySkills, installRegistrySkill), src/main/index.ts (+2 IPC handlers), src/preload/index.ts + .d.ts (+2 API methods), src/renderer/.../Skills/Skills.tsx (+Registry tab).

  • Dynamic require() for project-local modules silently fails in production (v3.9.18 fix): bridge-manager.ts and chrome-bridge.ts used require('./atomlisting') inside function bodies to get atomkAPI.auth.getCurrentUser() at connect time. electron-vite's Rollup does NOT bundle dynamic require() calls — the module is missing from out/main/, causing MODULE_NOT_FOUND at runtime. The catch {} block silently swallows the error → config.username stays undefined → WS auth sent without user field → Bridge routes to default. Fix: always use static ES import { atomkAPI } from './atomlisting' at the top of the file. The same bug previously hit Browser Agent (v3.9.8, see references/browser-agent-require-incident.md). Verification: grep require('./ ' in out/main/index.js after build — any relative require() in the output is a red flag.

  • TS6133 unused function parameters: WebSocket server connection handlers like wss.on('connection', (ws, req) => {...}) where req is unused must use underscore prefix: (ws, _req) => {...}. Same for any other callback with unused positional params — TypeScript strict mode flags them even in arrow functions.

  • TS2345 role type mismatch: When mapping DB rows to ChatMessage[], the role field from DB is string but the UI type expects "user" | "agent". Use an explicit type assertion: role: (m.role === "assistant" ? "agent" : m.role) as "user" | "agent". Without the as, TypeScript rejects string as not assignable to the union type.

  • TS6133 unused destructured variables: e.g. const [x, setX] = useState(...) where setX is never used → change to const [x] = useState(...)

  • TS1005 ',' expected in i18n locale files: When adding new keys to one locale file (e.g., zh-CN/settings.ts) but forgetting other locales (es, id, ja, pt-BR), the MISSING keys cause a trailing-comma error in those files because the last key before the gap lacks a comma. Always patch ALL 6 locale files simultaneously when adding new i18n keys.

  • AttachmentInfo missing fields: When adding new fields to interface AttachmentInfo in index.d.ts, all .map() calls creating AttachmentInfo objects must include the new field. E.g., adding ext: string to the interface requires adding ext: a.ext in useChatActions.ts line 75's .map() call. Otherwise TS2345 compilation error

  • Small setup.exe (~557K): Means NSIS packaging failed silently. Check dist/builder-debug.yml and the .7z archive. Check dist/builder-debug.yml and the .7z archive. If 7z is ~103MB but exe is tiny, the NSIS step failed. Rebuild with rm -rf dist/ first.

  • Relay node_modules: Must be copied to dist/win-unpacked/resources/relay/node_modules — a post-build script in package.json handles this ("build": "node scripts/copy-relay-deps.js" or similar)

  • cdpEnabled stays false: User must launch Chrome with --remote-debugging-port=9222. v2.1.1+ adds "Start CDP Browser" button to automate this. Using --user-data-dir=C:\HermesCDP avoids conflict with user's normal Chrome session.

  • electron-builder.yml: Contains NSIS config, file associations, extraResources. The extraResources entry copies resources/extension, resources/relay, and resources/chromium (v3.0.0+) into the installer.

  • spawn UNKNOWN on Windows: Root causes evolved across versions:

    • v3.0.1: (1) icudtl.dat missing from resources/chromium/ — Chrome crashes immediately, Node.js reports spawn UNKNOWN; (2) --disable-software-rasterizer flag blocked SwiftShader (the only rendering path when GPU DLLs are stripped) — Chrome renders nothing; (3) spawn() without shell:true fails for paths with spaces (e.g. C:\\Program Files\\...).
    • v3.0.2: Used shell: true to fix path-spaces issue, but this introduced new problems (see next bullet).
    • v3.0.4: Missing cwd option — Chrome needs cwd: path.dirname(browserPath) to find chrome.dll. Also --load-extension with non-existent directories crashes Chrome — must filter with fs.existsSync().
  • shell:true mangles Chrome flags (v3.0.2→v3.0.3): shell: true passes the command through cmd.exe which re-parses args — --load-extension=path1,path2 gets split at commas, backslashes in Windows paths get eaten. Result: Chrome silently fails to start, stdio: 'ignore' swallows the error. Fix: DO NOT use shell: true for spawn(); use stdio: ['ignore', 'pipe', 'pipe'] to capture Chrome output for debugging.

  • "打开内置浏览器"按钮无反应: Button had disabled={loading !== null || state.cdpEnabled} — when an external CDP browser was already connected, cdpEnabled=true disabled the button entirely (not just grayed, completely unclickable). Fix: remove cdpEnabled from disabled condition; instead, show Start/Stop toggle based on cdpBrowserRunning.

  • Auto-start CDP browser on launch (v3.0.3+): Added setTimeout in app.whenReady() after 2s delay to auto-call startCdpBrowser(). Skips if CDP already available (user connected external Chrome). Also added stopCdpBrowser() in app.on('before-quit') to clean up Chrome process tree on exit.

  • "update failed" banner on launch (v3.0.4): setupUpdater() had setTimeout(() => autoUpdater.checkForUpdates(), 5000) — when no update server is configured, this shows "update failed" in the UI. Fix: comment out the auto-check; users can still check manually from Settings.

  • spawn UNKNOWN even without shell:true (v3.0.4): Missing cwd option in spawn() — Chrome needs cwd: path.dirname(browserPath) to locate chrome.dll. Without it, Chrome fails with spawn UNKNOWN on Windows. Also: --load-extension with non-existent paths crashes Chrome immediately — must fs.existsSync() filter extension paths before passing them.

  • Stale cdpBrowserProcess check (v3.0.5 — CRITICAL): After a spawn UNKNOWN failure, cdpBrowserProcess is still assigned (not null) and .killed is false, but the process is actually dead. Subsequent button clicks hit the if (cdpBrowserProcess && !cdpBrowserProcess.killed) guard and return success without attempting to start Chrome. Fix: also check exitCode === null (null = still running, any number = exited):

    if (cdpBrowserProcess && !cdpBrowserProcess.killed && cdpBrowserProcess.exitCode === null) {
      return { success: true }  // Process genuinely alive
    }
    
  • Windows spawn() vs execFile() (v3.0.5): spawn() on Windows can still fail with UNKNOWN for certain executables even without shell:true. execFile() is more reliable for launching Chrome on Windows — it handles paths with spaces natively and avoids cmd.exe entirely. Use execFile as primary, spawn as fallback.

  • checkCdpAvailable() blocks built-in browser (v3.0.5): If an external Chrome is already listening on port 9222, checkCdpAvailable() returns true and the code skips launching the built-in browser. But the user clicked "打开内置浏览器" — they want the built-in one! Fix: either use a different port (9223) for the built-in browser, or don't check checkCdpAvailable() when user explicitly requests launch.

  • Bundled Chromium path resolution broken after ASAR packaging (v3.0.1v3.0.5 ROOT CAUSE): process.resourcesPath does not reliably resolve to the correct directory after ASAR + NSIS packaging. getBundledChromiumPath() returns null because fs.existsSync() fails on the constructed path. This is why cdpBundled: false, cdpBrowserPath: null appeared in the status — the browser was never found, never spawned. All previous "spawn UNKNOWN" fixes (cwd, execFile, shell) were addressing a non-existent problem because startCdpBrowser() exited before reaching spawn. Fix (v3.1.0): use Windows Registry to find system Edge/Chrome instead.

  • Diagnostic log must be written at FUNCTION ENTRY, not after path resolution: If findBrowser() returns null and you only write the log after spawn, the log never gets created. User reports "no cdp-debug.log" which looks like the function was never called, but actually it was called and returned early. Always write the log as the FIRST thing in the function.

  • Diagnostic log file (v3.0.4+): startCdpBrowser() writes a cdp-debug.log to app.getPath('userData') (on Windows: %AppData%/AtomK Desktop/) with browserPath, args, and any spawn errors. Critical for remote debugging when user reports issues. If user says "no cdp-debug.log exists", it means startCdpBrowser() was never reached — likely the stale process check bug above.

  • Browser error invisible to user (v3.2.1 and earlier): When bundled Chromium exits immediately (e.g. missing --no-sandbox), the browser just silently disappears. User sees "CDP Inactive" but has no idea why. Fix (v3.2.2): Added lastCdpError field to ChromeBridgeState. When browser exits with non-zero code, store error message + stderr tail. UI shows orange bridge-error-banner with the error. Also write stderr to cdp-debug.log for remote diagnosis.

  • Git push rejected (diverged remote): When Gitea remote has new commits since your last fetch, git push atomk main will be rejected. Fix: git fetch atomk main && git rebase atomk/main && git push atomk main. This is common when multiple sessions push to the same branch.

  • Gitea branch protection blocks push: If git push origin main fails with remote: error: Not allowed to push to protected branch main, the repo has branch protection rules. Fix via Gitea API (requires repo admin):

    1. Delete protection: curl -sk -u 'admin9webs:PASSWORD' -X DELETE 'https://gitea9webs.sh3.ikuai7.com/api/v1/repos/admin9webs/AtomK-Desktop/branch_protections/main'
    2. git push origin main
    3. Re-create protection: curl -sk -u 'admin9webs:PASSWORD' -X POST 'https://gitea9webs.sh3.ikuai7.com/api/v1/repos/admin9webs/AtomK-Desktop/branch_protections' -H 'Content-Type: application/json' -d '{"branch_name":"main","enable_push":false}' Do NOT leave the branch unprotected — re-create immediately after push.
  • Gitea main branch protection blocks push: If git push origin main fails with remote: error: Not allowed to push to protected branch main, the repo has branch protection (enable_push: false) with no push whitelist. Fix: temporarily delete the protection via Gitea API, push, then re-create it:

    # 1. Delete protection (requires admin9webs basic auth)
    curl -sk -u 'admin9webs:Tt123456!' -X DELETE \
      'https://gitea9webs.sh3.ikuai7.com/api/v1/repos/admin9webs/AtomK-Desktop/branch_protections/main'
    
    # 2. Push
    git push origin main
    
    # 3. Re-create protection immediately
    curl -sk -u 'admin9webs:Tt123456!' -X POST \
      'https://gitea9webs.sh3.ikuai7.com/api/v1/repos/admin9webs/AtomK-Desktop/branch_protections' \
      -H 'Content-Type: application/json' \
      -d '{"branch_name":"main","enable_push":false}'
    

    Check existing protection with: curl -sk -u 'admin9webs:Tt123456!' 'https://gitea9webs.sh3.ikuai7.com/api/v1/repos/admin9webs/AtomK-Desktop/branch_protections'.

  • Gitea "reference already exists" on push: Gitea sometimes rejects git push atomk main --force with "reference already exists" even after fetch+rebase. This appears to be a Gitea server-side caching issue — the server doesn't immediately reflect its own ref advertisement. Workaround: wait 30-60s and retry, or push via git push atomk HEAD:refs/heads/main. If it persists, use the Gitea API to delete the branch ref then push fresh.

  • Keep AtomK-Desktop Gitea repo single-branch (main): If a temporary branch was created because main push was rejected, consolidate it back immediately. Preferred order: try fast-forward push to main; if direct push/PR merge/update-ref all get rejected by hooks, temporarily set the repo default branch to the temp branch, delete old main, rename the temp branch to main, then delete all other branches. After any failed/timeout Gitea API merge/default-branch operation, the bare repo may leave locks such as /data/git/repositories/admin9webs/atomk-desktop.git/HEAD.lock or refs/heads/main.lock; ask the user/admin to remove the specific lock path before retrying. Finally run git fetch atomk main && git remote prune atomk and verify git rev-parse HEAD atomk/main match.

  • Git merge with unrelated remote commits: When remote repo has divergent/unrelated history (e.g. remote was re-initialized), git merge --allow-unrelated-histories produces massive conflicts. Better approach: git checkout atomk/main -- <specific-files> to cherry-pick new files, then manually integrate. If remote adds incomplete features (e.g. Cookies.tsx without preload/icon dependencies), temporarily .bak exclude them from build. If "以你的为主" (your code takes priority), use git push atomk clean-main:main --force to overwrite remote.

  • TS2322 Promise|null not assignable to Promise: When a lazily-initialized variable is typed let x: Promise<T> | null = null but the return type is Promise<T>, TypeScript rejects the return. Fix: use non-null assertion return x! since the assignment always happens before return (guarded by the if block).

  • Config data survives upgrades: The NSIS installer only replaces the application directory (e.g. AppData\Local\Programs\AtomK Desktop\). The user data directory (%USERPROFILE%\.hermes\) containing config.yaml, session DBs, and all settings is never touched. Sub-Agent settings, API keys, and other config stored in config.yaml persist across reinstalls.

  • Sensitive field masked display pattern: When loading existing config values (like API keys) into Settings UI, use a prefix-based masking technique to show "something exists" without exposing the real value:

    1. On load: transform apiKey__masked__sk-1****3456 (prefix + masked display)
    2. On render: show masked value in password field; show unmasked version in small hint text below
    3. On edit: if value still has __masked__ prefix, user hasn't changed it → strip prefix and restore original on save
    4. On focus: auto-select the field so user can instantly paste a new value
    5. Add Eye/EyeOff toggle icon next to field for show/hide This prevents users from re-entering existing keys they can't see, while keeping secrets out of the DOM in plaintext by default.

Bundled Chromium Setup (v3.0.0+)

Downloading Chrome for Testing

Always use the dynamic version API to get the latest stable version — don't hardcode a version number:

# Step 1: Get latest stable version + download URL
curl -sL "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json" | python3 -c "
import json,sys
data=json.load(sys.stdin)
stable=data['channels']['Stable']
print('Version:', stable['version'])
for p in stable['downloads']['chrome']:
    if p['platform']=='win64':
        print('URL:', p['url'])
"
# Output example: Version: 149.0.7827.54 / URL: https://storage.googleapis.com/.../chrome-win64.zip

# Step 2: Download
cd /tmp
curl -sL -o chrome-win64.zip "

### Stripping unnecessary files (350MB → ~246MB or ~275MB with GPU DLLs)
The full Chrome for Testing is ~350MB. Remove non-essential files to reduce installer size:

**Option A: Minimal bundle (~246MB) — GPU disabled, software rendering only**
```bash
cd /tmp/chrome-win64
rm -rf locales/ Default/
rm -f chrome_200_percent.pak libEGL.dll libGLESv2.dll D3DCompiler_47.dll
# MUST use --disable-gpu flag with this option
# MUST keep SwiftShader files (vk_swiftshader.dll + vk_swiftshader_icd.json)

Option B: Full bundle (~275MB) — GPU supported, RECOMMENDED since v3.0.2

cd /tmp/chrome-win64
rm -rf locales/ Default/
rm -f chrome_200_percent.pak
# KEEP: D3DCompiler_47.dll, libEGL.dll, libGLESv2.dll (GPU rendering support)
# Do NOT use --disable-gpu flag with this option

⚠️ Option A (stripping GPU DLLs) is DEPRECATED — it was used in v3.0.1 and caused "内置浏览器打不开" because even with --disable-gpu, Chromium still depends on these DLLs for basic rendering. The --disable-gpu flag disables hardware acceleration but doesn't remove the DLL dependency. Without the DLLs, Chrome crashes silently on Windows. Since v3.0.2, always use Option B (keep GPU DLLs).

Common for both options:

# CRITICAL: icudtl.dat (~10MB) is REQUIRED — Chrome cannot start without it!
#   - v3.0.1 had this missing, causing "spawn UNKNOWN" error on Windows
# IMPORTANT: Keep SwiftShader files (vk_swiftshader.dll + vk_swiftshader_icd.json)
#   - Software rendering fallback, needed even with GPU DLLs for headless/sandbox modes

Placing in project

mkdir -p /home/ubuntu/AtomK-Desktop/resources/chromium
cp -r /tmp/chrome-win64/* /home/ubuntu/AtomK-Desktop/resources/chromium/
# Verify chrome.exe exists
ls /home/ubuntu/AtomK-Desktop/resources/chromium/chrome.exe

⚠️ resources/chromium/ is typically absent from git working copies — it's too large for git (~400MB), and .gitignore likely excludes it. If you clone fresh or the directory is missing, the build will produce an incomplete ~103MB package instead of the expected ~230MB+. Always verify resources/chromium/chrome.exe exists BEFORE building. If missing, follow the download steps above.

⚠️ Disk space check before build: The build server has a 50GB disk that fills up fast. Chromium zip (184MB) + extraction (417MB) + build output (~234MB) requires ~1.5GB free. If df -h / shows <2GB free, clean up first:

# Quick wins (safe to delete, can be re-downloaded):
rm -rf /home/ubuntu/.npm/_cacache          # npm cache
rm -rf /home/ubuntu/.cache/camoufox/       # camoufox browser
rm -rf /home/ubuntu/.cache/ms-playwright/  # playwright browsers
rm -rf /home/ubuntu/.cache/uv/             # uv package manager cache
rm -rf /home/ubuntu/.cache/electron-builder/  # electron-builder cache
# If still tight, AI models can be removed (can re-download later):
# du -sh /home/ubuntu/ai-models/*/ | sort -rh

electron-builder.yml extraResources entry

extraResources:
  - from: resources/extension
    to: extension
  - from: resources/relay
    to: relay
  - from: resources/icon.png
    to: icon.png
  - from: resources/chromium      # v3.0.0+
    to: chromium
    filter:
      - "**/*"

chrome-bridge.ts: Bundled Chromium detection

function getBundledChromiumPath(): string | null {
  let chromiumDir: string
  if (process.resourcesPath) {
    chromiumDir = path.join(process.resourcesPath, 'chromium')
  } else {
    chromiumDir = path.join(__dirname, '../../resources/chromium')
  }
  const exePath = path.join(chromiumDir, 'chrome.exe')
  return fs.existsSync(exePath) ? exePath : null
}

startCdpBrowser priority (v3.1.0+)

  1. System Edge/Chrome via Windows Registry (findBrowserFromRegistry()) — most reliable, always present on Win10+
  2. System Chrome/Edge via known paths (findBrowserFromKnownPaths()) — fallback
  3. Bundled Chromium (getBundledChromiumPath()) — last resort (unreliable after ASAR packaging)
  4. When using system browser, pass --load-extension=<extension-path> and --user-data-dir for isolation
  5. Why bundled Chromium is unreliable: After ASAR packaging, process.resourcesPath + path.join(resourcesPath, 'chromium') may not resolve correctly at runtime — fs.existsSync(chrome.exe) returns false, findBrowser() returns null, and the entire browser launch is skipped silently with cdpBundled: false, cdpBrowserPath: null. This was the root cause of "spawn UNKNOWN" across v3.0.1v3.0.5.

⚠️ spawn() on Windows — CRITICAL

When launching the bundled Chromium on Windows, spawn() can fail. The fix has evolved across versions:

v3.0.2 approach (BROKEN): Used shell: true — this caused Chrome flags with commas (--load-extension=path1,path2) and backslashes (Windows paths) to be mangled by cmd.exe. Chrome silently failed to start, no error visible because stdio: 'ignore' swallowed everything.

v3.0.3+ approach (CORRECT): Do NOT use shell: true. Use execFile (preferred on Windows) or spawn() with stdio: 'pipe' to capture errors. MUST set cwd to chrome.exe's directory or Chrome can't find chrome.dll. MUST check exitCode === null to verify process is actually alive (not just assigned):

// ✅ CORRECT — execFile preferred on Windows, pipe stdio for debugging, cwd set, alive check
const browserDir = path.dirname(browserPath)

// Primary: execFile (most reliable on Windows)
import { execFile, spawn } from 'child_process'

if (process.platform === 'win32') {
  cdpBrowserProcess = execFile(browserPath, args, {
    cwd: browserDir,    // CRITICAL: Chrome needs cwd=its dir to find chrome.dll
    windowsHide: false,
    maxBuffer: 10 * 1024 * 1024,
  })
} else {
  cdpBrowserProcess = spawn(browserPath, args, {
    cwd: browserDir,
    windowsHide: false,
    stdio: ['ignore', 'pipe', 'pipe'],
  })
}

// ✅ CORRECT — check process is actually alive before skipping launch
if (cdpBrowserProcess && !cdpBrowserProcess.killed && cdpBrowserProcess.exitCode === null) {
  return { success: true }  // Process genuinely running
}

// Log Chrome output for debugging
cdpBrowserProcess.stdout?.on('data', (data: Buffer) => {
  console.log('[CDP Browser stdout]', data.toString())
})
cdpBrowserProcess.stderr?.on('data', (data: Buffer) => {
  console.error('[CDP Browser stderr]', data.toString())
})

Why no shell: true:

  • shell: true passes the entire command through cmd.exe, which re-parses/escapes args
  • Chrome flags like --load-extension=C:\path\to\ext1,C:\path\to\ext2 get mangled (commas split args, backslashes eaten)
  • --disable-extensions-except= paths with backslashes also break
  • Node.js spawn() on Windows handles paths with spaces natively via the args array — no shell needed
  • stdio: 'ignore' swallows ALL output, making startup failures invisible — always use 'pipe' for CDP browser

⚠️ Killing Chrome on Windows — CRITICAL

With shell: true (v3.0.2, deprecated), process.kill() only kills the shell. But without shell: true (v3.0.3+), process.kill() works correctly on the direct child. Still use taskkill as belt-and-suspenders for the full process tree:

function stopCdpBrowser() {
  if (process.platform === 'win32' && cdpBrowserProcess && !cdpBrowserProcess.killed) {
    const pid = cdpBrowserProcess.pid
    // Kill the entire Chrome process tree (Chrome spawns sub-processes)
    spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
  } else {
    cdpBrowserProcess?.kill()
  }
}

⚠️ Bundled Chromium required startup flags

const args = [
  `--remote-debugging-port=9222`,
  `--user-data-dir=C:\\\\HermesCDP`,
  // Extension paths — MUST filter by fs.existsSync()!
  // Chrome crashes with --load-extension pointing to non-existent directory.
  // If no extensions exist, add '--disable-extensions' instead.
  `--load-extension=${extPaths.filter(p => fs.existsSync(p)).join(',')}`,
  '--no-first-run',
  '--no-default-browser-check',
  // '--disable-gpu' REMOVED in v3.0.2 — GPU DLLs (D3DCompiler_47/libEGL/libGLESv2) are now included in bundle
]

// CRITICAL: When using BUNDLED Chromium (not system browser), these flags are REQUIRED:
if (isBundled) {
  args.push(
    '--no-sandbox',               // CRITICAL! Chromium sandbox doesn't work when launched
                                  // by another process (Electron). Without this, browser
                                  // process exits silently with no error on Windows.
    '--disable-setuid-sandbox',   // Belt-and-suspenders for sandbox disable
    '--disable-software-rasterizer', // Avoids vk_swiftshader crashes in sandbox-disabled mode
  )
  // NOTE: As of v3.0.2, DO NOT add --disable-gpu here. GPU DLLs are now included
  // in the bundle. --disable-gpu was previously needed because those DLLs were stripped,
  // but without them Chrome crashed silently (even with the flag!).
} else {
  // System browser — full GPU support, add window size hint
  args.push('--window-size=1280,900')
}

Why --no-sandbox is CRITICAL for bundled Chromium: When Chromium is launched as a child process of Electron (which itself is a Chromium process), the sandbox mechanism cannot initialize properly. The browser process exits immediately with no visible error. This was one cause of "内置浏览器打不开" in v3.0.1 — not a missing Node.js/npm issue as users might assume. System Chrome/Edge does NOT need this flag because it runs with its own independent sandbox.

⚠️ GPU DLLs are REQUIRED (v3.0.2 lesson): Stripping D3DCompiler_47.dll, libEGL.dll, libGLESv2.dll from the bundle to save ~14MB was a false economy. Even with --disable-gpu, Chromium still attempts to load these DLLs during initialization. Without them, the process crashes silently — no error, no log, just immediate exit. The --disable-gpu flag only disables hardware-accelerated rendering, it does NOT make the DLLs optional. Always include these DLLs in the bundle.

⚠️ NEVER taskkill user's browser to restart with CDP (v3.0.2 lesson): The OLD approach was taskkill /IM msedge.exe /F → restart Edge with --remote-debugging-port. This is catastrophically wrong:

  1. Kills ALL user's Edge windows/tabs (not just CDP-related)
  2. Profile directory is locked — Chrome can't restart with same profile
  3. Edge auto-restarts from background processes, stealing the port
  4. User loses their work and the CDP browser still doesn't start

Correct approach: Use a SEPARATE --user-data-dir (e.g. cdp-browser-profile under app data). The CDP browser instance coexists peacefully with the user's normal browser. No taskkill needed. The user's Edge stays untouched.

⚠️ --load-extension is MANDATORY (v3.0.2 lesson): Without --load-extension=<extension-path>, the Chrome Bridge extension is never loaded. The Cloud Bridge WebSocket has nothing to connect to. The browser appears to "start" (CDP port responds), but extension stays Disconnected. The user sees "内置浏览器打不开" because nothing actually works. Always add:

const extDir = process.resourcesPath
  ? path.join(process.resourcesPath, 'extension')
  : path.join(__dirname, '../../resources/extension')
if (fs.existsSync(path.join(extDir, 'manifest.json'))) {
  args.push(`--load-extension=${extDir}`)
}

Note on --disable-software-rasterizer: Earlier versions warned against this because SwiftShader was the only rendering path. However, with --no-sandbox, the rendering pipeline changes and vk_swiftshader can crash. Since the bundled Chromium already has --disable-gpu, software rasterizer is not needed. Only add this for bundled Chromium, not system browsers.

Extension auto-load

// In startCdpBrowser():
const extensionPath = getExtensionInstallPath()  // resources/extension
const args = [
  `--remote-debugging-port=9222`,
  `--user-data-dir=C:\\HermesCDP`,
  `--load-extension=${extensionPath}`,   // Auto-loads extension!
  '--no-first-run',
  '--no-default-browser-check',
]

State field: cdpBundled

  • state.cdpBundled = true when using bundled Chromium (extension auto-loaded)
  • state.cdpBundled = false when using system Chrome/Edge (user may need to manually install extension)

Version Variants

Version Size Browser Extension Best For
v2.1.1 104MB System Chrome/Edge Manual install Users who already have Chrome
v3.0.0 188MB Bundled Chromium Auto-loaded One-click setup, no Chrome needed
v3.0.2 203MB Bundled Chromium (DLL fix) Auto-loaded Restored D3DCompiler_47.dll, libEGL.dll, libGLESv2.dll to bundled Chromium — previous versions stripped these GPU DLLs to save space, but Chrome silently crashes without them even with --disable-gpu. Removed --disable-gpu flag. Added diagnoseBrowserSearch() for detailed error diagnostics when browser not found.
v3.0.3 200MB Bundled Chromium (fixed) Auto-loaded Removed shell:true (was mangling Chrome flags), stdio:pipe for debug, auto-start on launch, stopCdpBrowser on before-quit
v3.0.4 200MB Bundled Chromium (fixed) Auto-loaded Added cwd to spawn (chrome.dll discovery), extension path validation, cdp-debug.log, disabled autoUpdater.checkForUpdates (fixes "update failed" banner)
v3.0.5 200MB Bundled Chromium (fixed) Auto-loaded Fixed stale process check (exitCode===null), Windows execFile fallback, separate port for built-in browser vs external CDP
v3.1.0 200MB System Edge/Chrome (primary) + Bundled fallback Auto-loaded Pivotal: switched from bundled Chromium to system browser via Windows Registry lookup. Root cause of all previous spawn UNKNOWN: process.resourcesPath unreliable after ASAR packaging → findBrowser() returned null → browser never launched. Registry query is the only reliable way on Windows. Also: system browser gets full GPU support (no --disable-gpu needed).
v3.1.1 200MB System Edge/Chrome + Bundled fallback Broken Added --enable-extensions flag. Added --new-window + about:blank auto-open after CDP ready (to activate MV3 service worker). But Extension still disconnected — --load-extension silently ignored by Chrome.
v3.2.0 200MB System Edge/Chrome + Bundled fallback Broken Root cause of Extension failure: stale Chrome processes holding cdp-browser-profile/SingletonLock → new Chrome instance connects to existing one → --load-extension ignored. Fix: kill stale processes, delete lock files. But Page Snapshot still empty due to 3 API mismatch bugs (fixed in v3.2.1).
v3.2.1 199MB System Edge/Chrome (reuse user profile) 3 critical API mismatch bugs fixed: (1) Extension POSTs to /page-snapshot but Relay only had /push-snapshot → all snapshots 404! (2) Extension sends WS extension_connect but Relay only handled tab-update → connectedTabs always empty. (3) Extension sends text/html/screenshot/meta/selection but Relay only stored content. Also: reuse user's real Chrome profile (not cdp-browser-profile) to preserve extensions/bookmarks/sessions.
v3.0.2 (rebuild) 200MB System Edge/Chrome (separate profile, NO taskkill) Fixed "内置浏览器打不开" root cause: (1) Removed taskkill /IM msedge.exe /F that killed user's entire Edge — now uses separate --user-data-dir=cdp-browser-profile so CDP browser coexists with user's browser. (2) Added --load-extension=<path> flag (was MISSING entirely — extension never loaded, Cloud Bridge had nothing to connect to). (3) Added lastCdpError on all failure paths (no browser found, exe missing, spawn error). (4) Added CDP reuse check: if CDP already available on port 9222, skip launch and reuse.
v3.2.2 200MB System Edge/Chrome + Bundled fallback Added --no-sandbox + --disable-setuid-sandbox for bundled Chromium. Added Cloud Bridge client (WebSocket tunnel to cloud server for remote Agent control). Hermes API Server on port 8642 with key Bing2026Cao$$$.
v3.4.0 199MB System Edge/Chrome (separate profile, NO taskkill) New app icon (user-provided programmer illustration). Sidebar brand changed to "AtomK-Agent" with icon. CDP browser rewrite: independent --user-data-dir=cdp-browser-profile, no taskkill of user's Edge, --load-extension always passed, lastCdpError on all failure paths.
v3.4.1 199MB System Edge/Chrome (separate profile) Same as v3.4.0, minor fixes.
v3.4.2 203MB System Edge/Chrome (separate profile) Fixed AttachmentInfo missing ext field in useChatActions.ts. Route B pure-CDP endpoints integrated into Cloud Bridge server.py.
v3.4.3 203MB System Edge/Chrome (separate profile) Cloud Bridge WS reconnect loop fix (see post-mortem below). Splash screen version dynamically injected from package.json (__APP_VERSION__ via Vite define). Fixed AttachmentInfo missing ext in index.ts chat handler. WS server URL default changed to empty string (no hardcoded server).
v3.4.3+ 203MB System Edge/Chrome (separate profile) Added electron-log@5.4.4 — all console.log/warn/error auto-write to persistent log file. UI log viewer in ChromeBridge page. Log rotation 5MB.
v3.7.0 207MB Remote-only (no local mode) Removed local mode entirely — app now requires a remote AtomK URL or SSH tunnel. No local installation, no gateway management, no CLI fallback. ConnectionConfig.mode is "remote" | "ssh" only. Sessions use Hermes API /v1/sessions endpoints in remote mode. Removed install/welcome/setup screens from startup flow.
v3.8.0 207MB Remote-only (no local mode) Fixed Sessions blank page in remote mode — IPC handlers now propagate API errors (throw) instead of silently returning empty arrays. Frontend shows error banner with retry button on failure. Also: removed verifyWarning/onReinstall from Layout, cleaned up hermes.ts unused imports (fs/path/os/HERMES_HOME), removed connectionMode state from App.tsx.
v3.8.1 207MB Remote-only (no local mode) Fixed /status command not working during agent execution — Made /status a local command (shows connection mode, remote URL, model, session, message count, loading state). Moved isLocal() check BEFORE isLoadingRef check in handleSend so all local commands work at any time. Also: added getRemoteApiBaseUrl() (same URL, no port swap needed for AtomK Bridge v3.0).
v3.8.2 207MB Remote-only (no local mode) Adapted for AtomK Bridge v3.0 — Simplified getRemoteApiBaseUrl() to use same port as remoteUrl (no 9228→9229 swap), since AtomK Bridge v3.0 serves REST API on the same single port as WebSocket.
v3.8.4 207MB Remote-only (no local mode) Added friendly 401 error messages — When remote API returns HTTP 401 (Invalid API key), all IPC handlers and the chat streaming handler now show Chinese-friendly message: "API Key 无效或未设置,请在设置中检查 API Key 是否正确。(HTTP 401: ...)" instead of raw JSON error. Applied to list-sessions, get-session-messages (index.ts), and chat streaming (hermes.ts). Bridge-side fix: AtomK Bridge now routes ALL /v1/* through hermes_api_proxy_handler (proxy to 8642) instead of handling /v1/sessions locally with Bridge's own --key auth — this was the root cause of the 401 (key mismatch).
v3.8.5 207MB Remote-only (no local mode) Cookie persistent backup — Dual-layer persistence prevents cookie loss on app upgrade. Local: Cookie JSON saved to %APPDATA%/AtomK/cookie-backups/ (outside electron userData, survives upgrades). Auto-backup on quit, auto-restore on startup. Cloud: Upload/download via atomlisting.com API (backend stores to COS per user). UI: 4 buttons in ChromeBridge Cookie Management (本地备份/恢复, 云端备份/恢复). Activated previously-dead cookies.ts module by wiring IPC handlers.
v3.9.0 207MB Remote-only (no local mode) Welcome screen → Atomlisting.com login form — Removed SSH/Remote connection buttons from Welcome, replaced with username+password login. Login calls operationLogin API, on success auto-fetches Bridge connection info from instance config. App.tsx startup priority: atomlisting auth → legacy connection config → Welcome. ConnectionConfig gained cloudBridgeUrl field. ChromeBridge components (AutomationScripts, CookieManager, BridgeConnection) integrated with 17 stub IPC APIs. as any casts on unimplemented call sites. ⚠️ BROKEN: 5 duplicate IPC handlers caused fatal crash on startup — app window never appeared.
v3.9.1 207MB Remote-only (no local mode) Fixed fatal duplicate IPC handler crash — removed 5 stub handlers (chrome-bridge:set-bridge-list, cloud-connect, cloud-disconnect, cloud-get-state, cloud-get-config) that conflicted with real implementations. Debugging technique: ELECTRON_DISABLE_SANDBOX=1 xvfb-run -a npx electron out/main/index.js surfaces main-process crashes.
v3.9.2 207MB Remote-only (no local mode) Fixed authenticated users trapped at Welcome when Bridge unreachable — startup flow now always sends authenticated users to main screen; Bridge connection failure shows a dismissible yellow warning banner in Layout instead of blocking at login. Legacy (non-auth) users still need working connection to proceed.
v3.9.3 207MB Remote-only (no local mode) Fixed login flow using wrong URL for Bridgeinstance.base_url (e.g. https://us1.atomk.cn) is the Agent server, NOT the Bridge. Bridge info comes from bridges[0].host:port in the login API response. Login now calls operationApplyAgent() which fetches bridges list from server and configures Cloud Bridge WebSocket correctly. Startup check also uses operationApplyAgent() instead of manually testing instance.base_url.
v3.9.4 207MB Remote-only (no local mode) ⚠️ Same as v3.9.3 with additional fixes — operationApplyAgent handler rewritten to only use bridges[0].host:port (removed instance.base_url/agent_api_url fallback entirely). Still broken: Bridge rejects with 4001 because auth key was not propagated to WebSocket handshake.
v3.9.5 207MB Remote-only (no local mode) Fixed 4001 "Server rejected connection" — Root cause: connectCloudBridge() only sends WS auth message if (config.apiKey), but code was passing apiKey: "". Fix: propagate bridges[0].key through entire chain: operationApplyAgentconnectionConfig.apiKeyconnectCloudBridge({ serverUrl, apiKey: b.key }) → WS auth handshake. Also: getCloudBridgeConfig() now returns { serverUrl, apiKey } (not just serverUrl); UserBridgeInfo type extended with key: string; Settings handleConnectRecommendedBridge passes target.key; BridgeConnection/ChromeBridge manual Connect reads saved key via chromeBridgeCloudGetConfig().
v3.9.8 102MB Remote-only (no local mode) Browser Agent integration — New src/main/browser-agent/ module with DOM intelligent serialization (3-way CDP parallel fetch), observe→think→act→verify loop, ActionLoopDetector, 6-layer error recovery. Full UI with task input, step timeline, DOM tree viewer, screenshot preview. 6 new IPC handlers. Requires CDP browser to be running. ~2,400 lines new code across 6 files. ⚠️ v3.9.8 initial build crashed on Windows startup — Browser Agent code was loaded via require("./browser-agent") inside setupIPC(), but electron-vite's Rollup only bundles ES import — the 1765-line module was missing from the output, causing MODULE_NOT_FOUND. Fix: replace dynamic require() with static ES import at the top of index.ts. See references/browser-agent-require-incident.md for full post-mortem.
v3.9.9 234MB Remote-only (local_embedded) Fixed incomplete v3.9.8 buildresources/chromium/ was missing from working copy, producing a 103MB package with no bundled browser. Downloaded Chrome for Testing 149 via dynamic API, placed in resources/chromium/. Now ships as full local_embedded build (~234MB, includes Chromium 149). Bundled Chromium auto-launches on Windows — no system browser needed.
v3.9.16 238MB Remote-only (local_embedded) Bridge WS reconnect hardening + URL normalization — (1) normalizeCloudBridgeWsUrl(): s://ws://, ss://wss://, :9228:9229 auto-correction for Desktop WS connections. (2) 4001 close code changed from fatal to transient with exponential backoff retry (1s→2s→4s→8s); 4003 remains fatal. (3) WS auth message now carries user field (user_id/username) for multi-user routing via Bridge user_id_to_slot lookup. ⚠️ Known issue: user field comes from atomlistingAPI.auth.getCurrentUser()?.id?.toString() which can be "0" when user isn't logged in — Bridge PR #5 adds master-key fallback to default for unknown user_ids.

⚠️ CRITICAL: Why Bundled Chromium Failed (v3.0.1v3.0.5 Post-Mortem)

The entire "spawn UNKNOWN" saga was a red herring. The real problem was that findBrowser() never found the browser at all:

  1. getBundledChromiumPath() constructs path via path.join(process.resourcesPath, 'chromium', 'chrome.exe')
  2. After ASAR packaging + NSIS install, process.resourcesPath may not point to the expected directory
  3. fs.existsSync(chrome.exe) returns false → findBrowser() returns null
  4. startCdpBrowser() returns { success: false, error: 'No browser found' } before even reaching spawn
  5. But the stale cdpBrowserProcess bug (v3.0.5) masked this — subsequent clicks returned "success" without attempting launch

Lesson: When Electron built-in features don't work after packaging, verify the compiled JS actually reaches your code by:

  • Writing a diagnostic log to app.getPath('userData')/cdp-debug.log at the ENTRY of the function
  • If the log file doesn't exist, the function was never called (stale check or IPC routing issue)
  • If it exists but shows findBrowser() → null, the path resolution is broken

The fix (v3.1.0): Don't rely on process.resourcesPath for browser discovery. Use Windows Registry to find the system Edge/Chrome, which is always present on Win10+:

function findBrowserFromRegistry(): string | null {
  if (process.platform !== 'win32') return null
  try {
    const result = execSync(
      'reg query "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\msedge.exe" /ve 2>nul',
      { encoding: 'utf8', windowsHide: true }
    )
    const match = result.match(/REG_SZ\s+(.+)/i)
    if (match) {
      const p = match[1].trim()
      return fs.existsSync(p) ? p : null
    }
  } catch { /* not found */ }
  // Try Chrome
  try {
    const result = execSync(
      'reg query "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe" /ve 2>nul',
      { encoding: 'utf8', windowsHide: true }
    )
    const match = result.match(/REG_SZ\s+(.+)/i)
    if (match) {
      const p = match[1].trim()
      return fs.existsSync(p) ? p : null
    }
  } catch { /* not found */ }
  return null
}

⚠️ CRITICAL: Why --load-extension Silently Fails (v3.1.0v3.1.1 Post-Mortem)

After switching to system Chrome (v3.1.0), the browser launched fine with CDP , but the Extension stayed Disconnected. The status showed cdpBundled: false, extensionConnected: false.

Root cause: Chrome uses --user-data-dir=<profile> to isolate the CDP browser from the user's normal browser. If a previous CDP browser instance crashed or wasn't cleaned up properly:

  1. cdp-browser-profile/SingletonLock file remains on disk
  2. New Chrome launch detects the lock file → thinks another instance is using this profile
  3. Chrome connects to the existing process (even if it's dead/dying) instead of starting fresh
  4. --load-extension is completely ignored — Chrome only processes this flag on a clean start
  5. No error is raised — Chrome starts "successfully" but without the extension

The fix (v3.2.0): Before launching Chrome, clean up stale processes and lock files:

// 1. Kill stale Chrome processes using our user-data-dir
if (process.platform === 'win32') {
  try {
    const tasklist = execSync('tasklist /FI "IMAGENAME eq chrome.exe" /V /FO LIST', {
      encoding: 'utf8', windowsHide: true, timeout: 5000
    })
    // Parse and find processes using cdp-browser-profile
    // Kill them with: taskkill /F /T /PID <pid>
  } catch { /* no chrome processes */ }
}

// 2. Delete Chrome's singleton lock files
const lockFiles = ['SingletonLock', 'SingletonSocket', 'SingletonCookie']
for (const f of lockFiles) {
  const p = path.join(userDataDir, f)
  if (fs.existsSync(p)) fs.unlinkSync(p)
}

// 3. Now launch Chrome — --load-extension will work on a clean start

Also critical: Add --enable-extensions flag when using system Chrome. Without it, Chrome may default to extensions-disabled mode, and --load-extension is ignored silently.

Diagnostic: After CDP is available, check if the extension loaded by listing targets:

const resp = await fetch(`http://localhost:${CDP_PORT}/json/list`)
const targets = await resp.json()
const serviceWorkers = targets.filter((t: any) => t.type === 'service_worker')
// If serviceWorkers.length > 0, extension's MV3 service worker is running ✅

Key insight: The "系统浏览器 Relay Extension CDP / 内置浏览器 Relay CDP Extension " pattern perfectly explains the issue — the user's normal Chrome has the extension installed via chrome://extensions, but the CDP browser (launched with --user-data-dir) is a separate profile that needs --load-extension to load the extension. If --load-extension fails silently, you get CDP Extension .

⚠️ CRITICAL: Extension↔Relay API Mismatch (v3.0.1v3.2.0 Post-Mortem)

After fixing the extension loading, Page Snapshot still showed "No page snapshot available" with Extension showing "0 tab(s) connected". Three API mismatches between Extension and Relay:

  1. POST path mismatch: Extension's content_script.js sends snapshots via POST /page-snapshot, but Relay only registered POST /push-snapshotall snapshots returned 404, silently dropped
  2. WebSocket message type mismatch: Extension sends {type:"extension_connect"} on WS connect, but Relay only handled {type:"tab-update"}connectedTabs always empty, Extension appeared disconnected
  3. Data field loss: Extension POST body includes text, html, screenshot, meta, selection fields, but Relay only mapped contentrich snapshot data discarded

The fix (v3.2.1):

// 1. Register both snapshot endpoints
app.post('/push-snapshot', handleSnapshotPush)
app.post('/page-snapshot', handleSnapshotPush)  // Extension uses this path!

// 2. Handle both WS message types
if (msg.type === 'tab-update' || msg.type === 'extension_connect') {
  // ... add to connectedTabs
}

// 3. Map all fields from Extension payload
const snapshot: PageSnapshot = {
  url: body.url || '',
  title: body.title || '',
  content: body.text || body.content || body.html || '',
  selection: body.selection || undefined,
  meta: body.meta || undefined,
  screenshot: body.screenshot || null,
  timestamp: Date.now(),
  tabId: body.tabId
}

Lesson: When two independently-developed components (Extension and Relay) communicate, verify every endpoint path and message type matches. A typo in a URL path (/page-snapshot vs /push-snapshot) causes complete silent failure because HTTP 404s don't raise errors in fetch().

Splash Screen Version (Dynamic, v3.4.3+)

Since v3.4.3, the splash screen version is dynamically injected from package.json — no need to edit images when bumping version.

How it works:

  • electron.vite.config.ts imports { version } from package.json and defines __APP_VERSION__ global
  • src/renderer/src/env.d.ts declares declare const __APP_VERSION__: string
  • SplashScreen.tsx renders <span className="splash-version">v{APP_VERSION}</span> below the logo
  • CSS: .splash-version with semi-transparent white, 14px, fade-in animation (0.6s delay)
  • Version auto-updates when package.json version changes — zero manual image editing needed

⚠️ CRITICAL: Splash IMAGE assets can have hardcoded versions (v3.9.22 incident)

The dynamic __APP_VERSION__ injection only covers the <span> text element. Binary image assets used in the splash screen — splashtext-w.webp (the logo image) and splash-video.mp4 (the background video) — can have version numbers burned into the pixels themselves, completely invisible to grep and build-time checks.

Root cause (v3.9.22): splashtext-w.webp was created at v3.4.1 (commit 0588e89 "fix splash screen version text"). The image literally had the text "v3.4.1" rendered in it. This showed alongside the dynamically-injected "v3.9.22", causing a double-version display. The user visually spotted it — it would never appear in any automated check.

Pre-build verification — OCR check splash images for stale versions:

# Check splashtext-w.webp for stale version numbers
python3 -c "
from PIL import Image
img = Image.open('src/renderer/src/assets/splashtext-w.webp')
bg = Image.new('RGB', img.size, (255,255,255))
bg.paste(img, mask=img.split()[3])
bg.save('/tmp/splash-check.png')
" && tesseract /tmp/splash-check.png /tmp/splash-ocr && cat /tmp/splash-ocr.txt
# Should only show branding text like "AtomK Desktop" — NO version numbers like "v3.4.1"

Fix: Regenerate the image WITHOUT a version number at all. The dynamic <span> already shows the version. Use PIL to create a clean text-only logo:

from PIL import Image, ImageDraw, ImageFont
img = Image.new('RGBA', (600, 120), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', 52)
text = "AtomK Desktop"
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
draw.text(((600 - tw) // 2, (120 - th) // 2), text, fill=(255, 255, 255, 255), font=font)
img.save('src/renderer/src/assets/splashtext-w.webp', 'WEBP', quality=95)

Also applies to splash-video.mp4: If the video was generated with a version overlay, ffmpeg frame extraction + OCR should be used to verify. Currently the video was generated by Gemini without a version number, but any future video replacement should be checked.

Cloud Bridge CDP Remote Usage

Once a Desktop App is connected to the Cloud Bridge, the Agent can control the user's browser remotely through the HTTP proxy tunnel:

Attach to a browser tab:

curl -s -X POST -H "Authorization: Bearer Bing2026Cao\$\$\$" http://127.0.0.1:9229/cdp/attach
# Returns: {"ok": true, "targetId": "..."}

Evaluate JavaScript on the remote page:

curl -s -X POST -H "Authorization: Bearer Bing2026Cao\$\$\$" \
  -H "Content-Type: application/json" \
  -d '{"expression":"document.title"}' \
  http://127.0.0.1:9229/cdp/evaluate
# Returns: {"ok": true, "result": {"result": {"type": "string", "value": "Page Title"}}}

Available CDP endpoints (all POST, all proxied through Cloud Bridge):

Endpoint Body Purpose
/cdp/attach Attach debugger to active tab
/cdp/detach Detach debugger
/cdp/evaluate {"expression":"..."} Run JS in page context
/cdp/click {"selector":"..."} Click element by CSS selector
/cdp/type {"selector":"...", "text":"..."} Type text into element
/cdp/navigate {"url":"..."} Navigate to URL
/cdp/scroll {"direction":"up"/"down"} Scroll page
/cdp/send {"method":"...", "params":{}} Raw CDP command
/cdp/start-browser Start CDP browser on Desktop
/cdp/stop-browser Stop CDP browser on Desktop
/cdp/check Check CDP status

Health check (shows connected Desktop App info including version):

curl -s -H "Authorization: Bearer Bing2026Cao\$\$\$" http://127.0.0.1:9229/cloud-bridge/health
# Returns: {"status":"ok","connected_clients":1,"clients":{"<id>":{"version":"3.4.1","extensionConnected":true,...}}}
  • Bridge health_handler NameError (cloud-bridge/server.py): If /health returns 500, check for NameError: name 'cdp_avg_latency' is not defined. The health_handler references cdp_avg_latency as a bare name but only metrics_handler computes it locally. Fix: inline the computation ( round(metrics['cdp_latency_sum'] / metrics['cdp_latency_count'], 3) if metrics['cdp_latency_count'] > 0 else None).

Key Files Quick Reference

File Purpose
package.json Version, scripts, dependencies
electron-builder.yml Build config (NSIS, extraResources, signing)
src/main/chrome-bridge.ts Relay server + CDP browser launch logic
src/main/index.ts Electron main, IPC handler registration
src/preload/index.ts Preload API bridge (exposes IPC to renderer)
src/preload/index.d.ts TypeScript declarations for preload API
src/renderer/src/screens/ChromeBridge/ChromeBridge.tsx Chrome Bridge UI page
resources/chromium/ Bundled Chrome for Testing (v3.0.0+, ~246MB)

⚠️ CRITICAL: sql.js (WASM) Cannot Handle Large state.db

session-cache.ts uses sql.js (WASM SQLite) which calls readFileSync(DB_PATH) to load the entire DB file into JS memory, then copies it into WASM memory. For a typical state.db of 400+ MB, this requires ~900 MB total and will:

  • Crash with OOM on most systems
  • Time out silently (all catch {} blocks are empty — no logging)
  • Return an empty session list — the user sees zero old sessions

The fix

Never use listCachedSessions / syncSessionCache / searchSessionCache in the renderer. Use the better-sqlite3 backed functions instead:

Don't use (sql.js WASM) Use instead (better-sqlite3 native)
window.hermesAPI.listCachedSessions() window.hermesAPI.listSessions()
window.hermesAPI.syncSessionCache() (not needed — listSessions queries DB directly)
window.hermesAPI.searchSessions() (via searchSessionCache) window.hermesAPI.searchSessions() (IPC handler must use sessions.ts)

The main-process index.ts IPC handler for search-sessions must call searchSessions(query, limit) from sessions.ts, NOT searchSessionCache(query) from session-cache.ts. Import searchSessions from ./sessions.

Why better-sqlite3 works

better-sqlite3 is a native C++ addon linked against SQLite. It opens the DB via the OS file API (memory-mapped I/O), so even a 400+ MB DB takes only milliseconds for a SELECT ... LIMIT 50 query. No full-file read into memory.

Return shape differences

listSessions returns extra endedAt and preview fields. Adapt mapping in Sessions.tsx:

const rows = await window.hermesAPI.listSessions(50);
const mapped = rows.map((r) => ({
  id: r.id, title: r.title, startedAt: r.startedAt,
  source: r.source, messageCount: r.messageCount, model: r.model || "",
}));

Session Resume — React State Race Condition

When the user clicks a session in the Sessions page to resume it, the onResumeSession callback must:

  1. Load history from the Hermes DB via window.hermesAPI.getSessionMessages(sessionId) — an async IPC call.
  2. Seed hermesSessionId in Chat component so the first sendMessage includes session_id for the backend.

Common bug: Setting setMessages([]) + setCurrentSessionId(sessionId) together triggers Chat's useEffect([messages]) which clears hermesSessionId to null before the async message load completes → backend creates a brand-new session instead of resuming.

Correct pattern (Layout.tsx):

onResumeSession={async (sessionId: string) => {
  setCurrentSessionId(sessionId);
  if (sessionId) {
    const msgs = await window.hermesAPI.getSessionMessages(sessionId);
    setMessages(msgs.map((m) => ({
      id: `history-${m.id}`,
      role: m.role === "assistant" ? "agent" : m.role,
      content: m.content,
    })));
  } else {
    setMessages([]);
  }
  goTo("chat");
}}

Correct pattern (Chat.tsx useEffect):

// Only clear hermesSessionId when truly starting fresh (no session being resumed)
useEffect(() => {
  if (messages.length === 0 && !sessionId) {
    setHermesSessionId(null);
  }
}, [messages, sessionId]);

// Seed hermesSessionId from sessionId on resume
useEffect(() => {
  if (sessionId && !hermesSessionId) {
    setHermesSessionId(sessionId);
  }
}, [sessionId, hermesSessionId]);

Pitfall — TS2345 role type mismatch: getSessionMessages returns {role: string, ...}, but ChatMessage.role is "user" | "agent". The ternary m.role === "assistant" ? "agent" : m.role still produces string, not the union type. Fix: add as "user" | "agent":

role: (m.role === "assistant" ? "agent" : m.role) as "user" | "agent",

Pitfall — unused import after IPC refactor: When switching an IPC handler from one function to another (e.g. search-sessions from searchSessionCache to searchSessions), the old import in index.ts remains and triggers TS6133. Always clean up the import list after changing which function an IPC handler calls.

Key files: Layout.tsx (onResumeSession callback), Chat.tsx (hermesSessionId sync), sessions.ts (getSessionMessages), session-cache.ts (listCachedSessions/syncSessionCache/searchSessionCache), preload/index.ts (IPC bridge).

IPC channels for session ops: get-session-messages, list-cached-sessions, sync-session-cache, search-sessions, delete-session, prune-empty-sessions, clear-session-messages.

See references/session-resume-pattern.md for full detail, including the sql.js vs better-sqlite3 migration guide and pitfall reference.

⚠️ CRITICAL: IPC Handlers Must Cover All Connection Modes

Every IPC handler in src/main/index.ts that accesses local resources (DB, filesystem, config) must handle all three connection modes: local, remote, and ssh. The pattern is:

ipcMain.handle("some-op", async (_event, ...args) => {
  const conn = getConnectionConfig();
  if (conn.mode === "ssh" && conn.ssh) return sshSomeOp(conn.ssh, ...args);
  if (conn.mode === "remote" && conn.remoteUrl) {
    // Call the Hermes API Server endpoint
    try {
      const baseUrl = conn.remoteUrl.replace(/\/v1\/?\s*$/, "").replace(/\/+$/, "");
      const resp = await fetch(`${baseUrl}/v1/...`, { headers: getRemoteAuthHeader() });
      if (!resp.ok) return fallbackValue;
      const json = await resp.json();
      return mappedResult;
    } catch { return fallbackValue; }
  }
  return localImplementation(...args);  // better-sqlite3 / filesystem
});

Common mistake — returning empty fallbacks on API errors: Adding a new IPC handler that works locally but returns [] / 0 / false when the remote API returns non-200. This causes silent feature loss — the UI renders blank with zero diagnostic info. Instead, throw on API errors so the frontend can display what went wrong.

// ❌ WRONG — 401 returns [], user sees "no sessions" when they have 6000+
if (!resp.ok) return [];

// ✅ CORRECT — propagate error so frontend shows "API returned 401: Unauthorized"
if (!resp.ok) {
  const text = await resp.text().catch(() => "");
  console.error("[IPC-channel] Remote API error:", resp.status, text);
  throw new Error(`Remote API returned ${resp.status}: ${text.slice(0, 200)}`);
}

Frontend must handle IPC errors too — add loadError state + error banner + retry button:

const [loadError, setLoadError] = useState<string | null>(null);
try {
  const rows = await window.hermesAPI.listSessions(50);
  setSessions(rows);
  setLoadError(null);
} catch (err: any) {
  setLoadError(err.message || "Failed to load");
}
// Render: {loadError && <div className="error-banner">{loadError}<button onClick={reload}>Retry</button></div>}

Real incident (v3.8.0): list-sessions IPC returned [] on HTTP 401 (wrong API key). User saw completely empty Sessions page — actually 6296 sessions existed but auth was failing. The catch {} and !resp.ok → return [] swallowed all diagnostic info. After fixing to throw, users see the actual error and can fix their API key.

401 requires Chinese-friendly messages (v3.8.4+): Even after throwing errors, raw HTTP 401 JSON like {"error":{"message":"Invalid API key","type":"invalid_request_error","code":"invalid_api_key"}} is meaningless to non-technical users. Always check resp.status === 401 before the generic throw and provide a friendly Chinese message:

// In IPC handlers (index.ts):
if (!resp.ok) {
  const errText = await resp.text().catch(() => "");
  if (resp.status === 401) throw new Error(`API Key 无效或未设置,请在设置中检查 API Key 是否正确。(HTTP 401: ${errText.slice(0, 120)})`);
  throw new Error(`Remote API returned ${resp.status} (${resp.statusText}): ${errText.slice(0, 200)}`);
}

// In chat streaming (hermes.ts):
if (res.statusCode === 401) {
  finish("API Key 无效或未设置,请在设置中检查 API Key 是否正确。(HTTP 401)");
  return;
}

This applies to ALL remote API endpoints: list-sessions, get-session-messages, delete-session, search-sessions, prune-empty-sessions, and the chat /v1/chat/completions stream.

Root cause of 401 may be Bridge, not Desktop (v3.8.4 finding): When AtomK Bridge handles /v1/sessions locally (reading its own state.db with --key auth) instead of proxying to Hermes backend (8642), Desktop's API key (the Hermes key) fails Bridge's auth check → 401. The fix is on the Bridge side: route ALL /v1/* through hermes_api_proxy_handler so the auth header is passed through to Hermes API Server for validation. See atomk-browser-bridge skill for details.

Secondary mistake — missing endpoint: Always check if the Hermes API Server has a corresponding endpoint. If yes, call it in remote mode. If not, throw an explicit "not available in remote mode" error rather than returning an empty default.

Hermes API Server Session Endpoints (already available, no upgrade needed)

Method Path Purpose
GET /v1/sessions List sessions (limit, offset, source, order_by_last_active query params)
GET /v1/sessions/{id}/messages Get messages (role, limit, offset query params)
DELETE /v1/sessions/{id} Delete a session
POST /v1/sessions/prune Prune old sessions (older_than_days JSON body)

API returns snake_case fields (started_at, message_count) — IPC handlers must map to camelCase (startedAt, messageCount) for the renderer.

Remote-mode search fallback

The API has no /v1/sessions/search endpoint. For search-sessions in remote mode, call GET /v1/sessions?order_by_last_active=true with a high limit, then filter client-side by title/preview matching the query string. This is less precise than FTS5 but functional for moderate session counts.

Remote-mode auth

Use getRemoteAuthHeader() from hermes.ts which returns { Authorization: "Bearer <apiKey>" } for both remote and ssh modes. Import it in index.ts.

Removing UI Elements — Complete Cleanup Required

When removing a UI component that is the only consumer of a state variable, you must remove the entire chain or the build will fail with TS6133:

  1. Remove the JSX/TSX that uses the state
  2. Remove the useState declaration
  3. Remove the interface/type the state depended on
  4. Remove the useEffect or callback that populated the state
  5. Remove the IPC call that fetched the data (e.g., operationGetInstance())
  6. Remove the unused import from session-cache.ts or other modules

Example: Removing the "Use Bound URL" button and "Center-bound Node" section required also removing: EdgeInstanceView interface, edgeInstance state, setEdgeInstance in useEffect, operationGetInstance call, and searchSessionCache import from session-cache.ts.

Removing a Connection Mode (e.g., "local" → remote-only)

When removing a major feature like a connection mode, changes cascade across 8+ files. Missing any one causes TS compilation errors. Trace every reference:

File What changes
src/main/config.ts Remove the mode from ConnectionConfig.mode union type; update default ("local""remote"); map old values in getConnectionConfig()
src/main/hermes.ts Remove LOCAL_API_URL, ensureApiServerConfig, isApiServerReady, startGateway/stopGateway, sendMessageViaCli; simplify sendMessage() to always use API; add no-op stubs for gateway exports if other code imports them
src/main/index.ts Update IPC handler type signatures (e.g. "local" | "remote" | "ssh""remote" | "ssh"); remove handlers for gateway/install/verify; update search-sessions and prune-empty-sessions to call API in remote mode
src/preload/index.d.ts Mirror the type changes from config.ts and index.ts
src/renderer/src/App.tsx Remove install/setup screens from Screen type; remove handleSwitchToLocal and related flow; simplify startup to splash → welcome → main
src/renderer/src/screens/Settings/Settings.tsx Remove Local mode button; remove handleSwitchToLocal(); update useState type
src/renderer/src/screens/Welcome/Welcome.tsx Remove install-related panels (terminal install hint, copy install command, Switch to Local button); show ONLY Atomlisting.com login form (username + password). NO Remote/SSH connection buttons — those are in Settings → Connection → Advanced only. See atomk-desktop-dev skill for Welcome screen architecture.
src/renderer/src/screens/Layout/Layout.tsx Remove verifyWarning/onReinstall/onDismissVerifyWarning props and rendering

Pitfall — stale imports: After switching an IPC handler's implementation (e.g., search-sessions from searchSessionCache to searchSessions), the old import in index.ts becomes unused (TS6133). Always clean up imports.

Pitfall — render blocks referencing removed state: Even after removing the state variable and props interface, the JSX render block that used it may still exist. Search for the variable name in the render return to find and remove the conditional block (e.g., {verifyWarning && onReinstall && ...}).

Layout bridgeError banner pattern (v3.9.2+)

When the startup check finds Bridge unreachable but user is authenticated, pass the error as a bridgeError prop to Layout:

// App.tsx
case "main":
  return <Layout bridgeError={installError} />;
// Layout.tsx
interface LayoutProps {
  bridgeError?: string | null;
}

function Layout({ bridgeError }: LayoutProps = {}) {
  const [dismissedBridgeError, setDismissedBridgeError] = useState(false);
  const showBridgeWarning = bridgeError && !dismissedBridgeError;

  // In render, inside <main className="content">:
  {showBridgeWarning && (
    <div style={{
      display: "flex", alignItems: "center", gap: 8,
      padding: "8px 14px",
      background: "var(--warning-bg, #fef3cd)",
      color: "var(--warning-text, #856404)",
      borderBottom: "1px solid var(--warning-border, #ffc107)",
      fontSize: 13,
    }}>
      <span style={{ flex: 1 }}>⚠️ {bridgeError}</span>
      <button onClick={() => setDismissedBridgeError(true)} style={...}></button>
    </div>
  )}
}

Why not block at Welcome: The user already has valid credentials. Bridge being down is a transient network issue — they should be able to use other features (Sessions, Cookie Manager, Settings) while offline from Bridge. Blocking at Welcome creates a dead-end where login succeeds but they can't proceed.

Architecture note (v3.7.0+): The app is remote-only — no local Hermes Agent installation or gateway management. ConnectionConfig.mode is "remote" | "ssh", never "local". Gateway functions are exported as no-op stubs for backward compatibility.

Hermes API Server

The Hermes Agent exposes an OpenAI-compatible API on port 8642:

Item Value
Base URL http://49.51.249.171:8642/v1
API Key Bing2026Cao$$$
Model ID hermes-agent
Health http://49.51.249.171:8642/health

Any OpenAI-compatible client can connect and use it as a remote Agent endpoint.

⚠️ CLOUD_BRIDGE_SYSTEM_PROMPT — Must Guide Agent to Bridge CDP (v3.9.17+, PR #9)

When Cloud Bridge is connected, Desktop injects a system prompt via src/main/hermes.ts. This prompt MUST forbid browser_navigate/browser_type (server headless) and guide Agent to Bridge CDP + bridge-cdp-agent skill. Fixed in PR #9.

X-Desktop-Id Header for Chat → CDP Routing (v3.9.17+, PR #10)

Chat HTTP requests include X-Desktop-Id: <cloudBridgeState.clientId>. Bridge proxies to Hermes; Agent uses it to find correct CDP slot via /health.

Atomlisting Login Enforcement (v3.9.17+, PR #11)

No atomlisting token → always Welcome login screen. Legacy SSH/remote bypass removed. Ensures WS auth carries username → Bridge auto-registers user dynamically.

Key Pre-Build Fixes (v3.9.17)

  • atomlisting.ts L54: /\\/+$//\/+$/ (regex was matching backslashes instead of forward slashes)
  • BrowserAgent.tsx: TS narrowing fix — redundant activeTab === "sessions" comparisons inside narrowed branches replaced with literals
  • operation-api.ts: FALLBACK_OPERATION_BASE_URL already removed

Bridge Protocol: Desktop ↔ Bridge Server ↔ Server

Three incompatible Bridge servers exist in /home/ubuntu/atomk-page-bridge/:

  • relay.js (v1, port 3928): no auth, extension_connect/desktop_connect messages
  • atomk-bridge (v3, port 9228): no WS auth, register message → registered + client_id
  • cloud-bridge (v4, port 9228): requires auth message within 5s, returns slot_id; self-registers with atomlisting.com

Critical: Desktop must send {type:"auth", key: apiKey} BEFORE {type:"register", info:{...}} when connecting to cloud-bridge v4, otherwise the server closes the connection with 4003 after 5 seconds. Sending auth first is safe for v3/v1 (they ignore unknown message types).

Desktop must also handle {type:"command", action:"update_key"|"drain"|"kick"|"set_status", params:{...}} messages from the server.

See references/bridges-protocol.md for full protocol comparison and schema alignment details.

See references/cdp-proxy-implementation.md for the full-stack pattern for adding a new persistent user setting — config persistence → main-process state → IPC handler → preload API → i18n (6 locales) → Settings UI.

Cloud Bridge → AtomK Bridge v3.0 (v3.2.2+)

Current architecture (AtomK Bridge v3.0): Single-port aiohttp Application on 9228 handles everything:

  • /ws — WebSocket tunnel (Desktop App ↔ local Relay)
  • /cdp/* — CDP passthrough
  • /v1/models, /v1/chat/completions — proxied to local Hermes API server (:8642)
  • /v1/sessions, /v1/sessions/{id}/messages — direct SQLite read from ~/.hermes/state.db
  • /api/sessions — direct SQLite read from ~/.hermes/state.db
  • /api/* — proxied to local WebUI (:8787)
  • GET /* — static files + SPA fallback (WebUI)

Desktop App connects WebSocket to ws://<host>:9228/ws and sends REST API calls to http://<host>:9228/v1/*. Same URL, same port. No port mapping needed.

Historical Cloud Bridge (pre-v3.0): Used two ports — 9228 (WS) + 9229 (HTTP proxy), which caused firewall/security-group issues when 9229 wasn't externally accessible.

Tencent Cloud security group: Port 9228 TCP must be added to inbound rules (source 0.0.0.0/0). The default security group does NOT include 9228.

⚠️ Port 80 occupied by Docker: Server port 80 is taken by Docker container wordpress-atomk-caddy-1 (Caddy reverse proxy for us1.atomk.cn). nginx is installed but NOT running — systemctl start nginx will fail with "Address already in use". Do NOT attempt to use nginx on port 80 as a WebSocket reverse proxy. Instead, open the needed port directly in Tencent Cloud security group.

⚠️ websockets v15 compatibility: Server was rewritten for websockets v15.0.1 API — the serve() function signature and ServerConnection type differ from v10/v11. If you reinstall and get an older version, the server will crash on startup.

Full request flow:

  1. Desktop App connects WebSocket to ws://49.51.249.171:9228 with API key
  2. Agent sends HTTP request to http://127.0.0.1:9229/<path> with Authorization: Bearer <key> header
  3. Cloud Bridge Server forwards the HTTP request through the WebSocket tunnel to the Desktop App
  4. Desktop App forwards to its local Relay at http://localhost:3928/<path>
  5. Response flows back: Relay → Desktop App → WebSocket → Cloud Bridge Server → HTTP response → Agent

⚠️ CRITICAL: Cloud Bridge WS Reconnect Loop Bug (v3.2.2v3.4.2 Post-Mortem)

Symptom: Desktop App connects to Cloud Bridge → registers successfully → immediately opens a SECOND WebSocket → server rejects with 4001 (single-connection lock) → client treats 4001 as fatal → stops retrying entirely → 20-minute silence.

Root cause: connectCloudBridge() calls oldWs.close() then immediately new WebSocket(). But close() is asynchronous — it only initiates the TCP close handshake, doesn't wait for it to complete. The new connection opens before the old one fully closes. Server sees two simultaneous connections from the same client_id, rejects the new one (4001), and the cascade begins.

The fix (v3.4.3) — three-part defense in chrome-bridge.ts:

1. cloudBridgeConnecting lock — prevents concurrent connectCloudBridge calls

let cloudBridgeConnecting = false

function connectCloudBridge(config: CloudBridgeConfig): void {
  if (cloudBridgeConnecting) {
    console.log('[CloudBridge] Connection already in progress, skipping')
    return
  }
  cloudBridgeConnecting = true
  // ... rest of connection logic
}

2. Wait for old WS to truly close before opening new one

if (cloudBridgeWs) {
  // Set intentional close BEFORE calling close()
  cloudBridgeIntentionalClose = true
  
  const oldWs = cloudBridgeWs
  cloudBridgeWs = null
  
  // Wait for the close event with a safety timeout
  await new Promise<void>((resolve) => {
    const timeout = setTimeout(() => {
      console.warn('[CloudBridge] Old WS close timeout, proceeding anyway')
      resolve()
    }, 2000)
    
    oldWs.once('close', () => {
      clearTimeout(timeout)
      resolve()
    })
    
    oldWs.close()
  })
  
  cloudBridgeIntentionalClose = false
}

// NOW safe to open new connection
const ws = new WebSocket(url)

3. Don't reconnect on 4001 (server rejection)

ws.on('close', (code: number, reason: Buffer) => {
  if (code === 4001) {
    // Server rejected — duplicate connection. Don't retry.
    console.error('[CloudBridge] Rejected by server (4001), clearing config')
    cloudBridgeConfig = null  // prevents reconnect loop
    cloudBridgeConnecting = false
    return
  }
  // ... normal reconnect logic
})

4. WS reference safety — compare identity, not truthiness

In on('message'), on('close'), on('error') callbacks, use cloudBridgeWs === ws to verify the callback belongs to the CURRENT connection, not a stale one:

ws.on('close', (code: number) => {
  if (cloudBridgeWs !== ws) return  // stale callback, ignore
  // ... handle close for current connection
})

WS server URL default: Changed from hardcoded ws://49.51.249.171:9228 to empty string "" — user must enter the server URL manually. This prevents builds from auto-connecting to a specific server.

Key lesson: WebSocket close() is asynchronous. Never assume the connection is gone immediately after calling close(). Always wait for the close event or use a timeout before opening a replacement connection.

electron-log Integration (v3.4.3+)

Setup

cd /home/ubuntu/hermes-desktop
npm install electron-log

Main process initialization (src/main/index.ts)

import log from 'electron-log/main'

// Initialize electron-log — captures console.log/warn/error → file
log.initialize();

// Override console so ALL existing console.log/warn/error across all modules go to log file
Object.assign(console, log.functions);

// Configure log rotation: max 5MB per file, keep 3 old files
log.transports.file.maxSize = 5 * 1024 * 1024;  // 5MB
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}.{ms}] [{level}] {text}';

// Log file location on Windows: %APPDATA%\atomk-desktop\logs\main.log

Important: log.initialize() and Object.assign(console, log.functions) must run before other modules use console. Place them at the TOP of index.ts, right after imports. Since all modules share the same global console object, once overridden, ALL console.log/warn/error calls from any imported module (chrome-bridge.ts, cronjobs.ts, etc.) automatically go to the log file — no need to replace them individually.

electron-log v5 API gotchas

  • Import: import log from 'electron-log/main' (NOT 'electron-log' — v5 uses subpath exports)
  • log.transports.file.getFile() returns a LogFile object — it does NOT have a .read() method
  • To read logs programmatically, use fs.readFileSync(log.transports.file.getFile().path) directly
  • log.transports.file.readAllLogs() does NOT exist in v5
  • Log file path: log.transports.file.getFile().path (e.g. C:\Users\X\AppData\Roaming\atomk-desktop\logs\main.log)

IPC handlers for log viewer (src/main/index.ts)

ipcMain.handle("app:get-logs", async (_event, opts?: { tail?: number }) => {
  const tail = opts?.tail ?? 200;
  try {
    const logPath = log.transports.file.getFile().path;
    if (!fs.existsSync(logPath)) return { path: logPath, lines: [], total: 0 };
    const content = fs.readFileSync(logPath, 'utf-8');
    const allLines = content.split('\n').filter(Boolean);
    return { path: logPath, lines: allLines.slice(-tail), total: allLines.length };
  } catch (err: any) {
    return { path: '', lines: [`Error reading log: ${err.message}`], total: 1 };
  }
});

ipcMain.handle("app:get-log-path", () => log.transports.file.getFile().path);

ipcMain.handle("app:clear-logs", async () => {
  try {
    const logPath = log.transports.file.getFile().path;
    if (fs.existsSync(logPath)) fs.writeFileSync(logPath, '');
    return true;
  } catch { return false; }
});

Preload API (src/preload/index.ts + index.d.ts)

// index.ts
getLogs: (opts?: { tail?: number }) => ipcRenderer.invoke("app:get-logs", opts),
getLogPath: () => ipcRenderer.invoke("app:get-log-path"),
clearLogs: () => ipcRenderer.invoke("app:clear-logs"),

// index.d.ts (add to HermesAPI interface)
getLogs: (opts?: { tail?: number }) => Promise<{ path: string; lines: string[]; total: number }>;
getLogPath: () => Promise<string>;
clearLogs: () => Promise<boolean>;

UI Log Viewer (ChromeBridge.tsx)

Added a "Logs" card below the Cloud Bridge card with:

  • Dark terminal-style viewer (background: #0f172a, monospace font, 14px)
  • Auto-refresh every 5 seconds (via setInterval)
  • Shows last 300 lines, with total line count
  • Color-coded lines: errors in #ef4444, warnings in #eab308, timestamps in #64748b
  • "Clear" button (red) to wipe the log file
  • Path display so user can find the file manually
  • Scrollable with auto-scroll-to-bottom on refresh

Build & upload notes

  • npx electron-vite build may hang in terminal — use NODE_ENV=production node -e "require('electron-vite').build()" instead (avoids terminal tool's "long-running process" misdetection)
  • COS upload requires env vars from /home/ubuntu/.hermes/custom_services.env — source it first: bash -c 'source /home/ubuntu/.hermes/custom_services.env && python3 ...'
  • Git push: use git push origin main — the Gitea remote is named origin in AtomK-Desktop