app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
← Commit history
Publish 1.9.183
6 files changed
+994−994
dist/linux/adom-desktop⋯ 1 unchanged line ⋯
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop", - "version": "1.9.182", + "version": "1.9.183", "type": "app", "brief": "Adom Desktop is a small operating system for AI on your Windows PC: it gives an AI real hands on your native desktop, including your signed-in browser, KiCad, Fusion 360, the shell, files, windows and screenshots. It provisions its own Node and Python so bridges run without touching your setup, and it talks straight to your machine. It ships with Hydrogen Desktop and installs standalone too, so if you drive your PC with Claude Desktop or another AI, this is what gives that AI hands.", "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.",
page.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-desktop",- "version": "1.9.182",+ "version": "1.9.183", "type": "app", "description": "Install this package in a cloud or Hydrogen Desktop container, not on the PC. It delivers two things: the Linux adom-desktop CLI (linked onto PATH) and the Claude skill set that teaches an AI every desktop verb (shell, files, screenshots, windows, notifications, KiCad, Fusion 360, browser automation). The CLI drives the Adom Desktop app running on your Windows PC over the relay; the Windows app itself is the separate installer in the Download box, not part of this package. Run adom-wiki pkg update to keep the CLI and skills current.", "keywords": [
skills/adom-desktop-direct-api/SKILL.md+532−532@@ -1,532 +1,532 @@-name: adom-desktop-direct-api -description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind). - -# Direct HTTP API — discover via `~/.adom/direct-api-port` - -> **Port is no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs. - -Old: hardcoded `:47200` - -Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI itself (loopback-only `127.0.0.1` bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape — including every `_hint` field — that `adom-desktop <verb>` returns from the CLI. - -This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker callers should continue using the CLI or the WS proxy — those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't. - -Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host. - -## When to use this (vs. the CLI) - -| You are… | Use | -|---|---| -| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. | -| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in HD's container. The verb returns identical JSON to the cross-machine path. | -| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. | -| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). | -| A sibling app needing `pull_file`, `send_files`, or `shell_execute` | Spawn the CLI binary. The direct API refuses these with `errorCode:"cli_required"`. The CLI itself handles the fallback automatically when called. | - -### CLI auto-route (v1.8.27+) - -When the `adom-desktop` CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in `/tmp/adom-direct-probe.json`): - -- **Discovery file (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI writes the file at bind time and removes it at graceful shutdown. -- **Probe order** (first 200 OK wins): `127.0.0.1:47200`, `host.docker.internal:47200`, `localhost:47200` -- **Fallback scan (v1.8.31+):** if the discovery file is missing/stale AND the default candidates all fail, the probe scans `127.0.0.1:47201`..`47209`. Cheap — each closed port refuses connection in <5 ms on Windows loopback. -- **Connect timeout**: 400 ms per candidate — fails fast when nothing's there -- **Override**: `$ADOM_DIRECT_URL=off` forces relay-only; `$ADOM_DIRECT_URL=http://…:47209` skips the probe entirely and uses that URL; `auto` (default) does the probe - -The `ping` verb response now includes a `transport` field — `direct-http` or `relay-ws` — so you can confirm which path served the call. `pull_file`, `send_files`, and `shell_execute` always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable. - -### Port-conflict auto-recovery (v1.8.31+, ephemeral fallback in v1.8.89+) - -The GUI never treats any individual port as fatal. Bind walk: - -1. **Probe first**: connect to the candidate port and send a `GET /health`. If anything responds within 1 s, a LIVE process owns the port — skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between us and the other listener). -2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.) -3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip. -4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it). -5. **Write the chosen port** to `~/.adom/direct-api-port` so callers don't have to scan. -6. **Report the bound address** in `/status.endpoint` — never a hardcoded constant. - -This handles all five practical cases: -- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port. -- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port. -- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port. -- **Empty port** — bind cleanly. -- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins. - -### Graceful shutdown (v1.8.31+) - -When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` fires `direct_api::shutdown()` which: - -1. Sends a `oneshot` signal to the axum server -2. `with_graceful_shutdown` stops accepting new connections and drains in-flight ones (~tens of ms) -3. The TCP listener drops, releasing the port back to the OS *immediately* -4. The discovery file is removed so callers don't connect to a port that's about to close - -This prevents zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense. - -## Endpoints - -### `GET /health` - -Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback. - -```bash -curl -sf http://127.0.0.1:47200/health -# → {"ok":true,"service":"adom-desktop"} -``` - -Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI does. - -### `GET /status` - -Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list. - -```bash -curl -sf http://127.0.0.1:47200/status -``` - -```json -{ - "ok": true, - "service": "adom-desktop", - "version": "1.8.25", - "schema": 1, - "transport": "direct-http", - "endpoint": "http://127.0.0.1:47200", - "directApi": { - "cliRequired": ["pull_file", "send_files", "shell_execute"], - "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-desktop` CLI binary for those.", - "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}", - "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`." - }, - "wsl": { - "wslProcessCount": 18, - "wedged": false, - "_hint": null - }, - "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog." -} -``` - -The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional). - -The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again. - -### `GET /commands` (v1.8.156+) - -Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command". - -```bash -curl -sf http://127.0.0.1:47200/commands -``` - -```json -{ - "ok": true, - "desktop": { - "app": "desktop", - "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."], - "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema." - }, - "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}], - "cliRequired": ["pull_file","send_files","shell_execute"], - "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API." -} -``` - -Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge. - -### `POST /command` - -Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command): - -```json -{ - "command": "server_add", - "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true } -} -``` - -#### Full verb surface + cross-AD (v1.9.84) - -A caller (a **bridge** especially) reaches **every dispatchable verb** here, not just `desktop_*` — same dispatcher as the CLI/WS path, identical JSON back (`_hint`/`errorCode`/`statusVerb`). `GET /commands` lists the full set to capability-probe. - -- **`app` is optional** — inferred (`kicad_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hd, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override. -- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`. -- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `errorCode:attended_unresolved` (not built yet; use a concrete clientName). -- **`X-Adom-Bridge-Token` header = ATTRIBUTION only.** A bridge MAY send its spawn-time `ADOM_BRIDGE_TOKEN` env value; AD then badges the call "bridge" in the Activity Log. It is **not** an approval gate — a bridge is trusted-by-install (127.0.0.1-bound; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → fine. The full surface (incl. `write_file`/`run_script`) runs ungated for any local caller; only the remote relay origin is approval-gated. - -Example — a bridge fires a toast to the user's laptop while running on a VM: -```bash -curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \ - -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \ - -d '{"command":"notify_user","target":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}' -``` - -Plain single-AD envelope (unchanged, `app` still honored when provided): - -```json -{ - "app": "desktop", - "command": "server_add", - "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true } -} -``` - -Response is the verb's normal payload (200 OK), e.g.: - -```json -{ - "ok": true, - "success": true, - "name": "hydrogen-workspace", - "url": "ws://localhost:8765", - "id": "...", - "connected": true, - "created": true, - "_hint": "Server registered. Relay commands for this container now route through adom-desktop. Use server_list to see all connections." -} -``` - -#### Error responses - -| HTTP | When | Body shape | -|---|---|---| -| `400 Bad Request` | Envelope missing `app` or `command` | `{ok:false, error, _hint}` | -| `412 Precondition Failed` | Verb is in `cliRequired` list | `{ok:false, errorCode:"cli_required", _hint}` | -| `500 Internal Server Error` | Handler dropped the response channel (bug) | `{ok:false, errorCode:"handler_silent", _hint}` | -| `504 Gateway Timeout` | Handler didn't respond within the per-verb timeout (see below) | `{ok:false, errorCode:"timeout", _hint}` | - -Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on. - -#### Per-verb timeouts (v1.8.31+) - -The 504 timeout is **per-verb**, not a hardcoded 120s ceiling: - -| Verb category | Default timeout | -|---|---| -| `walk_cloud_tree`, `search_cloud_files` | **620 s** (10+ min — cloud trees can be hundreds of folders @ ~1 fps) | -| `export_step`/`iges`/`sat`/`stl`/`3mf`/`usdz`/`obj`/`f3d`/`fbx`/`skp`/`source`/`eagle_source` | 320 s | -| `export_gerbers` | 200 s | -| `export_dxf`/`dwg` | 140 s | -| `drc`, `erc` | 180 s | -| `bridge_install`, `fusion_start` | 300 s | -| Everything else | 120 s | - -Override per-call by passing `args.timeout` (seconds), clamped to a 1800 s ceiling: - -```json -{ - "app": "fusion", - "command": "search_cloud_files", - "args": { - "query": "cosmiic", - "recursive": true, - "maxFolders": 1000, - "searchTimeout": 1500, - "timeout": 1700 - } -} -``` - -The CLI dispatcher honors `args.timeout` the same way, so the two paths stay symmetric. - -## Update endpoints (v1.8.134+) — HD-driven embedded auto-update - -AD owns its own updater (fetch → SHA-verify → stage). HD does **not** download or -install AD's bytes — it reads AD's update state and, at a safe moment, "clicks -AD's Update button" via `POST /update/apply`. The contract is intentionally tiny -and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver): -`{ update_status, apply, busy }`. - -### `GET /update/status` - -```bash -curl -sf http://127.0.0.1:47200/update/status -``` - -```json -{ - "current_version": "1.8.133", - "available_version": "1.8.134", - "update_ready": true, - "state": "staged", - "busy": false -} -``` - -| Field | Meaning | -|---|---| -| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. | -| `available_version` | Latest version AD knows about from its manifest (`null` until the first check). May equal `current_version` when on latest. | -| `update_ready` | `true` **only** when a newer installer is already downloaded **+ SHA256-verified + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. | -| `state` | One of `idle \| checking \| downloading \| staged \| applying`. | -| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **HD should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) | - -`state` machine: `idle` → `checking` (a manifest check is running) → `downloading` -(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`) -→ `applying` (apply triggered; AD about to restart). A failed check never un-stages a -previously-staged update. - -### `POST /update/apply` - -Applies the already-staged update — the programmatic equivalent of the user clicking -"Update". No request body. - -```bash -curl -sf -X POST http://127.0.0.1:47200/update/apply -``` - -```json -{ "ok": true, "applying": true } -``` - -- **Only ever applies what AD itself staged + verified.** If nothing is staged, returns - HTTP 409 `{ "ok": false, "applying": false, "error": "no update staged …", "_hint": … }`. - Call `POST /update/check` first and poll `/update/status` until `update_ready=true`. -- **Embedded-aware:** no modal / blocking UI. AD returns this JSON **promptly** (before it - restarts), runs the staged installer, then exits cleanly ~1.5 s later. -- **Comes back in embedded mode automatically.** AD's embedded marker on disk persists - across the restart (the exit hook only drains the direct-API socket — it doesn't delete - the marker), so the relaunched AD re-enters embedded mode via the marker + the HD-liveness - probe. HD's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it - idempotently. - -#### Restart handshake — HD MUST suspend respawn for the apply window - -Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on -the running image means the file replacement only succeeds once AD has fully exited. **If HD's -supervisor respawns AD during the install window, it re-locks the exe and the replacement -silently no-ops** (AD comes back on the OLD version). So: - -1. `GET /update/status` → wait for `update_ready == true && busy == false`. -2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s - health-respawn) for ~60 s. -3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL). - AD re-embeds via its marker. -4. HD polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with - `current_version ==` the new version, resume normal supervision + re-adopt - (`POST /embedded/enter`). - -HD initiated the apply, so HD already knows the restart is intentional — treat AD's -disappearance in this window as expected, not a crash. - -### `POST /update/check` - -Force a manifest check now. If a newer version is found, AD stages it **in the background**; -this call returns the `/update/status` shape promptly (poll it until `staged`). No request body. - -```bash -curl -sf -X POST http://127.0.0.1:47200/update/check -``` - -Returns the same JSON shape as `GET /update/status`. - -### Standalone vs embedded - -- **Standalone AD:** the normal user-facing "Update available → Update" footer button + - flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner; - `off` disables checks (and staging). -- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for HD) - but **never auto-applies** — HD owns the apply timing via `POST /update/apply`. The - embedded apply path never pops its own restart UI. - -## What the user sees in the GUI Activity Log - -Each `POST /command` call shows up in adom-desktop's Activity Log panel with a `direct:47200` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hd`, etc.). This is visually distinct from the `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`. - -At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), the user sees a corresponding error entry — no silent failure. - -## Trust model - -- **Loopback-only bind.** The listener binds `127.0.0.1`, never `0.0.0.0`. Off-machine HTTP requests can't reach it. -- **No token required.** Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use. -- **Same verb surface as the WS path.** Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls — the dispatcher is the same code. - -## Reference integration: Hydrogen Desktop startup - -This is the canonical pattern HD uses to register its container relay with adom-desktop at boot. - -### Rust (HD's `src-tauri/src/lib.rs` or equivalent) - -```rust -use serde_json::json; -use std::time::Duration; - -const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health"; -const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command"; - -async fn register_with_adom_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> { - // 1. Cheap probe — is adom-desktop running and listening? - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(2)) - .build() - .map_err(|e| format!("client init: {e}"))?; - - let healthy = client - .get(ADOM_HEALTH) - .send() - .await - .ok() - .filter(|r| r.status().is_success()) - .is_some(); - - if !healthy { - // adom-desktop not running. Either tell the user to launch it, - // or shell out to the CLI as the legacy fallback. Don't block - // HD startup. - log::warn!("adom-desktop not reachable on 127.0.0.1:47200 — relay will not be auto-registered"); - return Ok(()); - } - - // 2. POST the server_add command. - let resp = client - .post(ADOM_COMMAND) - .timeout(Duration::from_secs(10)) - .json(&json!({ - "app": "desktop", - "command": "server_add", - "args": { - "name": workspace_name, - "url": relay_url, - "autoConnect": true, - } - })) - .send() - .await - .map_err(|e| format!("POST /command: {e}"))?; - - let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?; - - if body.get("ok").and_then(|v| v.as_bool()) == Some(true) { - log::info!( - "adom-desktop registered: name={workspace_name}, url={relay_url}, _hint={}", - body.get("_hint").and_then(|v| v.as_str()).unwrap_or("") - ); - Ok(()) - } else { - Err(format!("adom-desktop refused: {body}")) - } -} -``` - -### TypeScript (HD's frontend, if it ever calls directly) - -```typescript -async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> { - // probe first - const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null); - if (!health?.ok) return false; - - const resp = await fetch('http://127.0.0.1:47200/command', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - signal: AbortSignal.timeout(10_000), - body: JSON.stringify({ - app: 'desktop', - command: 'server_add', - args: { name, url, autoConnect: true }, - }), - }); - const body = await resp.json(); - return body.ok === true; -} -``` - -## Cleanup at shutdown - -Mirror the registration with a `server_remove`: - -```bash -curl -X POST http://127.0.0.1:47200/command \ - -H 'Content-Type: application/json' \ - -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}' -``` - -If adom-desktop has already exited, the connection refuses — that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort. - -## Embedded-mode integration (HD bundling AD, v1.8.42+) - -When HD bundles AD, the direct API is the channel for HD's tray menu items and runtime control. HD spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls: - -| HD action | direct-API envelope | -|---|---| -| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` | -| Hide AD's window again | `{app:"desktop", command:"window_hide"}` | -| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` | -| HD's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` | -| HD sign-out propagation | `{app:"desktop", command:"logout"}` | -| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` | -| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` | - -`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for HD's tray to know whether AD already booted embedded or it's running standalone. - -```typescript -// HD's tray-click handler — example -async function openAdomDesktop() { - await fetch('http://127.0.0.1:47200/command', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ app: 'desktop', command: 'window_show' }), - }); -} -``` - -Note: HD typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an HD setting like "Auto-approve AD shell commands ON/OFF"). - -## What about verbs not in the directApi list? - -The `cliRequired` array in `GET /status` lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via `adom-desktop <verb>` it works via direct POST. - -### Mapping CLI verb → direct-API envelope - -**The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be. - -**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.) - -| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) | -|---|---|---| -| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` | -| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` | -| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) | -| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` | -| `hd` | **stripped** — drop the `hd_` prefix | `hd_status` → `{app:"hd", command:"status"}` | -| `dynamic` | full verb name with third-party bridge prefix preserved | (dispatcher routes by prefix) | - -Common direct-safe verb groups: - -| Namespace | Examples | Notes | -|---|---|---| -| `desktop` | `server_add`, `server_remove`, `server_list`, `bridge_list`, `bridge_install`, `bridge_pause`, `bridge_resume`, `desktop_list_windows`, `desktop_screenshot_window`, `desktop_open_url`, `set_shell_auto_approve` | All sync, no prefix stripping. | -| `hd` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. | -| `kicad` | `list_versions`, `open_board`, `open_schematic`, `run_drc`, `lint_board`, `install_symbol`, ... | Multi-version-aware. | -| `fusion360` | `launch`, `export_step`, `walk_cloud_tree`, `window_info`, `get_app_state`, ... | 50+ verbs. | -| `browser` | `browser_open_window`, `browser_navigate`, `browser_screenshot`, `browser_eval`, `browser_click`, `browser_record_start` | Per-tab. **Keep the prefix.** | - -## Failure modes & retry - -The direct API doesn't auto-retry. Sibling apps should: - -1. **Probe `/health` before any `/command`** so a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch. -2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `bridge_install` can be 30 s for a large zip. Pick the timeout based on the verb you're calling. -3. **Treat 504 timeouts as "unknown state, ask later"** rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge. -4. **Read the `_hint` and `errorCode` fields on every error response.** They're machine-friendly: `cli_required`, `timeout`, `handler_silent`, plus whatever the verb's handler emits (`bridge_not_found`, `binary_missing`, etc.). - -## Version skew - -If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; everything that works via the direct API also works via the CLI, just slower. - -## Why a separate port from the WS relay's 8766? - -- **Different transport, different trust model.** 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only. -- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up. -- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. They don't fight for the same port or auth scheme. - -## See also - -- [`README.md`](../README.md) — feature list -- [`skills/SKILL.md`](SKILL.md) — full verb catalog (the entries Docker callers read) -- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation -- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference +---+name: adom-desktop-direct-api+description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind).+---++# Direct HTTP API — discover via `~/.adom/direct-api-port`++> **Port is no longer fixed.** As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see [PORTS.md](PORTS.md) for the full story). **Sibling apps MUST read the discovery file** `%USERPROFILE%\.adom\direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:55272`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.++Old: hardcoded `:47200`++Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI itself (loopback-only `127.0.0.1` bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape — including every `_hint` field — that `adom-desktop <verb>` returns from the CLI.++This skill is for **sibling-app authors** (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker callers should continue using the CLI or the WS proxy — those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't.++Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.++## When to use this (vs. the CLI)++| You are… | Use |+|---|---|+| A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like `server_add`, `bridge_list`, `hd_status`, `desktop_screenshot_window` | **Direct API.** One HTTP round-trip, ~5 ms, no process spawn. |+| Inside Hydrogen Desktop's local Docker container, using the `adom-desktop` CLI | **Just use the CLI.** As of v1.8.27 it auto-detects the direct API at `host.docker.internal:47200` on every invocation and routes there transparently. Zero config in HD's container. The verb returns identical JSON to the cross-machine path. |+| On the Windows host, running the `adom-desktop.exe` CLI directly | **Same — just use the CLI.** Auto-detects `127.0.0.1:47200` and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path. |+| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier). |+| A sibling app needing `pull_file`, `send_files`, or `shell_execute` | Spawn the CLI binary. The direct API refuses these with `errorCode:"cli_required"`. The CLI itself handles the fallback automatically when called. |++### CLI auto-route (v1.8.27+)++When the `adom-desktop` CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in `/tmp/adom-direct-probe.json`):++- **Discovery file (v1.8.31+, fastest path):** `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Contents are `host:port` (e.g. `127.0.0.1:47201`) and the CLI tries this first if present. The GUI writes the file at bind time and removes it at graceful shutdown.+- **Probe order** (first 200 OK wins): `127.0.0.1:47200`, `host.docker.internal:47200`, `localhost:47200`+- **Fallback scan (v1.8.31+):** if the discovery file is missing/stale AND the default candidates all fail, the probe scans `127.0.0.1:47201`..`47209`. Cheap — each closed port refuses connection in <5 ms on Windows loopback.+- **Connect timeout**: 400 ms per candidate — fails fast when nothing's there+- **Override**: `$ADOM_DIRECT_URL=off` forces relay-only; `$ADOM_DIRECT_URL=http://…:47209` skips the probe entirely and uses that URL; `auto` (default) does the probe++The `ping` verb response now includes a `transport` field — `direct-http` or `relay-ws` — so you can confirm which path served the call. `pull_file`, `send_files`, and `shell_execute` always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable.++### Port-conflict auto-recovery (v1.8.31+, ephemeral fallback in v1.8.89+)++The GUI never treats any individual port as fatal. Bind walk:++1. **Probe first**: connect to the candidate port and send a `GET /health`. If anything responds within 1 s, a LIVE process owns the port — skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between us and the other listener).+2. **Reuse-bind** with `SO_REUSEADDR` set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)+3. **Walk the range** `47200`..`47209` on three categorical errors: `AddrInUse` (10048), `PermissionDenied` (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.+4. **(NEW in v1.8.89) Ephemeral fallback**: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see [PORTS.md](PORTS.md) for why), bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).+5. **Write the chosen port** to `~/.adom/direct-api-port` so callers don't have to scan.+6. **Report the bound address** in `/status.endpoint` — never a hardcoded constant.++This handles all five practical cases:+- **Zombie from v1.8.31+ adom-desktop** (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.+- **Zombie from older code OR force-killed third-party** (exclusive bind) — WSAEACCES on bind; walk to next port.+- **Live owner** (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.+- **Empty port** — bind cleanly.+- **HNS reserved the whole range** (v1.8.89+) — ephemeral fallback wins.++### Graceful shutdown (v1.8.31+)++When the GUI exits via the tray "Quit" menu (or any path that calls `app.exit(0)`), Tauri's `RunEvent::Exit` fires `direct_api::shutdown()` which:++1. Sends a `oneshot` signal to the axum server+2. `with_graceful_shutdown` stops accepting new connections and drains in-flight ones (~tens of ms)+3. The TCP listener drops, releasing the port back to the OS *immediately*+4. The discovery file is removed so callers don't connect to a port that's about to close++This prevents zombie sockets on **clean exits**. For **force-kill** scenarios (`taskkill /F`, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense.++## Endpoints++### `GET /health`++Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback.++```bash+curl -sf http://127.0.0.1:47200/health+# → {"ok":true,"service":"adom-desktop"}+```++Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's `serve` mode does NOT bind 47200 — only the GUI does.++### `GET /status`++Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list.++```bash+curl -sf http://127.0.0.1:47200/status+```++```json+{+ "ok": true,+ "service": "adom-desktop",+ "version": "1.8.25",+ "schema": 1,+ "transport": "direct-http",+ "endpoint": "http://127.0.0.1:47200",+ "directApi": {+ "cliRequired": ["pull_file", "send_files", "shell_execute"],+ "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-desktop` CLI binary for those.",+ "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",+ "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."+ },+ "wsl": {+ "wslProcessCount": 18,+ "wedged": false,+ "_hint": null+ },+ "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."+}+```++The **`wsl` block (v1.8.157+)** surfaces WSL health: `wslProcessCount` is a cheap Toolhelp snapshot of live `wsl.exe`; `wedged:true` (at/above 50) warns that orphaned/hung clients are piling up. When wedged, `_hint` points to `wsl_recover`. If a `wsl_exec`/`run_script` call returns `errorCode:"wsl_wedged"` (refuse-cap at 80), the machine is wedging — call `wsl_recover` (dry-run, then `{confirm:true}`) before retrying, rather than spawning more doomed clients. `wsl_recover` is reachable via `POST /command {app:"desktop", command:"wsl_recover"}` (the `desktop_` prefix is optional).++The `schema` field is the contract version. v1 is the only one shipped. If `schema > 1` ever appears, expect a breaking change in the envelope/response shape and read this skill again.++### `GET /commands` (v1.8.156+)++Verb discovery — what `{app, command}` can a `/command` caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".++```bash+curl -sf http://127.0.0.1:47200/commands+```++```json+{+ "ok": true,+ "desktop": {+ "app": "desktop",+ "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],+ "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."+ },+ "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],+ "cliRequired": ["pull_file","send_files","shell_execute"],+ "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."+}+```++Pair it with the per-verb schema hints: call a verb with missing args and its `_hint` returns `required`/`optional`/`example`. Between `/commands` (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.++### `POST /command`++Dispatches a verb. Body envelope (`app` is OPTIONAL as of v1.9.84 — inferred from the command):++```json+{+ "command": "server_add",+ "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }+}+```++#### Full verb surface + cross-AD (v1.9.84)++A caller (a **bridge** especially) reaches **every dispatchable verb** here, not just `desktop_*` — same dispatcher as the CLI/WS path, identical JSON back (`_hint`/`errorCode`/`statusVerb`). `GET /commands` lists the full set to capability-probe.++- **`app` is optional** — inferred (`kicad_*`→kicad, `fusion_*`→fusion360, `browser_*`→browser, `hd_*`→hd, `aps_*`→aps, any installed bridge's prefix→dynamic, else desktop). Pass `app` explicitly to override.+- **Top-level verbs now reachable:** `notify_user` (toast → returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER ADs on the relay), `ping` (this AD's liveness+version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status`.+- **`target` (cross-AD):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` → `errorCode:attended_unresolved` (not built yet; use a concrete clientName).+- **`X-Adom-Bridge-Token` header = ATTRIBUTION only.** A bridge MAY send its spawn-time `ADOM_BRIDGE_TOKEN` env value; AD then badges the call "bridge" in the Activity Log. It is **not** an approval gate — a bridge is trusted-by-install (127.0.0.1-bound; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → fine. The full surface (incl. `write_file`/`run_script`) runs ungated for any local caller; only the remote relay origin is approval-gated.++Example — a bridge fires a toast to the user's laptop while running on a VM:+```bash+curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \+ -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \+ -d '{"command":"notify_user","target":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'+```++Plain single-AD envelope (unchanged, `app` still honored when provided):++```json+{+ "app": "desktop",+ "command": "server_add",+ "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }+}+```++Response is the verb's normal payload (200 OK), e.g.:++```json+{+ "ok": true,+ "success": true,+ "name": "hydrogen-workspace",+ "url": "ws://localhost:8765",+ "id": "...",+ "connected": true,+ "created": true,+ "_hint": "Server registered. Relay commands for this container now route through adom-desktop. Use server_list to see all connections."+}+```++#### Error responses++| HTTP | When | Body shape |+|---|---|---|+| `400 Bad Request` | Envelope missing `app` or `command` | `{ok:false, error, _hint}` |+| `412 Precondition Failed` | Verb is in `cliRequired` list | `{ok:false, errorCode:"cli_required", _hint}` |+| `500 Internal Server Error` | Handler dropped the response channel (bug) | `{ok:false, errorCode:"handler_silent", _hint}` |+| `504 Gateway Timeout` | Handler didn't respond within the per-verb timeout (see below) | `{ok:false, errorCode:"timeout", _hint}` |++Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on.++#### Per-verb timeouts (v1.8.31+)++The 504 timeout is **per-verb**, not a hardcoded 120s ceiling:++| Verb category | Default timeout |+|---|---|+| `walk_cloud_tree`, `search_cloud_files` | **620 s** (10+ min — cloud trees can be hundreds of folders @ ~1 fps) |+| `export_step`/`iges`/`sat`/`stl`/`3mf`/`usdz`/`obj`/`f3d`/`fbx`/`skp`/`source`/`eagle_source` | 320 s |+| `export_gerbers` | 200 s |+| `export_dxf`/`dwg` | 140 s |+| `drc`, `erc` | 180 s |+| `bridge_install`, `fusion_start` | 300 s |+| Everything else | 120 s |++Override per-call by passing `args.timeout` (seconds), clamped to a 1800 s ceiling:++```json+{+ "app": "fusion",+ "command": "search_cloud_files",+ "args": {+ "query": "cosmiic",+ "recursive": true,+ "maxFolders": 1000,+ "searchTimeout": 1500,+ "timeout": 1700+ }+}+```++The CLI dispatcher honors `args.timeout` the same way, so the two paths stay symmetric.++## Update endpoints (v1.8.134+) — HD-driven embedded auto-update++AD owns its own updater (fetch → SHA-verify → stage). HD does **not** download or+install AD's bytes — it reads AD's update state and, at a safe moment, "clicks+AD's Update button" via `POST /update/apply`. The contract is intentionally tiny+and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver):+`{ update_status, apply, busy }`.++### `GET /update/status`++```bash+curl -sf http://127.0.0.1:47200/update/status+```++```json+{+ "current_version": "1.8.133",+ "available_version": "1.8.134",+ "update_ready": true,+ "state": "staged",+ "busy": false+}+```++| Field | Meaning |+|---|---|+| `current_version` | The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |+| `available_version` | Latest version AD knows about from its manifest (`null` until the first check). May equal `current_version` when on latest. |+| `update_ready` | `true` **only** when a newer installer is already downloaded **+ SHA256-verified + staged on disk** — appliable with **no further network**. Equivalent to `state == "staged"`. |+| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |+| `busy` | `true` while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. **HD should defer the apply until `busy == false`.** (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.) |++`state` machine: `idle` → `checking` (a manifest check is running) → `downloading`+(fetching + verifying the installer) → `staged` (verified on disk; `update_ready=true`)+→ `applying` (apply triggered; AD about to restart). A failed check never un-stages a+previously-staged update.++### `POST /update/apply`++Applies the already-staged update — the programmatic equivalent of the user clicking+"Update". No request body.++```bash+curl -sf -X POST http://127.0.0.1:47200/update/apply+```++```json+{ "ok": true, "applying": true }+```++- **Only ever applies what AD itself staged + verified.** If nothing is staged, returns+ HTTP 409 `{ "ok": false, "applying": false, "error": "no update staged …", "_hint": … }`.+ Call `POST /update/check` first and poll `/update/status` until `update_ready=true`.+- **Embedded-aware:** no modal / blocking UI. AD returns this JSON **promptly** (before it+ restarts), runs the staged installer, then exits cleanly ~1.5 s later.+- **Comes back in embedded mode automatically.** AD's embedded marker on disk persists+ across the restart (the exit hook only drains the direct-API socket — it doesn't delete+ the marker), so the relaunched AD re-enters embedded mode via the marker + the HD-liveness+ probe. HD's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it+ idempotently.++#### Restart handshake — HD MUST suspend respawn for the apply window++Because the apply runs the NSIS installer **over the running AD exe**, Windows' mmap lock on+the running image means the file replacement only succeeds once AD has fully exited. **If HD's+supervisor respawns AD during the install window, it re-locks the exe and the replacement+silently no-ops** (AD comes back on the OLD version). So:++1. `GET /update/status` → wait for `update_ready == true && busy == false`.+2. `POST /update/apply` → on `{ applying: true }`, **stop respawning AD** (suspend the ~5 s+ health-respawn) for ~60 s.+3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL).+ AD re-embeds via its marker.+4. HD polls `GET /update/status` (or `/health`/`/buildinfo`); when AD answers again with+ `current_version ==` the new version, resume normal supervision + re-adopt+ (`POST /embedded/enter`).++HD initiated the apply, so HD already knows the restart is intentional — treat AD's+disappearance in this window as expected, not a crash.++### `POST /update/check`++Force a manifest check now. If a newer version is found, AD stages it **in the background**;+this call returns the `/update/status` shape promptly (poll it until `staged`). No request body.++```bash+curl -sf -X POST http://127.0.0.1:47200/update/check+```++Returns the same JSON shape as `GET /update/status`.++### Standalone vs embedded++- **Standalone AD:** the normal user-facing "Update available → Update" footer button ++ flow are unchanged. `auto` mode still silently self-updates; `prompt` shows the banner;+ `off` disables checks (and staging).+- **Embedded AD:** AD checks + **stages** on its own (so `update_ready` becomes true for HD)+ but **never auto-applies** — HD owns the apply timing via `POST /update/apply`. The+ embedded apply path never pops its own restart UI.++## What the user sees in the GUI Activity Log++Each `POST /command` call shows up in adom-desktop's Activity Log panel with a `direct:47200` badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (`desktop`, `kicad`, `hd`, etc.). This is visually distinct from the `localhost` badge attached to WS-relay traffic going through the localhost dev server at `ws://localhost:8865`.++At startup, a single `direct-api` event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), the user sees a corresponding error entry — no silent failure.++## Trust model++- **Loopback-only bind.** The listener binds `127.0.0.1`, never `0.0.0.0`. Off-machine HTTP requests can't reach it.+- **No token required.** Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use.+- **Same verb surface as the WS path.** Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls — the dispatcher is the same code.++## Reference integration: Hydrogen Desktop startup++This is the canonical pattern HD uses to register its container relay with adom-desktop at boot.++### Rust (HD's `src-tauri/src/lib.rs` or equivalent)++```rust+use serde_json::json;+use std::time::Duration;++const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health";+const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command";++async fn register_with_adom_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {+ // 1. Cheap probe — is adom-desktop running and listening?+ let client = reqwest::Client::builder()+ .timeout(Duration::from_secs(2))+ .build()+ .map_err(|e| format!("client init: {e}"))?;++ let healthy = client+ .get(ADOM_HEALTH)+ .send()+ .await+ .ok()+ .filter(|r| r.status().is_success())+ .is_some();++ if !healthy {+ // adom-desktop not running. Either tell the user to launch it,+ // or shell out to the CLI as the legacy fallback. Don't block+ // HD startup.+ log::warn!("adom-desktop not reachable on 127.0.0.1:47200 — relay will not be auto-registered");+ return Ok(());+ }++ // 2. POST the server_add command.+ let resp = client+ .post(ADOM_COMMAND)+ .timeout(Duration::from_secs(10))+ .json(&json!({+ "app": "desktop",+ "command": "server_add",+ "args": {+ "name": workspace_name,+ "url": relay_url,+ "autoConnect": true,+ }+ }))+ .send()+ .await+ .map_err(|e| format!("POST /command: {e}"))?;++ let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;++ if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {+ log::info!(+ "adom-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",+ body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")+ );+ Ok(())+ } else {+ Err(format!("adom-desktop refused: {body}"))+ }+}+```++### TypeScript (HD's frontend, if it ever calls directly)++```typescript+async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> {+ // probe first+ const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);+ if (!health?.ok) return false;++ const resp = await fetch('http://127.0.0.1:47200/command', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ signal: AbortSignal.timeout(10_000),+ body: JSON.stringify({+ app: 'desktop',+ command: 'server_add',+ args: { name, url, autoConnect: true },+ }),+ });+ const body = await resp.json();+ return body.ok === true;+}+```++## Cleanup at shutdown++Mirror the registration with a `server_remove`:++```bash+curl -X POST http://127.0.0.1:47200/command \+ -H 'Content-Type: application/json' \+ -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}'+```++If adom-desktop has already exited, the connection refuses — that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort.++## Embedded-mode integration (HD bundling AD, v1.8.42+)++When HD bundles AD, the direct API is the channel for HD's tray menu items and runtime control. HD spawns AD with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:++| HD action | direct-API envelope |+|---|---|+| "Open Adom Desktop" tray menu item | `{app:"desktop", command:"window_show"}` |+| Hide AD's window again | `{app:"desktop", command:"window_hide"}` |+| "Connect All" / "Disconnect All" tray items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |+| HD's "Quit" menu (cascade-stops AD) | `{app:"desktop", command:"shutdown"}` |+| HD sign-out propagation | `{app:"desktop", command:"logout"}` |+| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |+| Force permanent shell auto-approve | `{app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}` |++`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}` — useful for HD's tray to know whether AD already booted embedded or it's running standalone.++```typescript+// HD's tray-click handler — example+async function openAdomDesktop() {+ await fetch('http://127.0.0.1:47200/command', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ app: 'desktop', command: 'window_show' }),+ });+}+```++Note: HD typically calls `set_shell_auto_approve {permanent: true}` only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an HD setting like "Auto-approve AD shell commands ON/OFF").++## What about verbs not in the directApi list?++The `cliRequired` array in `GET /status` lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via `adom-desktop <verb>` it works via direct POST.++### Mapping CLI verb → direct-API envelope++**The prefix-stripping rule varies per namespace.** Easiest mental model: look at the CLI's `cli/src/commands.rs` dispatch line for the verb you want. Whatever string it passes as the second arg of `relay::desktop_command(app, command, ...)` is exactly what your direct-API envelope's `command` field should be.++**v1.8.153+: for `app:"desktop"` the `desktop_` prefix is OPTIONAL — POST the public verb name as-is.** A bridge delegating via `ADOM_DIRECT_API_URL/command` can send the exact verb it knows (`desktop_taskbar`, `desktop_flash_window`, `desktop_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form (`taskbar`, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so `desktop_taskbar` returned "Unknown desktop command" while `run_script` worked — that asymmetry is gone.)++| `app` value | What goes in `command` | Examples (CLI verb → direct envelope) |+|---|---|---|+| `desktop` | public verb name; `desktop_` prefix optional (v1.8.153+) | `desktop_taskbar` → `{app:"desktop", command:"desktop_taskbar"}` (or `"taskbar"`); `server_add` → `{app:"desktop", command:"server_add"}` |+| `kicad` | **stripped** — drop the `kicad_` prefix | `kicad_open_board` → `{app:"kicad", command:"open_board"}` |+| `fusion360` | **stripped** — drop the `fusion_` prefix | `fusion_start` → `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |+| `browser` | **kept** — pup bridge dispatches on the full name | `browser_open_window` → `{app:"browser", command:"browser_open_window"}` |+| `hd` | **stripped** — drop the `hd_` prefix | `hd_status` → `{app:"hd", command:"status"}` |+| `dynamic` | full verb name with third-party bridge prefix preserved | (dispatcher routes by prefix) |++Common direct-safe verb groups:++| Namespace | Examples | Notes |+|---|---|---|+| `desktop` | `server_add`, `server_remove`, `server_list`, `bridge_list`, `bridge_install`, `bridge_pause`, `bridge_resume`, `desktop_list_windows`, `desktop_screenshot_window`, `desktop_open_url`, `set_shell_auto_approve` | All sync, no prefix stripping. |+| `hd` | `status`, `eval`, `log`, `screenshot`, `build`, `build_frontend`, `build_rust`, `build_status`, `build_log`, `build_tail`, `launch`, `stop`, `restart` | `build*` returns instantly with `{pid, logPath}`; poll `build_status` or `build_tail` for progress. |+| `kicad` | `list_versions`, `open_board`, `open_schematic`, `run_drc`, `lint_board`, `install_symbol`, ... | Multi-version-aware. |+| `fusion360` | `launch`, `export_step`, `walk_cloud_tree`, `window_info`, `get_app_state`, ... | 50+ verbs. |+| `browser` | `browser_open_window`, `browser_navigate`, `browser_screenshot`, `browser_eval`, `browser_click`, `browser_record_start` | Per-tab. **Keep the prefix.** |++## Failure modes & retry++The direct API doesn't auto-retry. Sibling apps should:++1. **Probe `/health` before any `/command`** so a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.+2. **Per-request timeouts ≥ the verb's natural duration.** Most verbs are sub-second; `hd_build*` returns in <2 s; `bridge_install` can be 30 s for a large zip. Pick the timeout based on the verb you're calling.+3. **Treat 504 timeouts as "unknown state, ask later"** rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge.+4. **Read the `_hint` and `errorCode` fields on every error response.** They're machine-friendly: `cli_required`, `timeout`, `handler_silent`, plus whatever the verb's handler emits (`bridge_not_found`, `binary_missing`, etc.).++## Version skew++If `GET /status` returns `version < 1.8.25` OR fails entirely OR `schema` is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; everything that works via the direct API also works via the CLI, just slower.++## Why a separate port from the WS relay's 8766?++- **Different transport, different trust model.** 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.+- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.+- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. They don't fight for the same port or auth scheme.++## See also++- [`README.md`](../README.md) — feature list+- [`skills/SKILL.md`](SKILL.md) — full verb catalog (the entries Docker callers read)+- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation+- [Adom Wiki — apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop) — public verb reference
skills/adom-desktop-installer/SKILL.md+452−452@@ -1,452 +1,452 @@-name: adom-desktop-installer -description: How to invoke the Adom Desktop NSIS installer from a parent installer (especially Hydrogen Desktop's setup steps) — silent install, /NOLAUNCH switch for HD-managed bundling, live progress via the structured log file at %TEMP%\adom-desktop-install.log. Use when bundling AD inside another Windows installer, writing an install supervisor / setup-step UI, detecting AD's installed version, or upgrading an existing AD install programmatically. - -# Adom Desktop installer integration - -Adom Desktop (AD) ships as a single NSIS installer: - -``` -Adom Desktop_<version>_x64-setup.exe (~4.8 MB on v1.8.45) -``` - -It installs to `%LOCALAPPDATA%\Adom Desktop\` — no UAC prompt, no admin needed. Tauri-2 bundled NSIS with our customisations in `src-tauri/installer-hooks.nsh`. - -## Command-line surface - -| Flag | Meaning | -|---|---| -| (none) | Installs silently, auto-launches `adom-desktop.exe` after install | -| `/S` | Standard NSIS silent flag — same behavior since the top-level `SilentInstall silent` directive already suppresses UI | -| `/NOLAUNCH` | **v1.8.45+** — install silently, do NOT auto-launch after install. Caller is responsible for spawning AD with the desired flags | -| `/D=<path>` | Install destination override. Default is `$LOCALAPPDATA\Adom Desktop`. Rarely needed | -| `<uninstall.exe> /S` | Silent uninstall. The uninstaller lives at `%LOCALAPPDATA%\Adom Desktop\uninstall.exe` | - -**UI behavior**: zero UI in all cases. `SilentInstall silent` is set at the top of the NSIS script, so even `setup.exe` invoked from Explorer runs without a window. No Welcome page, no progress bar, no Finish page. - -**Auto-launch**: by default the installer calls `ExecShell "" "$INSTDIR\adom-desktop.exe"` at the end of POSTINSTALL. Pass `/NOLAUNCH` to suppress this. Used by HD when bundling AD because HD wants to spawn AD with `--embedded --start-hidden --relay-url ... --session-token ...` itself, not let AD launch standalone first and get respawned via single-instance. - -## Live progress log - -The installer writes structured progress to `%TEMP%\adom-desktop-install.log` (Windows expands `%TEMP%` to the user's temp dir, typically `C:\Users\<name>\AppData\Local\Temp`). Each line is `[STAGE] message\r\n`: - -``` -[START] adom-desktop installer 1.8.45 starting -[PREINSTALL] Cleaning legacy desktop shortcuts -[PREINSTALL] Done. Tauri will now copy bundle files. -[POSTINSTALL] Bundle files copied. Cleaning Tauri's auto-created desktop shortcut. -[POSTINSTALL] /NOLAUNCH passed — skipping auto-launch (caller will spawn) -[DONE] adom-desktop installer complete (no-launch mode) -``` - -**`[START]`** opens with a truncated write (fresh log per install). **`[DONE]`** is always the final line — readers tailing the file can use that as the completion sentinel without needing to wait for the process exit. - -Stage tags currently emitted: -- `[START]` — once, at the top -- `[PREINSTALL]` — Tauri's beforeBuildCommand hook ran, files about to be copied -- `[POSTINSTALL]` — files copied; Tauri's standard shortcuts created and our hook is cleaning the unwanted ones -- `[DONE]` — final line, installer about to exit - -The uninstaller writes the same log with `[PREUNINSTALL]` / `[POSTUNINSTALL]` stages. - -## Recipe: HD's setup-step UI tails the log - -Spawn the installer with `/NOLAUNCH`, poll the log file on a 200 ms timer, surface each new line to HD's UI. When `[DONE]` shows up OR the process exits, the install is complete. - -### Rust (HD's installer driver) - -```rust -use std::{env, fs, path::PathBuf, process::Command, time::Duration}; -use std::os::windows::process::CommandExt; -use tokio::time::sleep; - -const CREATE_NO_WINDOW: u32 = 0x08000000; - -async fn install_adom_desktop<F: FnMut(String)>( - installer_path: &str, - mut on_line: F, -) -> Result<(), String> { - let log_path: PathBuf = [&env::var("TEMP").map_err(|e| e.to_string())?, "adom-desktop-install.log"] - .iter() - .collect(); - // Pre-clear so we don't read stale lines from a previous install. - let _ = fs::remove_file(&log_path); - - let mut child = Command::new(installer_path) - .args(["/S", "/NOLAUNCH"]) - .creation_flags(CREATE_NO_WINDOW) - .spawn() - .map_err(|e| format!("spawn installer: {e}"))?; - - let mut last_pos = 0usize; - loop { - // Tail any new content - if let Ok(content) = fs::read_to_string(&log_path) { - if content.len() > last_pos { - let slice = &content[last_pos..]; - if let Some(last_nl) = slice.rfind('\n') { - let consumed = &slice[..=last_nl]; - for line in consumed.lines() { - on_line(line.to_string()); - if line.starts_with("[DONE]") { - // Don't return yet — wait for process exit - // so we surface the actual exit code, but - // we know progress streaming is finished. - } - } - last_pos += consumed.len(); - } - } - } - // Check if installer has exited - if let Some(status) = child.try_wait().map_err(|e| e.to_string())? { - return if status.success() { - Ok(()) - } else { - Err(format!("installer exited with {}", status)) - }; - } - sleep(Duration::from_millis(200)).await; - } -} - -// Caller: -// install_adom_desktop(r"C:\path\to\Adom Desktop_1.8.45_x64-setup.exe", |line| { -// hd_setup_step.append_live_output(&line); -// }).await?; -``` - -### Bash (debugging / scripting) - -```bash -# Pre-clear stale log -rm -f "$TEMP/adom-desktop-install.log" 2>/dev/null - -# Spawn installer in background, tail the log -"./Adom Desktop_1.8.45_x64-setup.exe" /S /NOLAUNCH & -INSTALLER_PID=$! - -# Tail until process exits OR [DONE] appears -while kill -0 $INSTALLER_PID 2>/dev/null; do - [ -f "$TEMP/adom-desktop-install.log" ] && tail -F "$TEMP/adom-desktop-install.log" & - sleep 0.2 -done -wait -echo "Installer exited" -``` - -## Recipe: detect if AD is already installed (and at what version) - -The signed NSIS installer (`installMode: "currentUser"`) writes AD's uninstall info to `HKCU` — no admin needed. **⚠ The key name is the product name `Adom Desktop` (WITH a space), NOT `AdomDesktop`** (verified live 2026-07-03). Two history notes so you don't chase the wrong key: -- The NSIS uninstall key = the product name → `…\Uninstall\Adom Desktop` (space). -- AD's OWN launch-time code used to ALSO write an `AdomDesktop` (no-space) key to **HKLM** — that write was **DELETED in v1.9.68** (the no-UAC fix). So `AdomDesktop` no longer exists anywhere; read `Adom Desktop`. - -``` -HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop - DisplayName = "Adom Desktop" - DisplayVersion = "1.9.74" - InstallLocation = "C:\Users\<name>\AppData\Local\Adom Desktop" - UninstallString = "C:\Users\<name>\AppData\Local\Adom Desktop\uninstall.exe" - Publisher = "Adom Industries, Inc." -``` - -```rust -use winreg::enums::HKEY_CURRENT_USER; -use winreg::RegKey; - -fn adom_desktop_installed_version() -> Option<String> { - let hkcu = RegKey::predef(HKEY_CURRENT_USER); - // NOTE the SPACE — the NSIS uninstall key is the product name "Adom Desktop". - let key = hkcu - .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop") - .ok()?; - key.get_value("DisplayVersion").ok() -} -``` - -Compare with `semver::Version::parse` to decide whether to skip / upgrade / leave-alone. - -## Recipe: HD's setup-step ladder for embedding AD - -The full pattern HD's setup-step UI runs when embedding AD: - -``` -1. Check installed version → registry read above -2. Decide: - no install → install bundled AD - older than bundled → install bundled AD (upgrade) - same or newer → skip install entirely -3. Run installer (silent + /NOLAUNCH) → tail log, surface to UI -4. Write embedded marker file → %LOCALAPPDATA%\Adom Desktop\embedded.json -5. Delete AD's user-facing shortcuts → Start Menu, Desktop, Startup folder - (so user can't double-launch standalone) -6. Spawn AD with embedded flags → adom-desktop.exe --embedded - --start-hidden - --relay-url ws://127.0.0.1:8765 - --relay-name hydrogen-desktop - --session-token <hd's token> -7. Poll AD's direct API for liveness → GET http://127.0.0.1:47200/health -``` - -Steps 4 and 5 are also covered by AD's installer if you pass `--embedded` at AD's first launch (the embedded-mode detection writes the marker and AD's autostart-upgrade routine handles its own shortcuts). Doing them in HD's installer is belt-and-suspenders. - -## Bundle-resource pattern - -HD's `tauri.conf.json` should include the AD installer as a bundle resource so it ships inside HD's NSIS installer: - -```json -"bundle": { - "resources": { - "resources/Adom Desktop_1.8.45_x64-setup.exe": "resources/" - } -} -``` - -At runtime HD finds it under its install dir at `$INSTDIR\resources\Adom Desktop_<ver>_x64-setup.exe` — pass that path to the install function above. - -## Versioning + upgrade strategy - -| Scenario | What HD's installer should do | -|---|---| -| No AD installed | Install bundled version | -| AD older than bundled | Install bundled version (upgrade) | -| AD same as bundled | Skip install step; still spawn with embedded flags | -| AD newer than bundled | Skip install step; spawn with embedded flags. Don't downgrade — embedded mode works for any AD ≥ v1.8.42 | - -The bundled AD installer is the minimum supported version for the HD release. HD can spawn newer ADs without modification. - -## Exit codes - -NSIS returns: -- `0` — install succeeded -- `2` — user cancelled (won't happen with `SilentInstall silent` since there's no UI to cancel) -- Other — install error (rare with our hooks; typical cause is disk full or `%LOCALAPPDATA%` not writable) - -Treat anything non-zero as a hard failure and surface the log file's last 20 lines to the user. - -## Where the installer lives - -**v1.8.115+** — the end-user binary home is the **V2 wiki blob store** (the -GitHub repo is private; GH release URLs 404 for anonymous downloads). Two -discovery surfaces, both anonymous: - -**Preferred for parent installers (HD setup): the packages API.** Resolve -the latest registered version and its assets in one or two GETs — note -`?org=adom` is REQUIRED (the slug exists in multiple orgs; bare URLs 400): - -``` -https://wiki.adom.inc/api/v1/packages/adom-desktop/dist-tags?org=adom - → {"dist_tags":{"latest":"X.Y.Z"}} -https://wiki.adom.inc/api/v1/packages/adom-desktop/manifest?org=adom - → { version, ..., assets: { "windows-x64": { filename, url, sha256, size } } } -``` - -The `assets` map is server-derived from the page's blobs (sha256 computed -from the actual bytes — integrity-checkable). `assets[...].url` is -server-relative (`/blob/app/adom-desktop/<file>`); prefix the git-wiki host. - -**Fallback / updater path: version.json**, V2-first with V1 fallback: - -``` -https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json -https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json -``` - -The V2 manifest's `windows.installer_url` / `cli.<plat>.binary_url` point at -the V2 files endpoint; V1's point at V1 static copies. **Follow the -manifest's URLs — do NOT construct binary URLs by hand.** - -Network quirk: HEAD on V2 blob/file URLs may return 404 while GET works -(git-wiki #40 — fix in flight). Probe with a Range GET (`Range: bytes=0-0`, -expect 206), never bare HEAD. Asset filenames are dot-form everywhere -(`Adom.Desktop_<ver>_windows_x64-setup.exe`); V1 dot-normalized since v1.8.72. - -The wiki page's `metadata.install.windows_installer` field points at the -canonical filename for the current published version. HD's release build -should fetch the latest via the V2-first pattern: - -```bash -# Try V2 first, fall back to V1. -fetch_manifest() { - curl -fL --max-time 15 \ - "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" \ - 2>/dev/null \ - || curl -fL --max-time 15 \ - "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" -} - -MANIFEST=$(fetch_manifest) -INSTALLER_URL=$(echo "$MANIFEST" | jq -r .windows.installer_url) -INSTALLER_SHA=$(echo "$MANIFEST" | jq -r .windows.installer_sha256) -# Each manifest carries a self-consistent installer_url that points at the -# SAME backend the manifest was served from — V2 manifest → V2 installer URL, -# V1 manifest → V1 installer URL. No URL mixing across backends. - -curl -fL "$INSTALLER_URL" -o resources/ad-setup.exe - -# Verify SHA256 before bundling — defends against partial fetches + tampering -DOWNLOAD_SHA=$(sha256sum resources/ad-setup.exe | awk '{print $1}') -[ "$DOWNLOAD_SHA" = "$INSTALLER_SHA" ] || { echo "SHA mismatch"; exit 1; } -``` - -The desktop AD's built-in auto-updater (`src-tauri/src/updater.rs`) walks an -ordered `MANIFEST_URLS` list — V2 first, V1 fallback — and uses whichever -responds first. Once V2 has been the source of truth for a release cycle -without incident, V1 will be retired from the pipeline. - -`version.json` also carries the SHA256 hash of the installer so HD's build -pipeline can verify the download before bundling. - -## Asset naming convention (v1.8.113+) - -The Adom Desktop wiki page hosts TWO distinct types of artifacts per release: - -1. **Full AD GUI installer** — the actual desktop app (Tauri window, bundled - bridges, relauncher). One per OS / architecture. -2. **CLI companion binary** — a separate executable that talks to AD over - the relay. Used by HD's WSL2 container, gallia builds, sibling-app - installers, cron jobs. Does NOT contain AD itself. - -Both ship together at the same version (single `python scripts/bump-version.py -patch` bumps both, single `cargo tauri build` + `cargo build --release` in -`cli/` produces both, single `scripts/release-publish.sh` uploads both). - -### Filename pattern - -| Artifact | Wiki asset name pattern | Example (v1.8.113) | -|---|---|---| -| Full AD GUI — Windows | `Adom.Desktop_<ver>_windows_x64-setup.exe` | `Adom.Desktop_1.8.113_windows_x64-setup.exe` | -| Full AD GUI — macOS | `Adom.Desktop_<ver>_macos_<arch>.dmg` | `Adom.Desktop_1.8.113_macos_aarch64.dmg` (future) | -| Full AD GUI — Linux | `Adom.Desktop_<ver>_linux_<arch>.AppImage` | `Adom.Desktop_1.8.113_linux_x86_64.AppImage` (future) | -| CLI companion — Linux | `adom-desktop-cli_<ver>_linux_<arch>` | `adom-desktop-cli_1.8.113_linux_x86_64` | -| CLI companion — macOS | `adom-desktop-cli_<ver>_macos_<arch>` | `adom-desktop-cli_1.8.113_macos_aarch64` (future) | -| CLI companion — Windows | `adom-desktop-cli_<ver>_windows_<arch>.exe` | `adom-desktop-cli_1.8.113_windows_x64.exe` (future) | - -The `_cli_` token in the middle is the only signal you need to know "this is -the companion, not the app." `linux_x86_64` always means Linux x86_64, -regardless of whether the file is a GUI installer or a CLI binary. - -### After install, the binary name is `adom-desktop` - -The wiki asset name disambiguates at FETCH time. At INSTALL time the binary -is renamed to `adom-desktop` so the command users type doesn't change: - -```bash -# Linux example — install the CLI companion -curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \ - -o /tmp/ad -sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop -adom-desktop --version -# → adom-desktop 1.8.113 (<sha>, built <ts>) -``` - -### Version.json `.cli` slot - -The auto-update manifest (v1.8.113+) carries a typed `cli` sibling section -for the CLI binaries: - -```json -{ - "manifest_version": 1, - "version": "1.8.113", - "windows": { "installer_url": ".../Adom.Desktop_1.8.113_windows_x64-setup.exe", ... }, - "cli": { - "linux_x86_64": { - "binary_url": ".../adom-desktop-cli_1.8.113_linux_x86_64", - "binary_filename": "adom-desktop-cli_1.8.113_linux_x86_64", - "binary_sha256": "...", - "binary_size": 8012408 - } - }, - "changelog_url": "...", - "minimum_required": "1.3.30" -} -``` - -Pre-v1.8.113 readers ignore `.cli` entirely (additive schema; no -`manifest_version` bump required). The desktop AD's own auto-updater only -reads `.windows / .macos / .linux` for its GUI installer URL — it doesn't -touch `.cli`. - -For HD / gallia / Docker-side fetchers, prefer the typed lookup: - -```bash -# Best: read the URL out of the manifest (so the filename can evolve) -MANIFEST=$(fetch_manifest) -LINUX_CLI_URL=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url') -LINUX_CLI_SHA=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_sha256') -curl -fL "$LINUX_CLI_URL" -o /tmp/ad -[ "$(sha256sum /tmp/ad | awk '{print $1}')" = "$LINUX_CLI_SHA" ] || exit 1 -sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop -``` - -### Windows CLI is shipped with the NSIS (v1.8.114+) - -Installing AD on Windows now drops the CLI companion next to the GUI exe -and adds the install dir to the user's PATH so PowerShell / Claude Desktop / -cmd find it without configuration: - -``` -%LOCALAPPDATA%\Adom Desktop\adom-desktop.exe ← GUI (Windows subsystem) -%LOCALAPPDATA%\Adom Desktop\adom-desktop-cli.exe ← CLI companion (Console subsystem) -%LOCALAPPDATA%\Adom Desktop\ad-relauncher.exe ← visible self-restart helper -``` - -NSIS's POSTINSTALL hook prepends `%LOCALAPPDATA%\Adom Desktop` to -`HKCU\Environment\Path` (no UAC — user-level only) and broadcasts -`WM_SETTINGCHANGE` so freshly-opened shells pick up the change. Already- -open shells need a re-launch (OS limitation). - -After install, in a fresh PowerShell: - -```powershell -adom-desktop-cli --version -# adom-desktop 1.8.114 (<sha>, built <ts>) -adom-desktop-cli status -adom-desktop-cli screenshot_window '{"title-contains":"VS Code"}' -``` - -The CLI auto-discovers the local AD via direct-API probe at -`127.0.0.1:47200` — no relay needed when AD is on the same box. ~5ms RTT -for compatible verbs; verbs in AD's `cliRequired` list still flow through -a relay if one's running (HD's WSL2 distro typically owns the relay). - -**Relay self-detect (v1.8.114+):** `adom-desktop-cli serve` checks for -another adom-desktop relay on `127.0.0.1:8766/health` before binding. If -one already answers, it prints a friendly note and exits cleanly without -starting a second relay (which would just sit idle — AD's WS client only -attaches to the first one it finds). Pass `--force-bind` to override. - -PREUNINSTALL strips the PATH entry back out and rebroadcasts so an -uninstall of AD also gets PATH cleanup. - -### Legacy alias migration window - -Releases prior to v1.8.113 published the Linux CLI as `adom-desktop-linux` -(no version, no architecture). HD's WSL2 setup and a few cron-based -installers have that literal filename baked in. - -To keep them working through the rename, v1.8.113 publishes BOTH names: - -| Name | Status | Wiki URL (V2 / V1) | -|---|---|---| -| `adom-desktop-cli_<ver>_linux_x86_64` | **canonical, v1.8.113+** | V2 / V1 | -| `adom-desktop-linux` | **deprecated alias** — byte-identical copy | V2 / V1 | - -Both files are byte-identical with the same SHA256. The legacy alias stays -through **v1.8.116**, then disappears. Migrate by then. Recommended pattern: - -```bash -# Migration period (v1.8.113 → v1.8.115): try new name, fall back to alias -LINUX_CLI_URL_NEW=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url // empty') -if [ -n "$LINUX_CLI_URL_NEW" ]; then - curl -fL "$LINUX_CLI_URL_NEW" -o /tmp/ad -else - # Falling back — likely talking to a pre-v1.8.113 manifest - curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-linux" \ - -o /tmp/ad -fi -``` - -Post-v1.8.116, the fallback branch dies; only the canonical name will exist. +---+name: adom-desktop-installer+description: How to invoke the Adom Desktop NSIS installer from a parent installer (especially Hydrogen Desktop's setup steps) — silent install, /NOLAUNCH switch for HD-managed bundling, live progress via the structured log file at %TEMP%\adom-desktop-install.log. Use when bundling AD inside another Windows installer, writing an install supervisor / setup-step UI, detecting AD's installed version, or upgrading an existing AD install programmatically.+---++# Adom Desktop installer integration++Adom Desktop (AD) ships as a single NSIS installer:++```+Adom Desktop_<version>_x64-setup.exe (~4.8 MB on v1.8.45)+```++It installs to `%LOCALAPPDATA%\Adom Desktop\` — no UAC prompt, no admin needed. Tauri-2 bundled NSIS with our customisations in `src-tauri/installer-hooks.nsh`.++## Command-line surface++| Flag | Meaning |+|---|---|+| (none) | Installs silently, auto-launches `adom-desktop.exe` after install |+| `/S` | Standard NSIS silent flag — same behavior since the top-level `SilentInstall silent` directive already suppresses UI |+| `/NOLAUNCH` | **v1.8.45+** — install silently, do NOT auto-launch after install. Caller is responsible for spawning AD with the desired flags |+| `/D=<path>` | Install destination override. Default is `$LOCALAPPDATA\Adom Desktop`. Rarely needed |+| `<uninstall.exe> /S` | Silent uninstall. The uninstaller lives at `%LOCALAPPDATA%\Adom Desktop\uninstall.exe` |++**UI behavior**: zero UI in all cases. `SilentInstall silent` is set at the top of the NSIS script, so even `setup.exe` invoked from Explorer runs without a window. No Welcome page, no progress bar, no Finish page.++**Auto-launch**: by default the installer calls `ExecShell "" "$INSTDIR\adom-desktop.exe"` at the end of POSTINSTALL. Pass `/NOLAUNCH` to suppress this. Used by HD when bundling AD because HD wants to spawn AD with `--embedded --start-hidden --relay-url ... --session-token ...` itself, not let AD launch standalone first and get respawned via single-instance.++## Live progress log++The installer writes structured progress to `%TEMP%\adom-desktop-install.log` (Windows expands `%TEMP%` to the user's temp dir, typically `C:\Users\<name>\AppData\Local\Temp`). Each line is `[STAGE] message\r\n`:++```+[START] adom-desktop installer 1.8.45 starting+[PREINSTALL] Cleaning legacy desktop shortcuts+[PREINSTALL] Done. Tauri will now copy bundle files.+[POSTINSTALL] Bundle files copied. Cleaning Tauri's auto-created desktop shortcut.+[POSTINSTALL] /NOLAUNCH passed — skipping auto-launch (caller will spawn)+[DONE] adom-desktop installer complete (no-launch mode)+```++**`[START]`** opens with a truncated write (fresh log per install). **`[DONE]`** is always the final line — readers tailing the file can use that as the completion sentinel without needing to wait for the process exit.++Stage tags currently emitted:+- `[START]` — once, at the top+- `[PREINSTALL]` — Tauri's beforeBuildCommand hook ran, files about to be copied+- `[POSTINSTALL]` — files copied; Tauri's standard shortcuts created and our hook is cleaning the unwanted ones+- `[DONE]` — final line, installer about to exit++The uninstaller writes the same log with `[PREUNINSTALL]` / `[POSTUNINSTALL]` stages.++## Recipe: HD's setup-step UI tails the log++Spawn the installer with `/NOLAUNCH`, poll the log file on a 200 ms timer, surface each new line to HD's UI. When `[DONE]` shows up OR the process exits, the install is complete.++### Rust (HD's installer driver)++```rust+use std::{env, fs, path::PathBuf, process::Command, time::Duration};+use std::os::windows::process::CommandExt;+use tokio::time::sleep;++const CREATE_NO_WINDOW: u32 = 0x08000000;++async fn install_adom_desktop<F: FnMut(String)>(+ installer_path: &str,+ mut on_line: F,+) -> Result<(), String> {+ let log_path: PathBuf = [&env::var("TEMP").map_err(|e| e.to_string())?, "adom-desktop-install.log"]+ .iter()+ .collect();+ // Pre-clear so we don't read stale lines from a previous install.+ let _ = fs::remove_file(&log_path);++ let mut child = Command::new(installer_path)+ .args(["/S", "/NOLAUNCH"])+ .creation_flags(CREATE_NO_WINDOW)+ .spawn()+ .map_err(|e| format!("spawn installer: {e}"))?;++ let mut last_pos = 0usize;+ loop {+ // Tail any new content+ if let Ok(content) = fs::read_to_string(&log_path) {+ if content.len() > last_pos {+ let slice = &content[last_pos..];+ if let Some(last_nl) = slice.rfind('\n') {+ let consumed = &slice[..=last_nl];+ for line in consumed.lines() {+ on_line(line.to_string());+ if line.starts_with("[DONE]") {+ // Don't return yet — wait for process exit+ // so we surface the actual exit code, but+ // we know progress streaming is finished.+ }+ }+ last_pos += consumed.len();+ }+ }+ }+ // Check if installer has exited+ if let Some(status) = child.try_wait().map_err(|e| e.to_string())? {+ return if status.success() {+ Ok(())+ } else {+ Err(format!("installer exited with {}", status))+ };+ }+ sleep(Duration::from_millis(200)).await;+ }+}++// Caller:+// install_adom_desktop(r"C:\path\to\Adom Desktop_1.8.45_x64-setup.exe", |line| {+// hd_setup_step.append_live_output(&line);+// }).await?;+```++### Bash (debugging / scripting)++```bash+# Pre-clear stale log+rm -f "$TEMP/adom-desktop-install.log" 2>/dev/null++# Spawn installer in background, tail the log+"./Adom Desktop_1.8.45_x64-setup.exe" /S /NOLAUNCH &+INSTALLER_PID=$!++# Tail until process exits OR [DONE] appears+while kill -0 $INSTALLER_PID 2>/dev/null; do+ [ -f "$TEMP/adom-desktop-install.log" ] && tail -F "$TEMP/adom-desktop-install.log" &+ sleep 0.2+done+wait+echo "Installer exited"+```++## Recipe: detect if AD is already installed (and at what version)++The signed NSIS installer (`installMode: "currentUser"`) writes AD's uninstall info to `HKCU` — no admin needed. **⚠ The key name is the product name `Adom Desktop` (WITH a space), NOT `AdomDesktop`** (verified live 2026-07-03). Two history notes so you don't chase the wrong key:+- The NSIS uninstall key = the product name → `…\Uninstall\Adom Desktop` (space).+- AD's OWN launch-time code used to ALSO write an `AdomDesktop` (no-space) key to **HKLM** — that write was **DELETED in v1.9.68** (the no-UAC fix). So `AdomDesktop` no longer exists anywhere; read `Adom Desktop`.++```+HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop+ DisplayName = "Adom Desktop"+ DisplayVersion = "1.9.74"+ InstallLocation = "C:\Users\<name>\AppData\Local\Adom Desktop"+ UninstallString = "C:\Users\<name>\AppData\Local\Adom Desktop\uninstall.exe"+ Publisher = "Adom Industries, Inc."+```++```rust+use winreg::enums::HKEY_CURRENT_USER;+use winreg::RegKey;++fn adom_desktop_installed_version() -> Option<String> {+ let hkcu = RegKey::predef(HKEY_CURRENT_USER);+ // NOTE the SPACE — the NSIS uninstall key is the product name "Adom Desktop".+ let key = hkcu+ .open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Adom Desktop")+ .ok()?;+ key.get_value("DisplayVersion").ok()+}+```++Compare with `semver::Version::parse` to decide whether to skip / upgrade / leave-alone.++## Recipe: HD's setup-step ladder for embedding AD++The full pattern HD's setup-step UI runs when embedding AD:++```+1. Check installed version → registry read above+2. Decide:+ no install → install bundled AD+ older than bundled → install bundled AD (upgrade)+ same or newer → skip install entirely+3. Run installer (silent + /NOLAUNCH) → tail log, surface to UI+4. Write embedded marker file → %LOCALAPPDATA%\Adom Desktop\embedded.json+5. Delete AD's user-facing shortcuts → Start Menu, Desktop, Startup folder+ (so user can't double-launch standalone)+6. Spawn AD with embedded flags → adom-desktop.exe --embedded+ --start-hidden+ --relay-url ws://127.0.0.1:8765+ --relay-name hydrogen-desktop+ --session-token <hd's token>+7. Poll AD's direct API for liveness → GET http://127.0.0.1:47200/health+```++Steps 4 and 5 are also covered by AD's installer if you pass `--embedded` at AD's first launch (the embedded-mode detection writes the marker and AD's autostart-upgrade routine handles its own shortcuts). Doing them in HD's installer is belt-and-suspenders.++## Bundle-resource pattern++HD's `tauri.conf.json` should include the AD installer as a bundle resource so it ships inside HD's NSIS installer:++```json+"bundle": {+ "resources": {+ "resources/Adom Desktop_1.8.45_x64-setup.exe": "resources/"+ }+}+```++At runtime HD finds it under its install dir at `$INSTDIR\resources\Adom Desktop_<ver>_x64-setup.exe` — pass that path to the install function above.++## Versioning + upgrade strategy++| Scenario | What HD's installer should do |+|---|---|+| No AD installed | Install bundled version |+| AD older than bundled | Install bundled version (upgrade) |+| AD same as bundled | Skip install step; still spawn with embedded flags |+| AD newer than bundled | Skip install step; spawn with embedded flags. Don't downgrade — embedded mode works for any AD ≥ v1.8.42 |++The bundled AD installer is the minimum supported version for the HD release. HD can spawn newer ADs without modification.++## Exit codes++NSIS returns:+- `0` — install succeeded+- `2` — user cancelled (won't happen with `SilentInstall silent` since there's no UI to cancel)+- Other — install error (rare with our hooks; typical cause is disk full or `%LOCALAPPDATA%` not writable)++Treat anything non-zero as a hard failure and surface the log file's last 20 lines to the user.++## Where the installer lives++**v1.8.115+** — the end-user binary home is the **V2 wiki blob store** (the+GitHub repo is private; GH release URLs 404 for anonymous downloads). Two+discovery surfaces, both anonymous:++**Preferred for parent installers (HD setup): the packages API.** Resolve+the latest registered version and its assets in one or two GETs — note+`?org=adom` is REQUIRED (the slug exists in multiple orgs; bare URLs 400):++```+https://wiki.adom.inc/api/v1/packages/adom-desktop/dist-tags?org=adom+ → {"dist_tags":{"latest":"X.Y.Z"}}+https://wiki.adom.inc/api/v1/packages/adom-desktop/manifest?org=adom+ → { version, ..., assets: { "windows-x64": { filename, url, sha256, size } } }+```++The `assets` map is server-derived from the page's blobs (sha256 computed+from the actual bytes — integrity-checkable). `assets[...].url` is+server-relative (`/blob/app/adom-desktop/<file>`); prefix the git-wiki host.++**Fallback / updater path: version.json**, V2-first with V1 fallback:++```+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json+https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json+```++The V2 manifest's `windows.installer_url` / `cli.<plat>.binary_url` point at+the V2 files endpoint; V1's point at V1 static copies. **Follow the+manifest's URLs — do NOT construct binary URLs by hand.**++Network quirk: HEAD on V2 blob/file URLs may return 404 while GET works+(git-wiki #40 — fix in flight). Probe with a Range GET (`Range: bytes=0-0`,+expect 206), never bare HEAD. Asset filenames are dot-form everywhere+(`Adom.Desktop_<ver>_windows_x64-setup.exe`); V1 dot-normalized since v1.8.72.++The wiki page's `metadata.install.windows_installer` field points at the+canonical filename for the current published version. HD's release build+should fetch the latest via the V2-first pattern:++```bash+# Try V2 first, fall back to V1.+fetch_manifest() {+ curl -fL --max-time 15 \+ "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json" \+ 2>/dev/null \+ || curl -fL --max-time 15 \+ "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/version.json"+}++MANIFEST=$(fetch_manifest)+INSTALLER_URL=$(echo "$MANIFEST" | jq -r .windows.installer_url)+INSTALLER_SHA=$(echo "$MANIFEST" | jq -r .windows.installer_sha256)+# Each manifest carries a self-consistent installer_url that points at the+# SAME backend the manifest was served from — V2 manifest → V2 installer URL,+# V1 manifest → V1 installer URL. No URL mixing across backends.++curl -fL "$INSTALLER_URL" -o resources/ad-setup.exe++# Verify SHA256 before bundling — defends against partial fetches + tampering+DOWNLOAD_SHA=$(sha256sum resources/ad-setup.exe | awk '{print $1}')+[ "$DOWNLOAD_SHA" = "$INSTALLER_SHA" ] || { echo "SHA mismatch"; exit 1; }+```++The desktop AD's built-in auto-updater (`src-tauri/src/updater.rs`) walks an+ordered `MANIFEST_URLS` list — V2 first, V1 fallback — and uses whichever+responds first. Once V2 has been the source of truth for a release cycle+without incident, V1 will be retired from the pipeline.++`version.json` also carries the SHA256 hash of the installer so HD's build+pipeline can verify the download before bundling.++## Asset naming convention (v1.8.113+)++The Adom Desktop wiki page hosts TWO distinct types of artifacts per release:++1. **Full AD GUI installer** — the actual desktop app (Tauri window, bundled+ bridges, relauncher). One per OS / architecture.+2. **CLI companion binary** — a separate executable that talks to AD over+ the relay. Used by HD's WSL2 container, gallia builds, sibling-app+ installers, cron jobs. Does NOT contain AD itself.++Both ship together at the same version (single `python scripts/bump-version.py+patch` bumps both, single `cargo tauri build` + `cargo build --release` in+`cli/` produces both, single `scripts/release-publish.sh` uploads both).++### Filename pattern++| Artifact | Wiki asset name pattern | Example (v1.8.113) |+|---|---|---|+| Full AD GUI — Windows | `Adom.Desktop_<ver>_windows_x64-setup.exe` | `Adom.Desktop_1.8.113_windows_x64-setup.exe` |+| Full AD GUI — macOS | `Adom.Desktop_<ver>_macos_<arch>.dmg` | `Adom.Desktop_1.8.113_macos_aarch64.dmg` (future) |+| Full AD GUI — Linux | `Adom.Desktop_<ver>_linux_<arch>.AppImage` | `Adom.Desktop_1.8.113_linux_x86_64.AppImage` (future) |+| CLI companion — Linux | `adom-desktop-cli_<ver>_linux_<arch>` | `adom-desktop-cli_1.8.113_linux_x86_64` |+| CLI companion — macOS | `adom-desktop-cli_<ver>_macos_<arch>` | `adom-desktop-cli_1.8.113_macos_aarch64` (future) |+| CLI companion — Windows | `adom-desktop-cli_<ver>_windows_<arch>.exe` | `adom-desktop-cli_1.8.113_windows_x64.exe` (future) |++The `_cli_` token in the middle is the only signal you need to know "this is+the companion, not the app." `linux_x86_64` always means Linux x86_64,+regardless of whether the file is a GUI installer or a CLI binary.++### After install, the binary name is `adom-desktop`++The wiki asset name disambiguates at FETCH time. At INSTALL time the binary+is renamed to `adom-desktop` so the command users type doesn't change:++```bash+# Linux example — install the CLI companion+curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-cli_1.8.113_linux_x86_64" \+ -o /tmp/ad+sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop+adom-desktop --version+# → adom-desktop 1.8.113 (<sha>, built <ts>)+```++### Version.json `.cli` slot++The auto-update manifest (v1.8.113+) carries a typed `cli` sibling section+for the CLI binaries:++```json+{+ "manifest_version": 1,+ "version": "1.8.113",+ "windows": { "installer_url": ".../Adom.Desktop_1.8.113_windows_x64-setup.exe", ... },+ "cli": {+ "linux_x86_64": {+ "binary_url": ".../adom-desktop-cli_1.8.113_linux_x86_64",+ "binary_filename": "adom-desktop-cli_1.8.113_linux_x86_64",+ "binary_sha256": "...",+ "binary_size": 8012408+ }+ },+ "changelog_url": "...",+ "minimum_required": "1.3.30"+}+```++Pre-v1.8.113 readers ignore `.cli` entirely (additive schema; no+`manifest_version` bump required). The desktop AD's own auto-updater only+reads `.windows / .macos / .linux` for its GUI installer URL — it doesn't+touch `.cli`.++For HD / gallia / Docker-side fetchers, prefer the typed lookup:++```bash+# Best: read the URL out of the manifest (so the filename can evolve)+MANIFEST=$(fetch_manifest)+LINUX_CLI_URL=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url')+LINUX_CLI_SHA=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_sha256')+curl -fL "$LINUX_CLI_URL" -o /tmp/ad+[ "$(sha256sum /tmp/ad | awk '{print $1}')" = "$LINUX_CLI_SHA" ] || exit 1+sudo install -m 755 /tmp/ad /usr/local/bin/adom-desktop+```++### Windows CLI is shipped with the NSIS (v1.8.114+)++Installing AD on Windows now drops the CLI companion next to the GUI exe+and adds the install dir to the user's PATH so PowerShell / Claude Desktop /+cmd find it without configuration:++```+%LOCALAPPDATA%\Adom Desktop\adom-desktop.exe ← GUI (Windows subsystem)+%LOCALAPPDATA%\Adom Desktop\adom-desktop-cli.exe ← CLI companion (Console subsystem)+%LOCALAPPDATA%\Adom Desktop\ad-relauncher.exe ← visible self-restart helper+```++NSIS's POSTINSTALL hook prepends `%LOCALAPPDATA%\Adom Desktop` to+`HKCU\Environment\Path` (no UAC — user-level only) and broadcasts+`WM_SETTINGCHANGE` so freshly-opened shells pick up the change. Already-+open shells need a re-launch (OS limitation).++After install, in a fresh PowerShell:++```powershell+adom-desktop-cli --version+# adom-desktop 1.8.114 (<sha>, built <ts>)+adom-desktop-cli status+adom-desktop-cli screenshot_window '{"title-contains":"VS Code"}'+```++The CLI auto-discovers the local AD via direct-API probe at+`127.0.0.1:47200` — no relay needed when AD is on the same box. ~5ms RTT+for compatible verbs; verbs in AD's `cliRequired` list still flow through+a relay if one's running (HD's WSL2 distro typically owns the relay).++**Relay self-detect (v1.8.114+):** `adom-desktop-cli serve` checks for+another adom-desktop relay on `127.0.0.1:8766/health` before binding. If+one already answers, it prints a friendly note and exits cleanly without+starting a second relay (which would just sit idle — AD's WS client only+attaches to the first one it finds). Pass `--force-bind` to override.++PREUNINSTALL strips the PATH entry back out and rebroadcasts so an+uninstall of AD also gets PATH cleanup.++### Legacy alias migration window++Releases prior to v1.8.113 published the Linux CLI as `adom-desktop-linux`+(no version, no architecture). HD's WSL2 setup and a few cron-based+installers have that literal filename baked in.++To keep them working through the rename, v1.8.113 publishes BOTH names:++| Name | Status | Wiki URL (V2 / V1) |+|---|---|---|+| `adom-desktop-cli_<ver>_linux_x86_64` | **canonical, v1.8.113+** | V2 / V1 |+| `adom-desktop-linux` | **deprecated alias** — byte-identical copy | V2 / V1 |++Both files are byte-identical with the same SHA256. The legacy alias stays+through **v1.8.116**, then disappears. Migrate by then. Recommended pattern:++```bash+# Migration period (v1.8.113 → v1.8.115): try new name, fall back to alias+LINUX_CLI_URL_NEW=$(echo "$MANIFEST" | jq -r '.cli.linux_x86_64.binary_url // empty')+if [ -n "$LINUX_CLI_URL_NEW" ]; then+ curl -fL "$LINUX_CLI_URL_NEW" -o /tmp/ad+else+ # Falling back — likely talking to a pre-v1.8.113 manifest+ curl -fL "https://wiki.adom.inc/api/v1/pages/adom-desktop/files/adom-desktop-linux" \+ -o /tmp/ad+fi+```++Post-v1.8.116, the fallback branch dies; only the canonical name will exist.
version.json+8−8@@ -1,16 +1,16 @@ { "manifest_version": 1, - "version": "1.9.182", - "released_at": "2026-07-25T17:00:00Z", + "version": "1.9.183", + "released_at": "2026-07-25T13:00:00Z", "windows": { - "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop/1.9.182/Adom.Desktop_1.9.182_windows_x64-setup.exe", - "installer_filename": "Adom.Desktop_1.9.182_windows_x64-setup.exe", - "installer_sha256": "6a76fc6b357b03f1e1d9e6c46c1957ae71a50665d9b91cdc3ba6b238764594c5", - "installer_size": 13543648 + "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop/1.9.183/Adom.Desktop_1.9.183_windows_x64-setup.exe", + "installer_filename": "Adom.Desktop_1.9.183_windows_x64-setup.exe", + "installer_sha256": "3d4e88a82e2b159995f99af537e2e3746172777c25bfe431f937496c26060189", + "installer_size": 13562872 }, "changelog_url": "https://wiki.adom.inc/adom/adom-desktop", "minimum_required": "1.3.30", - "note": "AI threads name themselves per command now (new --ai-thread flag), which keeps the names honest when many AI conversations share one container.", + "note": "When a bridge does work for one of your AI tabs, the desktop now shows both names: the tab that asked and the bridge that ran it.", "macos": { "installer_url": "https://wiki.adom.inc/download/adom/adom-desktop-macos/1.9.162/Adom.Desktop_1.9.162_macos_aarch64.dmg", "installer_filename": "Adom.Desktop_1.9.162_macos_aarch64.dmg", @@ -18,4 +18,4 @@ "installer_size": 12241094, "arch": "aarch64" } -}+}