diff --git a/desktop/src/embedded-server.ts b/desktop/src/embedded-server.ts index 04b2254..61e58a4 100644 --- a/desktop/src/embedded-server.ts +++ b/desktop/src/embedded-server.ts @@ -23,6 +23,8 @@ import { buildServerEnv } from './server-config.js' /** Default listen port when the user hasn't pinned one (free-port fallback still applies). */ const DEFAULT_PORT = 3000 +/** How long to wait for the probe of an already-running base app before giving up. */ +const PROBE_TIMEOUT_MS = 1500 export interface EmbeddedServerDeps { readonly prefs: DesktopPrefs @@ -34,6 +36,26 @@ interface ServerHandle { close(): Promise } +/** + * Probe whether a web-terminal base app is ALREADY serving on `port` (typically + * the always-on tunnel daemon that owns :3000 + frpc). `GET /config/ui` returns a + * small JSON object with `allowAutoMode` only from our backend, so it doubles as a + * cheap "is this one of ours?" fingerprint. Any failure (nothing listening, wrong + * shape, timeout) → false, and we spawn our own server as before. + */ +async function probeExistingBaseApp(port: number): Promise { + try { + const res = await fetch(`http://127.0.0.1:${port}/config/ui`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }) + if (!res.ok) return false + const body: unknown = await res.json() + return typeof body === 'object' && body !== null && 'allowAutoMode' in body + } catch { + return false + } +} + /** * Start the embedded backend and return a lifecycle handle. Throws (after * logging) if the compiled server can't be imported or fails to start — the app @@ -41,7 +63,23 @@ interface ServerHandle { */ export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise { const { prefs, logger } = deps - const port = await pickFreePort(prefs.port ?? DEFAULT_PORT) + const preferred = prefs.port ?? DEFAULT_PORT + + // If a base app is already up on the preferred port (the always-on tunnel + // daemon), RIDE it instead of spawning a duplicate on another port: one base app + // then serves the desktop UI, LAN, and the frpc tunnel alike. close() is a no-op + // because we don't own that process — quitting the window must not kill it. + if (await probeExistingBaseApp(preferred)) { + logger.info(`reusing base app already on http://127.0.0.1:${preferred} (tunnel daemon) — not spawning a duplicate`) + return { + port: preferred, + bindHost: '127.0.0.1', + allowedOrigins: [], + close: async () => {}, + } + } + + const port = await pickFreePort(preferred) const env = buildServerEnv({ prefs, port, platform: process.platform, env: process.env }) // dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged.