From 72673b7f72be217416a484456ce3c7a18e65b7e9 Mon Sep 17 00:00:00 2001 From: CT4nk3r Date: Sun, 26 Apr 2026 05:22:46 +0200 Subject: [PATCH] fix: improve Windows setup UX and persist tunnel provider choice - Plugin now reads persisted provider from ~/.config/opencode-mobile/tunnel-config.json in addition to the TUNNEL_PROVIDER env var, so the choice made during 'npx opencode-mobile install --provider ' actually takes effect at runtime. - 'opencode-mobile install --provider ' now writes that file even when --skip-tunnel-setup is used (or when tunnel-setup itself wouldn't have written it). - Install success output now warns prominently that '/mobile' only works in 'opencode serve' mode and that --hostname 0.0.0.0 is required for LAN access (default 127.0.0.1 is loopback-only and unreachable from a phone). - The /mobile tool returns an actionable multi-line message instead of the bare 'No tunnel URL found.' when run in TUI/attach mode. - The plugin's non-serve-mode log spells out the exact serve command to run. - README: Quick Start now recommends 'opencode serve --hostname 0.0.0.0 --port 4096', documents the persisted tunnel config, and adds a troubleshooting section for mobile permission approvals that don't reflect on the PC (with curl-based diagnostics to isolate mobile-app vs tunnel vs server issues). --- README.md | 93 +++++++++++++++++++++++++++++++++++++++++++--- index.ts | 76 +++++++++++++++++++++++++++++++++---- src/cli/install.ts | 77 +++++++++++++++++++++++++++++++++++++- 3 files changed, 230 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9ac30f0..31529a0 100644 --- a/README.md +++ b/README.md @@ -44,16 +44,24 @@ npx opencode-mobile install ### Step 2: Start OpenCode +The plugin only initializes its tunnel and LAN server when OpenCode is launched +in **serve** mode. Use `--hostname 0.0.0.0` so your phone can reach it over LAN +(the default `127.0.0.1` is loopback-only). + ```bash -opencode attach +opencode serve --hostname 0.0.0.0 --port 4096 ``` -Or start a new session: +If you want a TUI, attach in a second terminal: ```bash -opencode serve +opencode attach ``` +> ⚠️ Running plain `opencode` (or `opencode attach` alone) will load the plugin +> but skip tunnel/LAN-server startup. `/mobile` will respond with +> "No tunnel URL found" until you start a `serve` instance. + **What you'll see:** ``` [opencode-mobile] v1.3.10 @@ -129,10 +137,22 @@ https://your-tunnel-url.ngrok.io | Variable | Description | Default | |----------|-------------|---------| -| `TUNNEL_PROVIDER` | Tunnel provider (`auto`, `ngrok`, `cloudflare`, `localtunnel`) | `auto` | +| `TUNNEL_PROVIDER` | Tunnel provider (`auto`, `ngrok`, `cloudflare`, `localtunnel`). Overrides persisted config. | persisted config, else `auto` | | `OPENCODE_MOBILE_DEBUG` | Enable debug logging (`1` to enable) | disabled | | `OPENCODE_PORT` | Local server port | `3000` | +### Persisted Tunnel Config + +When you run `npx opencode-mobile install --provider ` (or +`npx opencode-mobile-tunnel-setup`), the choice is persisted to: + +``` +~/.config/opencode-mobile/tunnel-config.json +``` + +The plugin reads this file at runtime, so you don't need to set +`TUNNEL_PROVIDER` every time. The env var still wins if both are present. + ### Tunnel Providers The plugin automatically tries providers in this order: @@ -170,11 +190,21 @@ ngrok config add-authtoken YOUR_TOKEN ### "No tunnel URL found" -**Problem**: Tunnel failed to start +**Problem**: Tunnel failed to start, or you're not in `serve` mode + +**The plugin only auto-starts the tunnel when OpenCode is launched in `serve` mode.** +If you ran the plain `opencode` TUI (or `opencode attach`), `/mobile` will report +"No tunnel URL found" because the LAN server and tunnel were never initialized. **Solutions:** ```bash -# Check tunnel provider is installed +# Start OpenCode with LAN access enabled (required for phone-on-LAN access) +opencode serve --hostname 0.0.0.0 --port 4096 + +# Then in another terminal, attach a TUI if you want one: +opencode attach + +# Verify your tunnel provider is installed command -v ngrok command -v cloudflared @@ -185,6 +215,57 @@ npx opencode-mobile-tunnel-setup npx opencode-mobile install --skip-tunnel-setup ``` +> ⚠️ `opencode serve` defaults to `--hostname 127.0.0.1` (loopback only). Phones +> on your LAN cannot reach 127.0.0.1. **Always use `--hostname 0.0.0.0`** unless +> you are relying solely on the public tunnel. + +### Mobile permission approvals don't reflect on the PC + +**Problem**: Tapping "Always allow" / "Approve" in the mobile app doesn't update +OpenCode on your computer. + +**How the flow works:** +1. OpenCode emits a `permission.asked` event when a tool needs approval. +2. The plugin formats it into a push notification with `permissionId`, + `sessionId`, and the tunnel `serverUrl` in the payload. +3. The mobile app is expected to POST the approval back to OpenCode's REST API + via the tunnel URL, e.g. + `POST {serverUrl}/session/{sessionId}/permissions/{permissionId}` with + `{"response": "always"}` (exact path/payload depends on the OpenCode version + running on your PC). + +**Solutions / diagnostics:** + +```bash +# 1. Confirm OpenCode is reachable through the tunnel from your phone. +# Open the tunnel URL in your phone's browser - you should hit OpenCode's API. + +# 2. Make sure OpenCode is bound to 0.0.0.0, NOT 127.0.0.1. +# A loopback-only bind works for tunnels running in-process, but breaks +# direct LAN connections from the mobile app. +opencode serve --hostname 0.0.0.0 --port 4096 + +# 3. Enable plugin debug logging to see permission events as they're emitted. +$env:OPENCODE_MOBILE_DEBUG = "1" # PowerShell +# or export OPENCODE_MOBILE_DEBUG=1 # bash + +# 4. Tail OpenCode's own server logs while you tap "Approve" on the phone - +# if no incoming request appears, the mobile app never reached the server +# (tunnel issue / DNS / firewall). + +# 5. Test the approve endpoint manually from your machine using the tunnel URL +# (replace IDs with values from a real notification payload): +curl -X POST "https:///session//permissions/" \ + -H "Content-Type: application/json" \ + -d '{"response":"always"}' +``` + +If the manual `curl` works but the mobile app's tap does not, the bug is on the +mobile-app side. If the manual `curl` also fails, check: +- Tunnel provider is up (visit `${tunnelUrl}/` in a browser) +- OpenCode is bound to `0.0.0.0` and the tunnel is pointing at the correct port +- Your firewall isn't blocking the port + ### "Push token not registering" **Problem**: Device can't reach the plugin server diff --git a/index.ts b/index.ts index 47889cb..6676dd5 100644 --- a/index.ts +++ b/index.ts @@ -36,6 +36,7 @@ debugLog("LAN-only architecture: plugin handles push tokens, tunnel goes to Open debugLog("[PushPlugin][Mobile] Entry loaded: index.ts"); import * as path from "path"; +import * as os from "os"; import http from "http"; import { tool } from "@opencode-ai/plugin"; @@ -86,14 +87,55 @@ const VALID_PROVIDERS = ["cloudflare", "ngrok", "localtunnel", "auto"] as const; type TunnelProvider = typeof VALID_PROVIDERS[number]; /** - * Get the configured tunnel provider from environment variable - * Defaults to 'auto' if not set or invalid + * Path to persisted tunnel config (written by `opencode-mobile install --provider ...` + * and `opencode-mobile-tunnel-setup`). + */ +const TUNNEL_CONFIG_FILE = path.join( + os.homedir(), + ".config", + "opencode-mobile", + "tunnel-config.json", +); + +/** + * Read the persisted tunnel-config.json, if any. + * Returns null when missing/unreadable so callers can fall back to defaults. + */ +function loadPersistedTunnelConfig(): { provider?: string } | null { + try { + if (!fs.existsSync(TUNNEL_CONFIG_FILE)) return null; + const raw = fs.readFileSync(TUNNEL_CONFIG_FILE, "utf-8"); + const data = JSON.parse(raw) as { provider?: unknown }; + if (typeof data.provider === "string") { + return { provider: data.provider }; + } + return null; + } catch { + return null; + } +} + +/** + * Get the configured tunnel provider. + * + * Resolution order: + * 1. TUNNEL_PROVIDER env var (overrides everything) + * 2. ~/.config/opencode-mobile/tunnel-config.json (written by install/tunnel-setup) + * 3. 'auto' */ function getTunnelProvider(): TunnelProvider { - const provider = process.env.TUNNEL_PROVIDER?.toLowerCase() as TunnelProvider; - if (VALID_PROVIDERS.includes(provider)) { - return provider; + const envProvider = process.env.TUNNEL_PROVIDER?.toLowerCase() as TunnelProvider; + if (VALID_PROVIDERS.includes(envProvider)) { + return envProvider; + } + + const persisted = loadPersistedTunnelConfig(); + const persistedProvider = persisted?.provider?.toLowerCase() as TunnelProvider; + if (persistedProvider && VALID_PROVIDERS.includes(persistedProvider)) { + debugLog(`[Tunnel] Using persisted provider from ${TUNNEL_CONFIG_FILE}: ${persistedProvider}`); + return persistedProvider; } + return "auto"; } @@ -456,7 +498,18 @@ const mobileTool = tool({ const url = rawUrl || urlFromPath || metadataUrl || ""; if (!url || !url.startsWith("http")) { - return "No tunnel URL found."; + return [ + "No tunnel URL found.", + "", + "The mobile tunnel only auto-starts when OpenCode is launched in `serve` mode.", + "If you started OpenCode with the plain `opencode` TUI (or `opencode attach`), the", + "plugin's LAN server and tunnel were not initialized.", + "", + "Start the server with LAN access enabled:", + " opencode serve --hostname 0.0.0.0 --port 4096", + "", + "Then run `/mobile` again to display the QR code.", + ].join("\n"); } const qr = await generateQRCodeAsciiPlain(url); @@ -768,8 +821,15 @@ export const PushNotificationPlugin: Plugin = async (ctx) => { if (!isServeMode) { console.log( - "[opencode-mobile] Plugin init OK; skipping (not in 'serve' mode). " + - "Run: opencode serve ...", + "[opencode-mobile] Plugin loaded in non-serve mode (TUI/attach). " + + "Tunnel + LAN server are NOT started here.", + ); + console.log( + "[opencode-mobile] To enable mobile features (QR + push), restart OpenCode with:", + ); + console.log("[opencode-mobile] opencode serve --hostname 0.0.0.0 --port 4096"); + console.log( + "[opencode-mobile] (--hostname 0.0.0.0 is required so your phone can reach the server over LAN.)", ); return { tool: { diff --git a/src/cli/install.ts b/src/cli/install.ts index 8eaab71..531cc3a 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -2,12 +2,67 @@ import { installPluginToGlobalOpenCodeConfig, installGlobalCommand } from "./ope import { MOBILE_COMMAND_NAME, getMobileCommandMarkdown } from "./mobile-command.js"; import { checkForUpdates, executeUpdate } from "./version-check.js"; import { spawn } from "child_process"; +import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import * as url from "url"; import { createInterface } from "readline"; const PLUGIN_SPEC = "opencode-mobile@latest"; +const VALID_PROVIDERS = ["cloudflare", "ngrok", "localtunnel", "auto", "none"] as const; + +const TUNNEL_CONFIG_DIR = path.join(os.homedir(), ".config", "opencode-mobile"); +const TUNNEL_CONFIG_FILE = path.join(TUNNEL_CONFIG_DIR, "tunnel-config.json"); + +/** + * Persist the user's --provider choice so the plugin can read it at runtime. + * + * Writing here is intentionally additive: tunnel-setup also writes this file with + * provider-specific extras (cloudflaredPath, mode, etc). We only persist a minimal + * `{ provider }` record when the install command itself is invoked with --provider + * but tunnel-setup didn't run (e.g. --skip-tunnel-setup, or "none"). + */ +function persistProviderChoice(provider: string, dryRun: boolean): void { + const normalized = provider.toLowerCase(); + if (!VALID_PROVIDERS.includes(normalized as (typeof VALID_PROVIDERS)[number])) { + return; + } + if (normalized === "none") { + return; + } + if (dryRun) { + console.log(`[Dry Run] Would persist tunnel provider "${normalized}" to ${TUNNEL_CONFIG_FILE}`); + return; + } + + try { + if (!fs.existsSync(TUNNEL_CONFIG_DIR)) { + fs.mkdirSync(TUNNEL_CONFIG_DIR, { recursive: true }); + } + + let existing: Record = {}; + if (fs.existsSync(TUNNEL_CONFIG_FILE)) { + try { + existing = JSON.parse(fs.readFileSync(TUNNEL_CONFIG_FILE, "utf-8")) as Record; + } catch { + existing = {}; + } + } + + const merged = { ...existing, provider: normalized }; + fs.writeFileSync(TUNNEL_CONFIG_FILE, JSON.stringify(merged, null, 2)); + try { + fs.chmodSync(TUNNEL_CONFIG_FILE, 0o600); + } catch { + // Ignore permission errors on Windows + } + console.log(`✅ Persisted tunnel provider "${normalized}" to ${TUNNEL_CONFIG_FILE}`); + } catch (error) { + console.error("⚠️ Failed to persist tunnel provider choice:", error instanceof Error ? error.message : error); + } +} + type InstallCliOptions = { help: boolean; dryRun: boolean; @@ -244,7 +299,25 @@ export async function main(args: string[] = process.argv.slice(2)): Promise