feat(desktop): Auto-start web server mirror for remote access #8449

Open
opened 2026-02-16 18:10:00 -05:00 by yindo · 5 comments
Owner

Originally created by @mguttmann on GitHub (Feb 3, 2026).

Originally assigned to: @adamdotdevin on GitHub.

Problem

The OpenCode desktop app runs a local server on 127.0.0.1, inaccessible from other devices. To access OpenCode remotely, users must manually run opencode web in a terminal — but this starts a completely separate server instance with its own in-memory state. That means:

  • No live sync — SSE events, WebSocket updates, and session changes don't propagate between desktop and web
  • No sidebar sync — projects opened/closed on desktop don't reflect in the web UI
  • Separate auth — different credentials, different session cookies
  • Manual lifecycle — users must start/stop the web server themselves

This makes remote access impractical for real use.

Solution: TCP Reverse Proxy (True 1:1 Mirror)

Instead of spawning a second server, we add a TCP reverse proxy written in Rust (using tokio::net) that forwards raw TCP bytes from 0.0.0.0:<port> to the desktop's local sidecar server. This gives a true 1:1 mirror — same sessions, same live updates (SSE/WebSocket), same auth, same everything — because it just pipes bytes through without interpreting them.

Why a TCP proxy instead of a separate server?

Approach State Sync Live Updates Auth Complexity
opencode web (separate server) Separate in-memory state SSE/WS don't cross instances Separate credentials Spawns full server
TCP reverse proxy (this PR) Same server, same state All events pass through Same auth (Basic Auth) Lightweight byte-pipe

The TCP proxy is the right abstraction: it operates at the transport layer, so every HTTP request, SSE stream, and WebSocket connection from the browser goes directly to the same server the desktop uses. No protocol-level interpretation, no state duplication, no sync issues.

Sidebar Synchronization

On top of the TCP proxy, we add one-way sidebar sync (Desktop → Server → Mirror) so the browser mirror shows the same projects in the same order as the desktop:

  1. Desktop pushes its sidebar state (worktree, expanded) to PUT /mirror/sidebar on every change (debounced 300ms)
  2. Server stores it via Storage.write() and broadcasts via SSE (mirror.sidebar.updated)
  3. Mirror reads initial state from GET /mirror/sidebar on startup, then reacts to SSE updates

This is strictly one-way — the mirror never writes back — which eliminates reactive loops and conflict resolution entirely.

Architecture

Browser (phone/tablet/laptop)
  → http://192.168.x.x:4096
    → TCP Proxy (Rust/tokio, binds 0.0.0.0:4096)
      → Sidecar (127.0.0.1:<random-port>)
        → If OPENCODE_WEB_DIR is set: serve bundled mirror UI
        → Else: proxy to https://app.opencode.ai

Platform Detection

The mirror runs as platform: "desktop" (unlocks all desktop UI sections) but without platform.storage (no Tauri APIs). This distinction is how the app knows it's a mirror:

  • Real Desktop (Tauri): platform.platform === "desktop" && platform.storage !== undefined
  • Mirror (browser): platform.platform === "desktop" && platform.storage === undefined

Security Decisions

  1. Terminal is hidden in the mirror UI — allowing terminal access via a browser with only HTTP Basic Auth would let anyone with the password execute arbitrary commands on the host machine. Terminal remains desktop-only.

  2. "Add Project" button is hidden — on macOS/Linux, opening a directory picker requires a native file dialog that prompts for filesystem permissions. This can only be confirmed locally on the machine, not through a remote browser. Projects can only be added from the desktop; the mirror automatically syncs them.

  3. Authentication uses HTTP Basic Auth (same as opencode web). The browser shows its native login dialog and caches credentials per origin. Credential resolution priority:

    • Settings UI values (user-configured username/password)
    • Shell environment variables (OPENCODE_SERVER_USERNAME / OPENCODE_SERVER_PASSWORD)
    • Defaults (opencode / random UUID)

Use Cases

  • Phone/tablet access — check on running sessions, review changes, read output while away from your desk
  • Second monitor/laptop — run OpenCode on your main machine, interact from another device on the same network
  • Remote server — run OpenCode on a powerful server, connect from your desktop via the web UI; it keeps working autonomously even when you disconnect
  • Quick remote check — no SSH, no terminal setup, just open a browser and go

Features

  • Settings UI in Settings → General → Desktop with enable/disable toggle and port configuration
  • Status indicator in the Servers tab of the status popover (green dot + local/network URL + credentials)
  • Auto-start — if enabled, starts automatically when the desktop app launches (after sidecar is ready)
  • Auto-stop — stops when the desktop app exits
  • Orphan cleanup — if a previous OpenCode instance left a zombie process on the port, it's automatically killed
  • Pre-built mirror UI — Vite builds a separate dist-mirror/ bundle that gets embedded in the desktop app as a Tauri resource, served by the sidecar when OPENCODE_WEB_DIR is set
  • i18n — all 15 languages with native translations

Technical Details

Files Changed (39 files, +1362 lines)

Rust (desktop core):

  • lib.rsWebMirrorState, WebMirrorConfig, WebMirrorStatus, TCP proxy via tokio::net, 3 Tauri commands (start_web_mirror, stop_web_mirror, get_web_mirror_status), auto-start/stop lifecycle, orphan port cleanup, credential resolution
  • cli.rsprobe_shell_env() reads env vars from the user's login shell (.zshrc, .bashrc etc.)

TypeScript (desktop bindings):

  • bindings.ts — Tauri command bindings + types
  • index.tsx — Platform integration

App (shared UI):

  • entry-mirror.tsx — Mirror entry point (sets platform: "desktop" without Tauri APIs)
  • mirror.html — HTML shell for the mirror build
  • vite.mirror.config.ts — Vite config for the mirror bundle
  • platform.tsxWebMirrorConfig, WebMirrorStatus type definitions
  • settings-general.tsx — Web Mirror settings section
  • status-popover.tsx — Status indicator with polling
  • layout.tsx — One-way sidebar sync (Desktop push + Mirror apply)
  • global-sync.tsxmirrorSidebar SSE field
  • server.tsxStoredProject type used by sidebar sync
  • session-header.tsx — Hide terminal in mirror
  • home.tsx — Hide "Open Project" button in mirror
  • layout.tsx (pages) — Hide sidebar "+" button in mirror
  • session.tsx — Hide terminal panel in mirror

Server (Hono):

  • server.tsGET/PUT /mirror/sidebar endpoints + SSE broadcast
  • flag.tsOPENCODE_WEB_DIR environment flag

Build:

  • predev.ts / prepare.ts — Mirror build steps
  • tauri.conf.jsonweb-ui resource declaration

i18n (15 files):

  • All language files with native translations for web mirror strings
Originally created by @mguttmann on GitHub (Feb 3, 2026). Originally assigned to: @adamdotdevin on GitHub. ## Problem The OpenCode desktop app runs a local server on `127.0.0.1`, inaccessible from other devices. To access OpenCode remotely, users must manually run `opencode web` in a terminal — but this starts a **completely separate server instance** with its own in-memory state. That means: - **No live sync** — SSE events, WebSocket updates, and session changes don't propagate between desktop and web - **No sidebar sync** — projects opened/closed on desktop don't reflect in the web UI - **Separate auth** — different credentials, different session cookies - **Manual lifecycle** — users must start/stop the web server themselves This makes remote access impractical for real use. ## Solution: TCP Reverse Proxy (True 1:1 Mirror) Instead of spawning a second server, we add a **TCP reverse proxy** written in Rust (using `tokio::net`) that forwards raw TCP bytes from `0.0.0.0:<port>` to the desktop's local sidecar server. This gives a **true 1:1 mirror** — same sessions, same live updates (SSE/WebSocket), same auth, same everything — because it just pipes bytes through without interpreting them. ### Why a TCP proxy instead of a separate server? | Approach | State Sync | Live Updates | Auth | Complexity | |----------|-----------|-------------|------|-----------| | `opencode web` (separate server) | ❌ Separate in-memory state | ❌ SSE/WS don't cross instances | ❌ Separate credentials | Spawns full server | | **TCP reverse proxy** (this PR) | ✅ Same server, same state | ✅ All events pass through | ✅ Same auth (Basic Auth) | Lightweight byte-pipe | The TCP proxy is the right abstraction: it operates at the transport layer, so every HTTP request, SSE stream, and WebSocket connection from the browser goes directly to the same server the desktop uses. No protocol-level interpretation, no state duplication, no sync issues. ### Sidebar Synchronization On top of the TCP proxy, we add **one-way sidebar sync** (Desktop → Server → Mirror) so the browser mirror shows the same projects in the same order as the desktop: 1. **Desktop** pushes its sidebar state (`worktree`, `expanded`) to `PUT /mirror/sidebar` on every change (debounced 300ms) 2. **Server** stores it via `Storage.write()` and broadcasts via SSE (`mirror.sidebar.updated`) 3. **Mirror** reads initial state from `GET /mirror/sidebar` on startup, then reacts to SSE updates This is **strictly one-way** — the mirror never writes back — which eliminates reactive loops and conflict resolution entirely. ## Architecture ``` Browser (phone/tablet/laptop) → http://192.168.x.x:4096 → TCP Proxy (Rust/tokio, binds 0.0.0.0:4096) → Sidecar (127.0.0.1:<random-port>) → If OPENCODE_WEB_DIR is set: serve bundled mirror UI → Else: proxy to https://app.opencode.ai ``` ### Platform Detection The mirror runs as `platform: "desktop"` (unlocks all desktop UI sections) but **without** `platform.storage` (no Tauri APIs). This distinction is how the app knows it's a mirror: - **Real Desktop (Tauri):** `platform.platform === "desktop" && platform.storage !== undefined` - **Mirror (browser):** `platform.platform === "desktop" && platform.storage === undefined` ### Security Decisions 1. **Terminal is hidden** in the mirror UI — allowing terminal access via a browser with only HTTP Basic Auth would let anyone with the password execute arbitrary commands on the host machine. Terminal remains desktop-only. 2. **"Add Project" button is hidden** — on macOS/Linux, opening a directory picker requires a native file dialog that prompts for filesystem permissions. This can only be confirmed locally on the machine, not through a remote browser. Projects can only be added from the desktop; the mirror automatically syncs them. 3. **Authentication** uses HTTP Basic Auth (same as `opencode web`). The browser shows its native login dialog and caches credentials per origin. Credential resolution priority: - Settings UI values (user-configured username/password) - Shell environment variables (`OPENCODE_SERVER_USERNAME` / `OPENCODE_SERVER_PASSWORD`) - Defaults (`opencode` / random UUID) ## Use Cases - **Phone/tablet access** — check on running sessions, review changes, read output while away from your desk - **Second monitor/laptop** — run OpenCode on your main machine, interact from another device on the same network - **Remote server** — run OpenCode on a powerful server, connect from your desktop via the web UI; it keeps working autonomously even when you disconnect - **Quick remote check** — no SSH, no terminal setup, just open a browser and go ## Features - **Settings UI** in Settings → General → Desktop with enable/disable toggle and port configuration - **Status indicator** in the Servers tab of the status popover (green dot + local/network URL + credentials) - **Auto-start** — if enabled, starts automatically when the desktop app launches (after sidecar is ready) - **Auto-stop** — stops when the desktop app exits - **Orphan cleanup** — if a previous OpenCode instance left a zombie process on the port, it's automatically killed - **Pre-built mirror UI** — Vite builds a separate `dist-mirror/` bundle that gets embedded in the desktop app as a Tauri resource, served by the sidecar when `OPENCODE_WEB_DIR` is set - **i18n** — all 15 languages with native translations ## Technical Details ### Files Changed (39 files, +1362 lines) **Rust (desktop core):** - `lib.rs` — `WebMirrorState`, `WebMirrorConfig`, `WebMirrorStatus`, TCP proxy via `tokio::net`, 3 Tauri commands (`start_web_mirror`, `stop_web_mirror`, `get_web_mirror_status`), auto-start/stop lifecycle, orphan port cleanup, credential resolution - `cli.rs` — `probe_shell_env()` reads env vars from the user's login shell (`.zshrc`, `.bashrc` etc.) **TypeScript (desktop bindings):** - `bindings.ts` — Tauri command bindings + types - `index.tsx` — Platform integration **App (shared UI):** - `entry-mirror.tsx` — Mirror entry point (sets `platform: "desktop"` without Tauri APIs) - `mirror.html` — HTML shell for the mirror build - `vite.mirror.config.ts` — Vite config for the mirror bundle - `platform.tsx` — `WebMirrorConfig`, `WebMirrorStatus` type definitions - `settings-general.tsx` — Web Mirror settings section - `status-popover.tsx` — Status indicator with polling - `layout.tsx` — One-way sidebar sync (Desktop push + Mirror apply) - `global-sync.tsx` — `mirrorSidebar` SSE field - `server.tsx` — `StoredProject` type used by sidebar sync - `session-header.tsx` — Hide terminal in mirror - `home.tsx` — Hide "Open Project" button in mirror - `layout.tsx` (pages) — Hide sidebar "+" button in mirror - `session.tsx` — Hide terminal panel in mirror **Server (Hono):** - `server.ts` — `GET/PUT /mirror/sidebar` endpoints + SSE broadcast - `flag.ts` — `OPENCODE_WEB_DIR` environment flag **Build:** - `predev.ts` / `prepare.ts` — Mirror build steps - `tauri.conf.json` — `web-ui` resource declaration **i18n (15 files):** - All language files with native translations for web mirror strings
yindo added the web label 2026-02-16 18:10:00 -05:00
Author
Owner

@github-actions[bot] commented on GitHub (Feb 3, 2026):

This issue might be a duplicate of existing issues. Please check:

  • #11934: Access web interface from different device - User wants to monitor sessions from other devices on a VPN, which aligns with the network access use case for this feature
  • #11331: Opencode web should display sessions history in the sidebar when using opencode server - Related to web UI session synchronization across devices

Feel free to ignore if none of these address your specific case.

@github-actions[bot] commented on GitHub (Feb 3, 2026): This issue might be a duplicate of existing issues. Please check: - #11934: Access web interface from different device - User wants to monitor sessions from other devices on a VPN, which aligns with the network access use case for this feature - #11331: Opencode web should display sessions history in the sidebar when using opencode server - Related to web UI session synchronization across devices Feel free to ignore if none of these address your specific case.
Author
Owner

@mguttmann commented on GitHub (Feb 3, 2026):

Image
@mguttmann commented on GitHub (Feb 3, 2026): <img width="930" height="578" alt="Image" src="https://github.com/user-attachments/assets/b69e1f99-d93b-4335-b7ad-e26bc72e4d99" />
Author
Owner

@mguttmann commented on GitHub (Feb 3, 2026):

Image
@mguttmann commented on GitHub (Feb 3, 2026): <img width="590" height="1278" alt="Image" src="https://github.com/user-attachments/assets/801fdf4f-9afa-4371-a52d-9b9953fb2eb4" />
Author
Owner

@mguttmann commented on GitHub (Feb 3, 2026):

Image
@mguttmann commented on GitHub (Feb 3, 2026): <img width="590" height="1278" alt="Image" src="https://github.com/user-attachments/assets/a45af5e3-0521-4bd3-90e4-34fb0742c9dd" />
Author
Owner

@mguttmann commented on GitHub (Feb 3, 2026):

Thanks for flagging these — both issues describe exactly the problem this feature solves, and then some:

#11934"Access web interface from different device" — asks for monitoring sessions from other devices over a VPN without relying on localStorage. Our TCP reverse proxy does exactly this: same server, same state, same live SSE/WebSocket streams. No localStorage dependency because the mirror reads sidebar state from the server via GET /mirror/sidebar + SSE updates. Open http://<ip>:4096 from your phone and you see everything live.

#11331"Opencode web should display sessions history in the sidebar" — the core problem is that opencode web starts a separate server instance with its own in-memory state, so sessions from the main server don't show up. Our approach eliminates this entirely: the TCP proxy forwards to the same server the desktop uses, so all sessions, all history, all live updates are there automatically. No sync needed because there's nothing to sync — it's the same server.

Both issues would be fully resolved by this PR (#12000).

@mguttmann commented on GitHub (Feb 3, 2026): Thanks for flagging these — both issues describe exactly the problem this feature solves, and then some: **#11934** — *"Access web interface from different device"* — asks for monitoring sessions from other devices over a VPN without relying on `localStorage`. Our TCP reverse proxy does exactly this: same server, same state, same live SSE/WebSocket streams. No `localStorage` dependency because the mirror reads sidebar state from the server via `GET /mirror/sidebar` + SSE updates. Open `http://<ip>:4096` from your phone and you see everything live. **#11331** — *"Opencode web should display sessions history in the sidebar"* — the core problem is that `opencode web` starts a **separate server instance** with its own in-memory state, so sessions from the main server don't show up. Our approach eliminates this entirely: the TCP proxy forwards to the **same server** the desktop uses, so all sessions, all history, all live updates are there automatically. No sync needed because there's nothing to sync — it's the same server. Both issues would be fully resolved by this PR (#12000).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: anomalyco/opencode#8449