feat(desktop): ride an existing base app on :3000 instead of duplicating (S1)

When the always-on tunnel daemon already serves :3000, the app reuses it
(close() is a no-op) rather than spawning a second server on another port, so
the GUI and the tunnel share one base app.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 5e427dcf98
commit 10688b0dd1

View File

@@ -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). */ /** Default listen port when the user hasn't pinned one (free-port fallback still applies). */
const DEFAULT_PORT = 3000 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 { export interface EmbeddedServerDeps {
readonly prefs: DesktopPrefs readonly prefs: DesktopPrefs
@@ -34,6 +36,26 @@ interface ServerHandle {
close(): Promise<void> close(): Promise<void>
} }
/**
* 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<boolean> {
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 * 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 * 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<EmbeddedServer> { export async function startEmbeddedServer(deps: EmbeddedServerDeps): Promise<EmbeddedServer> {
const { prefs, logger } = deps 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 }) 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. // dist/ is a sibling of the desktop dir in dev; under resourcesPath when packaged.