diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 2eb540f0526..528bd57b8c4 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -56,6 +56,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), + releaseSingleInstanceLock: Effect.void, isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.relaunch.test.ts b/apps/desktop/src/app/DesktopLifecycle.relaunch.test.ts new file mode 100644 index 00000000000..348f2925437 --- /dev/null +++ b/apps/desktop/src/app/DesktopLifecycle.relaunch.test.ts @@ -0,0 +1,172 @@ +// @effect-diagnostics nodeBuiltinImport:off - Skip predicate runs at collection time, outside any Effect runtime. +import * as NodeFS from "node:fs"; + +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + buildAppImageRelaunchShellCommand, + posixShellSingleQuote, + resolveDesktopRelaunchPlan, +} from "./resolveDesktopRelaunchOptions.ts"; +import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts"; + +describe("resolveDesktopRelaunchPlan", () => { + it.effect("schedules a delayed AppImage re-exec with flag argv only", () => + Effect.sync(() => { + const plan = resolveDesktopRelaunchPlan({ + appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage", + execPath: "/tmp/.mount_t3_codXXXX/t3code", + argv: [ + "/tmp/.mount_t3_codXXXX/t3code", + "--no-sandbox", + "/tmp/.mount_t3_codXXXX/resources/app.asar", + ], + }); + + assert.deepEqual(plan, { + kind: "appimage-delayed", + appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage", + args: ["--no-sandbox"], + delayMs: 1_000, + }); + }), + ); + + it.effect("trims APPIMAGE and falls back to electron relaunch when empty", () => + Effect.sync(() => { + assert.deepEqual( + resolveDesktopRelaunchPlan({ + appImagePath: " /opt/T3-Code.AppImage ", + execPath: "/tmp/.mount/app", + argv: ["/tmp/.mount/app"], + }), + { + kind: "appimage-delayed", + appImagePath: "/opt/T3-Code.AppImage", + args: [], + delayMs: 1_000, + }, + ); + + assert.deepEqual( + resolveDesktopRelaunchPlan({ + appImagePath: " ", + execPath: "/usr/bin/t3-code", + argv: ["/usr/bin/t3-code", "--flag"], + }), + { + kind: "electron", + execPath: "/usr/bin/t3-code", + args: ["--flag"], + }, + ); + }), + ); + + it.effect("preserves packaged non-AppImage exec path and argv", () => + Effect.sync(() => { + const plan = resolveDesktopRelaunchPlan({ + appImagePath: null, + execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code", + argv: ["/Applications/T3 Code.app/Contents/MacOS/T3 Code", "--inspect"], + }); + + assert.deepEqual(plan, { + kind: "electron", + execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code", + args: ["--inspect"], + }); + }), + ); +}); + +describe("buildAppImageRelaunchShellCommand", () => { + it.effect("quotes the AppImage path and delays before exec", () => + Effect.sync(() => { + const command = buildAppImageRelaunchShellCommand({ + appImagePath: "/home/user/T3 Code.AppImage", + args: ["--no-sandbox"], + delayMs: 1_000, + }); + + assert.equal( + command, + `sleep 1 && exec ${posixShellSingleQuote("/home/user/T3 Code.AppImage")} ${posixShellSingleQuote("--no-sandbox")}`, + ); + + assert.equal(posixShellSingleQuote("it's"), `'it'\\''s'`); + }), + ); + + it.effect("omits the fd cleanup unless the shell is known to handle it", () => + Effect.sync(() => { + const base = { + appImagePath: "/opt/T3.AppImage", + args: [], + delayMs: 1_000, + } as const; + + // dash reads `exec 10>&-` as a command named "10" and a failed exec kills + // a non-interactive shell, so emitting this for /bin/sh would abort the + // helper before the re-exec and lose the app entirely. + assert.isFalse(buildAppImageRelaunchShellCommand(base).includes("/proc/$$/fd")); + + const withCleanup = buildAppImageRelaunchShellCommand({ + ...base, + closeInheritedFds: true, + }); + // Must run before the sleep, so the outgoing mount can be released during it. + assert.isTrue(withCleanup.startsWith("for fd in /proc/$$/fd/*;"), withCleanup); + assert.isTrue( + withCleanup.indexOf("exec $n>&-") < withCleanup.indexOf("sleep 1"), + withCleanup, + ); + }), + ); + + it.effect("records a relaunch failure without letting the log block the exec", () => + Effect.sync(() => { + const command = buildAppImageRelaunchShellCommand({ + appImagePath: "/opt/T3.AppImage", + args: [], + delayMs: 1_000, + logPath: "/home/user/.t3/logs/relaunch.log", + }); + + // Wrapping the group in `2>>log` would abort the whole helper when the + // log directory is missing, and would also follow a *successful* exec + // into the relaunched app. Guard with a test and append afterwards. + assert.notInclude(command, "} 2>>"); + assert.include(command, `[ -x ${posixShellSingleQuote("/opt/T3.AppImage")} ] && exec `); + // A bad log path must not matter once we are past the exec. + assert.isTrue( + command.endsWith( + `>>${posixShellSingleQuote("/home/user/.t3/logs/relaunch.log")} 2>/dev/null`, + ), + command, + ); + }), + ); +}); + +describe("scheduleAppImageRelaunch", () => { + // Really spawns the helper. The code path is POSIX-only by construction, so + // gate on the shell it needs rather than on a platform name — on a Windows + // dev machine (a supported setup) this would otherwise fail with ENOENT. + it.effect.skipIf(!NodeFS.existsSync("/bin/sh"))( + "resolves only once the detached helper has actually spawned", + () => + // Resolves on the `spawn` event, well before the helper's sleep elapses. + // The caller depends on this to know it is safe to release the + // single-instance lock and exit rather than vanishing on a failed spawn. + Effect.promise(() => + scheduleAppImageRelaunch({ + kind: "appimage-delayed", + appImagePath: "/bin/true", + args: [], + delayMs: 200, + }), + ), + ); +}); diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index e5ce72f8e48..3961fbe7c55 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -31,6 +31,7 @@ describe("DesktopLifecycle", () => { setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), + releaseSingleInstanceLock: Effect.void, isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index ab03d18f38d..8eabded61bf 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -14,6 +14,8 @@ import * as ElectronApp from "../electron/ElectronApp.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as DesktopState from "./DesktopState.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts"; +import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts"; export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass()( "DesktopLifecycleRelaunchError", @@ -27,6 +29,8 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass + scheduleAppImageRelaunch( + relaunchPlan, + process.env, + // The re-exec fires after we are gone, so its own failures can + // only surface here. + environment.path.join(environment.logDir, "relaunch.log"), + ), + catch: (cause) => new DesktopLifecycleRelaunchError({ reason, cause }), + }); + } else { + yield* electronApp.relaunch({ + execPath: relaunchPlan.execPath, + args: relaunchPlan.args, + }); + } + // Only give up the lock once the relaunch is committed. Neither path has + // started the next process yet (app.relaunch defers its spawn to exit, + // the AppImage helper sleeps first), so releasing here still beats the + // successor's requestSingleInstanceLock(). + yield* electronApp.releaseSingleInstanceLock; yield* electronApp.exit(0); }).pipe( - Effect.catchCause((cause) => { - const error = new DesktopLifecycleRelaunchError({ reason, cause }); - return logLifecycleError(error.message, { error }); - }), + Effect.catchCause((cause) => + Effect.gen(function* () { + const error = new DesktopLifecycleRelaunchError({ reason, cause }); + yield* logLifecycleError(error.message, { error }); + // By this point `quitting` is set and requestDesktopShutdownAndWait + // has already torn the backend down, so there is no working app left + // to return to. Staying alive would just hold the single-instance + // lock on a dead instance, and every later launch would focus *this* + // window instead of starting a usable one. Hand the lock back and + // exit non-zero so the next launch comes up clean. + yield* electronApp.releaseSingleInstanceLock.pipe(Effect.ignore); + yield* electronApp.exit(1); + }), + ), Effect.forkDetach, Effect.asVoid, ); diff --git a/apps/desktop/src/app/resolveDesktopRelaunchOptions.ts b/apps/desktop/src/app/resolveDesktopRelaunchOptions.ts new file mode 100644 index 00000000000..56b173f5ec7 --- /dev/null +++ b/apps/desktop/src/app/resolveDesktopRelaunchOptions.ts @@ -0,0 +1,120 @@ +export interface DesktopElectronRelaunchPlan { + readonly kind: "electron"; + readonly execPath: string; + readonly args: string[]; +} + +export interface DesktopAppImageRelaunchPlan { + readonly kind: "appimage-delayed"; + readonly appImagePath: string; + readonly args: string[]; + /** Delay before re-exec so the current AppImage can unmount its FUSE image. */ + readonly delayMs: number; +} + +export type DesktopRelaunchPlan = DesktopElectronRelaunchPlan | DesktopAppImageRelaunchPlan; + +export const DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS = 1_000; + +/** + * Resolve how the desktop process should restart for the current packaging mode. + * + * AppImage mounts the payload under a temporary directory and sets `APPIMAGE` + * to the outer `.AppImage` path. `process.execPath` / `process.argv` point at + * the mount, which is unmounted when the process exits — so relaunching with + * those paths (or racing a second FUSE mount while the first unmounts) fails + * with "Cannot mount AppImage, please check your FUSE setup." + * + * For AppImage we schedule a delayed re-exec of `$APPIMAGE` after the current + * process exits. Keep only flag-style argv entries (e.g. `--no-sandbox` from + * Gear Lever / Flatpak launches). + */ +export function resolveDesktopRelaunchPlan(input: { + readonly appImagePath?: string | null | undefined; + readonly execPath: string; + readonly argv: readonly string[]; + readonly appImageDelayMs?: number; +}): DesktopRelaunchPlan { + const appImagePath = input.appImagePath?.trim(); + if (appImagePath) { + return { + kind: "appimage-delayed", + appImagePath, + args: input.argv.slice(1).filter((arg) => arg.startsWith("--")), + delayMs: input.appImageDelayMs ?? DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS, + }; + } + + return { + kind: "electron", + execPath: input.execPath, + args: input.argv.slice(1), + }; +} + +/** POSIX-safe single-quoting for `/bin/sh -c` command construction. */ +export function posixShellSingleQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +/** + * Drop file descriptors inherited from the exiting app before re-exec. + * + * Chromium keeps fds open without CLOEXEC (`app.asar`, `icudtl.dat`, the `.pak` + * files, the mount directory itself), and `spawn` cannot close them for us. + * Inherited through this helper's `exec`, they keep the *outgoing* AppImage's + * FUSE mount busy forever: its runtime process parks in `fuse_dev_do_read` and + * never unmounts, so every restart strands a mount and a process. + * + * Only emitted for bash. Chromium's fds are well above 9, and dash — `/bin/sh` + * on Debian and Ubuntu — does not accept multi-digit fd numbers in + * redirections: it reads `exec 10>&-` as running a command named `10`, and a + * failed `exec` terminates a non-interactive shell on the spot. That kills the + * helper before it reaches the re-exec, leaving the app gone for good. `eval` + * does not help, because this is a failed command rather than a parse error, + * so `|| true` never runs. + */ +const CLOSE_INHERITED_FDS_SNIPPET = + 'for fd in /proc/$$/fd/*; do n=${fd##*/}; case "$n" in 0|1|2) continue;; esac; ' + + 'eval "exec $n>&-" 2>/dev/null || true; done'; + +export function buildAppImageRelaunchShellCommand(input: { + readonly appImagePath: string; + readonly args: readonly string[]; + readonly delayMs: number; + /** Only safe under bash; see {@link CLOSE_INHERITED_FDS_SNIPPET}. */ + readonly closeInheritedFds?: boolean; + /** Captures the helper's stderr, so a failed delayed `exec` is diagnosable. */ + readonly logPath?: string | undefined; +}): string { + // Whole seconds only: POSIX specifies `sleep` as taking an integer, and a + // /bin/sh whose sleep rejects "1.0" would short-circuit the `&&` and never + // exec — an app that quits and never returns, which is the failure this + // helper exists to avoid. The spawn succeeds either way, so nothing would + // report it. + const sleepSeconds = Math.max(1, Math.ceil(input.delayMs / 1_000)); + const quotedPath = posixShellSingleQuote(input.appImagePath); + const quotedArgs = input.args.map(posixShellSingleQuote).join(" "); + const execTarget = quotedArgs.length > 0 ? `${quotedPath} ${quotedArgs}` : quotedPath; + // The exec happens after we have exited, so a missing or non-executable + // AppImage can only be reported by leaving a trace behind. Test for it and + // append a line *after* the exec instead of redirecting the whole group: + // + // - `{ ...; } 2>>log` fails the redirection outright when the log's + // directory is missing or unwritable, and the re-exec then never runs at + // all — trading a diagnosable failure for a guaranteed one. + // - that redirection also survives a *successful* exec, so the relaunched + // app would spend its whole life appending Chromium stderr to an + // unrotated file. + // + // `2>/dev/null` on the append keeps a bad log path from mattering, and the + // exec'd process keeps the stdio it was always meant to have. + const relaunch = input.logPath + ? `sleep ${sleepSeconds}; [ -x ${quotedPath} ] && exec ${execTarget}; ` + + `echo ${posixShellSingleQuote(`T3 Code relaunch failed: ${input.appImagePath} is missing or not executable`)} ` + + `>>${posixShellSingleQuote(input.logPath)} 2>/dev/null` + : `sleep ${sleepSeconds} && exec ${execTarget}`; + // Close first, then sleep: releasing the fds up front lets the outgoing + // mount unmount *during* the delay rather than after it. + return input.closeInheritedFds ? `${CLOSE_INHERITED_FDS_SNIPPET}; ${relaunch}` : relaunch; +} diff --git a/apps/desktop/src/app/scheduleAppImageRelaunch.ts b/apps/desktop/src/app/scheduleAppImageRelaunch.ts new file mode 100644 index 00000000000..3716a5b05b4 --- /dev/null +++ b/apps/desktop/src/app/scheduleAppImageRelaunch.ts @@ -0,0 +1,77 @@ +// @effect-diagnostics nodeBuiltinImport:off - Detached AppImage re-exec must outlive the Effect runtime and process exit path; Effect ChildProcess is scope-bound. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; + +import { + buildAppImageRelaunchShellCommand, + type DesktopAppImageRelaunchPlan, +} from "./resolveDesktopRelaunchOptions.ts"; + +/** + * Schedule a delayed re-exec of the outer AppImage after this process exits. + * + * Electron.app.relaunch races the AppImage FUSE unmount and often fails with + * "Cannot mount AppImage, please check your FUSE setup." A short sleep lets + * the current mount clean up before the next runtime attaches. + * + * Resolves once the helper has actually spawned, and rejects if it could not + * (ENOENT on the shell, EAGAIN, ...). `spawn` reports those asynchronously, so + * the caller must await this before exiting — quitting on a failed spawn is an + * app that never comes back, with nothing logged to say why. + * + * Note this cannot vouch for the delayed `exec` itself: if the AppImage is + * moved or loses its execute bit during the sleep, that failure happens after + * we are gone. The helper's stderr is redirected to a log for that case. + */ +const BASH_PATH = "/bin/bash"; + +/** + * Prefer bash for the helper, falling back to `/bin/sh`. + * + * Only bash can close the inherited Chromium fds that otherwise pin the + * outgoing FUSE mount — dash (Debian/Ubuntu `/bin/sh`) rejects fd numbers above + * 9 and would abort the helper mid-script. Every distribution that ships + * AppImages ships bash, so the `/bin/sh` fallback is a belt-and-braces path + * that relaunches correctly but leaves the old mount behind. + */ +function resolveRelaunchShell(): { readonly path: string; readonly isBash: boolean } { + try { + NodeFS.accessSync(BASH_PATH, NodeFS.constants.X_OK); + return { path: BASH_PATH, isBash: true }; + } catch { + return { path: "/bin/sh", isBash: false }; + } +} + +export function scheduleAppImageRelaunch( + plan: DesktopAppImageRelaunchPlan, + env: NodeJS.ProcessEnv = process.env, + logPath?: string, +): Promise { + const shell = resolveRelaunchShell(); + const command = buildAppImageRelaunchShellCommand({ + ...plan, + closeInheritedFds: shell.isBash, + ...(logPath === undefined ? {} : { logPath }), + }); + return new Promise((resolve, reject) => { + const child = NodeChildProcess.spawn(shell.path, ["-c", command], { + detached: true, + stdio: "ignore", + env, + // The current working directory can live inside the AppImage FUSE mount, + // which is unmounted while this helper sleeps. Anchor the helper (and the + // relaunched AppImage that inherits its cwd) outside the mount. + cwd: "/", + }); + child.once("spawn", () => { + // Detach from our event loop so the pending sleep cannot hold up exit. + child.unref(); + resolve(); + }); + child.once("error", (error) => { + child.unref(); + reject(error); + }); + }); +} diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 309dbb21d4a..296d993d61f 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -46,6 +46,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), + resolveTailscaleHttpsEndpoint: Effect.succeed(null), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); function makeEnvironmentLayer( diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..8d59532820d 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -3,8 +3,10 @@ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; @@ -17,6 +19,15 @@ import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; const encoder = new TextEncoder(); +/** What the spawner reports when the `tailscale` binary is not on PATH. */ +const tailscaleBinaryMissing = PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcessSpawner", + method: "spawn", + syscall: "spawn", + description: "spawn tailscale ENOENT", +}); + const emptyNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = {}; const lanNetworkInterfaces: DesktopNetworkInterfaces.NetworkInterfaces = { en0: [ @@ -61,13 +72,6 @@ function mockSpawnerLayer(statusJson = "{}") { ); } -function dieOnSpawnLayer() { - return Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), - ); -} - function makeEnvironmentLayer(baseDir: string, env: Record = {}) { return DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", @@ -209,7 +213,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("persists tailscale serve preferences atomically and reports no-op updates", () => + it.effect("persists tailscale serve preferences atomically after a successful preflight", () => withHarness( emptyNetworkInterfaces, Effect.gen(function* () { @@ -227,11 +231,14 @@ describe("DesktopServerExposure", () => { assert.equal(changed.state.tailscaleServeEnabled, true); assert.equal(changed.state.tailscaleServePort, 8443); - const unchanged = yield* serverExposure.setTailscaleServeEnabled({ + // Re-enabling still relaunches so the child server re-binds Serve to + // its listen port, even when the preference document is unchanged. + const reenabled = yield* serverExposure.setTailscaleServeEnabled({ enabled: true, port: 8443, }); - assert.equal(unchanged.requiresRelaunch, false); + assert.equal(reenabled.requiresRelaunch, true); + assert.equal(reenabled.state.tailscaleServeEnabled, true); const persisted = yield* settings.get; assert.equal(persisted.tailscaleServeEnabled, true); @@ -240,6 +247,427 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("fails enablement with CLI guidance when Tailscale Serve is not available", () => { + const configureUrl = "https://login.tailscale.com/f/serve?node=nExampleNodeIdForTests"; + const failingSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + const isServe = childProcess.args[0] === "serve"; + const stderr = isServe + ? [ + "Serve is not enabled on your tailnet.", + "To enable, visit:", + "", + ` ${configureUrl}`, + ].join("\n") + : ""; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(isServe ? 1 : 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(isServe ? "" : "{}")), + stderr: Stream.make(encoder.encode(stderr)), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 443 }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopServerExposure.DesktopTailscaleServeConfigureError); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetTailscaleServeError(error)); + assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(error)); + assert.equal(error.detail, "Serve is not enabled on your tailnet."); + assert.equal(error.configureUrl, configureUrl); + assert.equal(error.cause?._tag, "TailscaleCommandExitError"); + assert.equal( + error.message, + `Serve is not enabled on your tailnet. To enable, visit: ${configureUrl}`, + ); + // The desktop error turns the label into prose for the toast; the CLI + // error underneath stays label-free because that one gets logged. + const exitCause = error.cause; + if (exitCause?._tag !== "TailscaleCommandExitError") { + throw new Error(`expected TailscaleCommandExitError, got ${String(exitCause?._tag)}`); + } + assert.equal(exitCause.stderrDiagnostic, "serve-not-enabled"); + assert.equal( + exitCause.message, + `tailscale serve exited with code 1. To enable, visit: ${configureUrl}`, + ); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, false); + }), + {}, + failingSpawner, + ); + }); + + it.effect("reports the actionable CLI hint when the tailscale binary cannot be spawned", () => { + const unspawnableSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.fail(tailscaleBinaryMissing)), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const error = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 443 }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopServerExposure.DesktopTailscaleServeConfigureError); + assert.equal(error.cause?._tag, "TailscaleCommandSpawnError"); + // Tailscale missing from PATH is the most common failure; the toast must + // say so instead of the generic "on port 4173" fallback. + assert.equal( + error.message, + "Could not run the tailscale CLI. Is Tailscale installed and on PATH?", + ); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, false); + }), + {}, + unspawnableSpawner, + ); + }); + + it.effect("tears Serve down from the main process when disabling", () => { + const tailscaleCommands: Array> = []; + const recordingSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + tailscaleCommands.push(childProcess.args); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("{}")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true, port: 8443 }); + + tailscaleCommands.length = 0; + const disabled = yield* serverExposure.setTailscaleServeEnabled({ enabled: false }); + + assert.equal(disabled.state.tailscaleServeEnabled, false); + // Disabling must not depend on the child server's acquireRelease + // finalizer — after a failed relaunch that child has none, and Serve + // would stay live on the tailnet while settings said disabled. + assert.deepEqual(tailscaleCommands, [["serve", "--https=8443", "off"]]); + + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, false); + }), + {}, + recordingSpawner, + ); + }); + + it.effect("keeps Serve enabled when the main-process teardown fails", () => { + const unspawnableAfterEnable = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { readonly args: ReadonlyArray }; + if (childProcess.args.at(-1) === "off") { + return Effect.fail(tailscaleBinaryMissing); + } + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("{}")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true, port: 8443 }); + + const error = yield* serverExposure + .setTailscaleServeEnabled({ enabled: false }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopServerExposure.DesktopTailscaleServeConfigureError); + assert.equal(error.enabled, false); + // The CLI hint must not crowd out the exposure warning: a teardown that + // only says "could not run the tailscale CLI" reads like a no-op, when + // in fact the backend may still be served over the tailnet. + assert.equal( + error.message, + "Could not run the tailscale CLI. Is Tailscale installed and on PATH? It may still be reachable on your tailnet.", + ); + + // Never record "disabled" for a teardown we could not perform — Serve + // may still be reachable on the tailnet. + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, true); + }), + {}, + unspawnableAfterEnable, + ); + }); + + it.effect("rolls the preflighted binding back when persistence dies", () => { + // `tapError` only sees typed failures, so a defect (and likewise an + // interrupt, e.g. the app quitting mid-toggle) used to skip the rollback and + // leave Serve live on the tailnet with settings still saying disabled. + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setServerExposureMode: () => Effect.die("unexpected exposure mode change"), + setTailscaleServe: () => Effect.die("settings file vanished"), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + const tailscaleCommands: Array> = []; + const recordingSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + tailscaleCommands.push((command as unknown as { args: ReadonlyArray }).args); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("{}")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + tailscaleCommands.length = 0; + const exit = yield* serverExposure + .setTailscaleServeEnabled({ enabled: true, port: 8443 }) + .pipe(Effect.exit); + + assert.isTrue(Exit.isFailure(exit)); + // Enabled, then rolled back — the tailnet must not be left exposed. + assert.deepEqual(tailscaleCommands, [ + ["serve", "--bg", "--https=8443", "http://127.0.0.1:4173"], + ["serve", "--https=8443", "off"], + ]); + }), + {}, + recordingSpawner, + settingsLayer, + ); + }); + + it.effect("still disables when Serve was already unbound", () => { + // `tailscale serve --https= off` exits 1 with "handler does not + // exist" when nothing is bound. The tailnet already matches what the user + // asked for, so this must not strand the toggle on. + const alreadyOffSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const args = (command as unknown as { args: ReadonlyArray }).args; + const isOff = args.at(-1) === "off"; + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(isOff ? 1 : 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(isOff ? "" : "{}")), + stderr: Stream.make( + encoder.encode( + isOff ? "error: failed to remove web serve: handler does not exist" : "", + ), + ), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + + yield* settings.load; + yield* serverExposure.configureFromSettings({ port: 4173 }); + yield* serverExposure.setTailscaleServeEnabled({ enabled: true, port: 8443 }); + + const disabled = yield* serverExposure.setTailscaleServeEnabled({ enabled: false }); + + assert.equal(disabled.state.tailscaleServeEnabled, false); + const persisted = yield* settings.get; + assert.equal(persisted.tailscaleServeEnabled, false); + }), + {}, + alreadyOffSpawner, + ); + }); + + it.effect("re-enables Serve when persisting the disable fails", () => { + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/desktop-settings.json", + cause: new Error("disk exploded"), + }); + const enabledSettings = { + ...DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS, + tailscaleServeEnabled: true, + tailscaleServePort: 8443, + }; + const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(enabledSettings), + load: Effect.succeed(enabledSettings), + setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setServerExposureMode: () => Effect.die("unexpected exposure mode change"), + setTailscaleServe: () => Effect.fail(settingsFailure), + setUpdateChannel: () => Effect.die("unexpected update channel change"), + setWslBackendEnabled: () => Effect.die("unexpected WSL backend toggle"), + setWslDistro: () => Effect.die("unexpected WSL distro change"), + setWslOnly: () => Effect.die("unexpected WSL-only toggle"), + applyWslWindowsFallback: Effect.die("unexpected WSL Windows fallback"), + applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + + const tailscaleCommands: Array> = []; + const recordingSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + tailscaleCommands.push((command as unknown as { args: ReadonlyArray }).args); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("{}")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + + return withHarness( + emptyNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + tailscaleCommands.length = 0; + const error = yield* serverExposure + .setTailscaleServeEnabled({ enabled: false }) + .pipe(Effect.flip); + + assert.instanceOf(error, DesktopServerExposure.DesktopTailscaleServePersistenceError); + // Serve was torn down before the write, and the write failed — so the + // binding must come back, or settings would advertise an HTTPS endpoint + // that no longer answers. + assert.deepEqual(tailscaleCommands, [ + ["serve", "--https=8443", "off"], + ["serve", "--bg", "--https=8443", "http://127.0.0.1:4173"], + ]); + }), + {}, + recordingSpawner, + settingsLayer, + ); + }); + it.effect("preserves persistence request context and the settings failure chain", () => { const diskFailure = new Error("disk exploded"); const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ @@ -261,6 +689,39 @@ describe("DesktopServerExposure", () => { applyWslWindowsFallbackInMemory: Effect.die("unexpected WSL Windows fallback"), } satisfies DesktopAppSettings.DesktopAppSettings["Service"]); + const tailscaleCommands: Array<{ + readonly command: string; + readonly args: ReadonlyArray; + }> = []; + const recordingSpawner = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + tailscaleCommands.push({ + command: childProcess.command, + args: childProcess.args, + }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode("{}")), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }), + ); + return withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -300,9 +761,22 @@ describe("DesktopServerExposure", () => { "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).", ); assert.notInclude(tailscaleError.message, diskFailure.message); + + // Preflight turned Serve on; persistence failure must roll it back so we + // do not leave HTTPS exposure active while settings still say disabled. + assert.deepEqual(tailscaleCommands, [ + { + command: "tailscale", + args: ["serve", "--bg", "--https=8443", "http://127.0.0.1:4173"], + }, + { + command: "tailscale", + args: ["serve", "--https=8443", "off"], + }, + ]); }), {}, - undefined, + recordingSpawner, settingsLayer, ); }); @@ -333,18 +807,46 @@ describe("DesktopServerExposure", () => { // mode stays at default "local-only", tailscaleServeEnabled stays false. const endpoints = yield* serverExposure.getAdvertisedEndpoints; - // Only the loopback endpoint; no tailscale spawn means the dieOnSpawnLayer - // would have crashed the test if the gate was missing. + // Only the loopback endpoint; no tailscale spawn means dieOnSpawn would + // crash if the passive gate were missing. assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), ["http://127.0.0.1:4173/"], ); }), {}, - dieOnSpawnLayer(), + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => Effect.die("unexpected tailscale spawn")), + ), ), ); + it.effect("resolves Tailscale HTTPS on demand while remaining local-only", () => { + const statusJson = `{"Self":{"DNSName":"desktop.tail.ts.net.","TailscaleIPs":["100.90.1.2"]}}`; + return withHarness( + lanNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + yield* serverExposure.configureFromSettings({ port: 4173 }); + + const passive = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + passive.map((endpoint) => endpoint.httpBaseUrl), + ["http://127.0.0.1:4173/"], + ); + + const endpoint = yield* serverExposure.resolveTailscaleHttpsEndpoint; + assert.isNotNull(endpoint); + assert.equal(endpoint?.httpBaseUrl, "https://desktop.tail.ts.net/"); + assert.equal(endpoint?.status, "unavailable"); + assert.equal(endpoint?.label, "Tailscale HTTPS"); + }), + {}, + mockSpawnerLayer(statusJson), + ); + }); + it.effect("uses ConfigProvider desktop exposure overrides", () => withHarness( lanNetworkInterfaces, diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f..9dcd3de6560 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,10 +9,20 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { + DEFAULT_TAILSCALE_SERVE_PORT, + describeTailscaleStderrDiagnostic, + disableTailscaleServe, + ensureTailscaleServe, + formatTailscaleServeUserMessage, + readTailscaleStatus, + TailscaleCommandError, + type TailscaleStderrDiagnostic, +} from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -203,6 +213,18 @@ const resolveDesktopCoreAdvertisedEndpoints = ( return endpoints; }; +/** + * Reason text for a `tailscale` exit failure, or undefined when the CLI said + * nothing we recognize. Callers fall back to their own wording rather than + * echoing the exit code at the user. + */ +function describeTailscaleServeExitCause(cause: { + readonly stderrDiagnostic?: TailscaleStderrDiagnostic | undefined; +}): string | undefined { + if (cause.stderrDiagnostic === undefined) return undefined; + return describeTailscaleStderrDiagnostic(cause.stderrDiagnostic) ?? undefined; +} + export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErrorClass()( "DesktopServerExposureNoNetworkAddressError", { @@ -239,6 +261,54 @@ export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorCla } } +export class DesktopTailscaleServeConfigureError extends Schema.TaggedErrorClass()( + "DesktopTailscaleServeConfigureError", + { + enabled: Schema.Boolean, + port: Schema.NullOr(Schema.Number), + localPort: Schema.Number, + /** + * Safe CLI summary extracted from known `tailscale serve` patterns. + * Never raw stderr; optional when this is a pure validation failure. + */ + detail: Schema.optionalKey(Schema.String), + /** Admin URL from the CLI (`login.tailscale.com/f/serve...`) when present. */ + configureUrl: Schema.NullOr(Schema.String), + /** Immediate CLI failure when configuration was attempted; absent for validation-only errors. */ + cause: Schema.optionalKey(TailscaleCommandError), + }, +) { + override get message(): string { + // Derive only from structural attributes — never cause.message. + if (this.localPort <= 0 && this.cause === undefined) { + return "Local backend is not ready yet. Try again in a moment."; + } + const parts: string[] = []; + if (this.detail !== undefined) { + parts.push(this.detail); + } else if (this.enabled) { + parts.push( + `Failed to configure Tailscale Serve for the local backend on port ${this.localPort}.`, + ); + } else { + parts.push( + `Failed to turn off Tailscale Serve on port ${this.port ?? DEFAULT_TAILSCALE_SERVE_PORT}.`, + ); + } + if (!this.enabled) { + // Append regardless of `detail`: whatever the CLI failed on, the point the + // user must not miss is that the backend may still be exposed. A teardown + // failure that only says "could not run the tailscale CLI" reads like a + // no-op, which is exactly the silent-exposure case this error exists for. + parts.push("It may still be reachable on your tailnet."); + } + if (this.configureUrl !== null) { + parts.push(`To enable, visit: ${this.configureUrl}`); + } + return parts.join(" "); + } +} + export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, @@ -246,10 +316,21 @@ export const DesktopServerExposureSetModeError = Schema.Union([ export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); +export const DesktopServerExposureSetTailscaleServeError = Schema.Union([ + DesktopTailscaleServePersistenceError, + DesktopTailscaleServeConfigureError, +]); +export type DesktopServerExposureSetTailscaleServeError = + typeof DesktopServerExposureSetTailscaleServeError.Type; +export const isDesktopServerExposureSetTailscaleServeError = Schema.is( + DesktopServerExposureSetTailscaleServeError, +); + export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, DesktopServerExposureModePersistenceError, DesktopTailscaleServePersistenceError, + DesktopTailscaleServeConfigureError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); @@ -281,8 +362,15 @@ export class DesktopServerExposure extends Context.Service< readonly setTailscaleServeEnabled: (input: { readonly enabled: boolean; readonly port?: number; - }) => Effect.Effect; + }) => Effect.Effect; readonly getAdvertisedEndpoints: Effect.Effect; + /** + * User-initiated MagicDNS resolve for the Tailscale HTTPS setup flow. + * Spawns `tailscale status` (cached). Unlike getAdvertisedEndpoints, this + * runs even when network access is local-only and Serve is still off so + * Settings can offer the toggle without probing on every panel mount. + */ + readonly resolveTailscaleHttpsEndpoint: Effect.Effect; } >()("@t3tools/desktop/backend/DesktopServerExposure") {} @@ -415,14 +503,15 @@ export const make = Effect.gen(function* () { // Cache the `tailscale status` spawn for the TTL. On macOS, the Mac App // Store Tailscale CLI lives inside Tailscale's sandbox container, so each // spawn re-triggers the "Other apps" TCC prompt. - const cachedReadMagicDnsName = yield* Effect.cachedWithTTL( - readTailscaleStatus.pipe( - Effect.map((status) => status.magicDnsName), - Effect.orElseSucceed(() => null), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - ), - TAILSCALE_STATUS_CACHE_TTL, - ); + const [cachedReadMagicDnsName, invalidateCachedMagicDnsName] = + yield* Effect.cachedInvalidateWithTTL( + readTailscaleStatus.pipe( + Effect.map((status) => status.magicDnsName), + Effect.orElseSucceed(() => null), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + ), + TAILSCALE_STATUS_CACHE_TTL, + ); const readNetworkInterfaces = networkInterfaces.read; @@ -492,6 +581,118 @@ export const make = Effect.gen(function* () { enabled: input.enabled, ...(input.port === undefined ? {} : { port: input.port }), }); + + // Preflight Serve configuration before persisting so Enable can fail loudly + // with the same CLI guidance (including the admin setup URL) instead of a + // silent restart that leaves the toggle unchecked. If settings write fails + // after Serve is already active, roll Serve back so we do not leave + // unintended network exposure with UI/settings still showing disabled. + let preflightedServePort: number | null = null; + let revertEnableBinding: { readonly servePort: number; readonly localPort: number } | null = + null; + if (input.enabled) { + const current = yield* Ref.get(stateRef); + const servePort = input.port ?? current.tailscaleServePort; + const localPort = current.port; + if (localPort <= 0) { + return yield* new DesktopTailscaleServeConfigureError({ + enabled: true, + port: servePort, + localPort, + configureUrl: null, + }); + } + + yield* ensureTailscaleServe({ + localPort, + servePort, + localHost: "127.0.0.1", + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError((cause) => { + // Lift only safe structural diagnostics from the CLI error; keep the + // full TailscaleCommandError as cause for the error chain/stack. + // Exit errors already carry a vetted `detail`; spawn/timeout/output + // errors have none, and the generic "port N" fallback hides the most + // common failure (Tailscale not installed or not on PATH), so use the + // shared user-facing copy for those. + const exitDetail = + cause._tag === "TailscaleCommandExitError" + ? describeTailscaleServeExitCause(cause) + : formatTailscaleServeUserMessage(cause); + const configureUrl = + cause._tag === "TailscaleCommandExitError" ? (cause.configureUrl ?? null) : null; + return new DesktopTailscaleServeConfigureError({ + enabled: true, + port: servePort, + localPort, + ...(exitDetail === undefined ? {} : { detail: exitDetail }), + configureUrl, + cause, + }); + }), + ); + // Only roll back a binding this preflight actually created. When Serve + // was already enabled on the same port, `tailscale serve --bg` is a + // no-op and disabling it on a persistence failure would tear down a + // working HTTPS endpoint that settings still record as enabled. + preflightedServePort = + current.tailscaleServeEnabled && current.tailscaleServePort === servePort + ? null + : servePort; + } else { + // Tear Serve down here rather than leaving it to the child server's + // acquireRelease finalizer. That finalizer only exists when the *current* + // child booted with Serve enabled, so after a failed relaunch (which + // `lifecycle.relaunch` logs and swallows) disabling would persist + // `enabled: false` while `tailscale serve --https=` stayed live on + // the tailnet. Fail loudly instead of reporting a teardown we did not do. + const current = yield* Ref.get(stateRef); + let removedBinding = false; + if (current.tailscaleServeEnabled) { + const servePort = current.tailscaleServePort; + removedBinding = yield* disableTailscaleServe({ servePort }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.as(true), + // `serve off` on a port with nothing bound exits non-zero with + // "handler does not exist". The tailnet is already in the state the + // user asked for, so treating that as a failure would strand the + // toggle on and warn about an exposure that does not exist — and it + // would bite hardest in the very case this eager teardown exists + // for, where settings say enabled but no child ever bound Serve. + Effect.catchTags({ + TailscaleCommandExitError: (cause) => + cause.stderrDiagnostic === "no-existing-handler" + ? Effect.succeed(false) + : Effect.fail(cause), + }), + Effect.mapError((cause) => { + const exitDetail = + cause._tag === "TailscaleCommandExitError" + ? describeTailscaleServeExitCause(cause) + : formatTailscaleServeUserMessage(cause); + return new DesktopTailscaleServeConfigureError({ + enabled: false, + port: servePort, + localPort: current.port, + ...(exitDetail === undefined ? {} : { detail: exitDetail }), + configureUrl: null, + cause, + }); + }), + ); + // Mirror the enable path's rollback. Serve is already down; if the + // settings write now fails, both stateRef and the persisted document + // still say enabled, so without this the UI would advertise an HTTPS + // endpoint that no longer answers. Only when we actually removed a + // binding, though — re-creating one that was never there would expose + // the backend rather than restore it. + if (removedBinding && current.port > 0) { + revertEnableBinding = { servePort, localPort: current.port }; + } + } + } + const result = yield* desktopSettings .setTailscaleServe({ enabled: input.enabled, @@ -506,6 +707,33 @@ export const make = Effect.gen(function* () { cause, }), ), + // Put the tailnet back the way the still-unchanged settings describe + // it. `onExit` rather than `tapError`: the tailnet has already been + // changed by this point, so an interrupt (app quitting mid-toggle) or + // a defect must undo it too — `tapError` sees only typed failures, and + // skipping the rollback on those exits is exactly the silent-exposure + // case this whole path exists to prevent. Best-effort, since the + // original persistence failure is what the caller needs to see. + Effect.onExit((exit) => { + if (Exit.isSuccess(exit)) return Effect.void; + if (preflightedServePort !== null) { + return disableTailscaleServe({ servePort: preflightedServePort }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.ignore, + ); + } + if (revertEnableBinding !== null) { + return ensureTailscaleServe({ + localPort: revertEnableBinding.localPort, + servePort: revertEnableBinding.servePort, + localHost: "127.0.0.1", + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.ignore, + ); + } + return Effect.void; + }), ); const nextState = yield* Ref.updateAndGet(stateRef, (current) => ({ @@ -516,40 +744,66 @@ export const make = Effect.gen(function* () { return { state: toContractState(nextState), - requiresRelaunch: result.changed, + // Always relaunch after a successful enable preflight so the child + // server re-binds Serve to its current listen port (which may change). + requiresRelaunch: result.changed || input.enabled, }; }, ); + const resolveTailscaleEndpoints = Effect.fn("desktop.serverExposure.resolveTailscaleEndpoints")( + function* (input: { readonly state: RuntimeState }) { + const currentNetworkInterfaces = yield* readNetworkInterfaces; + return yield* resolveTailscaleAdvertisedEndpoints({ + port: input.state.port, + serveEnabled: input.state.tailscaleServeEnabled, + servePort: input.state.tailscaleServePort, + networkInterfaces: currentNetworkInterfaces, + readMagicDnsName: cachedReadMagicDnsName, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + }, + ); + const getAdvertisedEndpoints = Effect.gen(function* () { const state = yield* Ref.get(stateRef); - const currentNetworkInterfaces = yield* readNetworkInterfaces; const coreEndpoints = resolveDesktopCoreAdvertisedEndpoints({ port: state.port, exposure: toResolvedExposure(state), customHttpsEndpointUrls: config.desktopHttpsEndpointUrls, }); - // Don't spawn the Tailscale CLI when the user hasn't opted into any - // network exposure. The spawn itself triggers a macOS "Other apps" - // TCC prompt on Mac App Store Tailscale builds. + // Don't spawn the Tailscale CLI on every Connections panel mount when the + // user hasn't opted into network exposure or Serve yet. MAS Tailscale's + // CLI lives in a sandbox, so each spawn re-fires the "Other apps" TCC + // prompt (#2745). Tailscale HTTPS setup uses resolveTailscaleHttpsEndpoint + // on explicit user interaction instead (Serve is independent of LAN bind). if (state.mode !== "network-accessible" && !state.tailscaleServeEnabled) { return coreEndpoints; } - const tailscaleEndpoints = yield* resolveTailscaleAdvertisedEndpoints({ - port: state.port, - serveEnabled: state.tailscaleServeEnabled, - servePort: state.tailscaleServePort, - networkInterfaces: currentNetworkInterfaces, - readMagicDnsName: cachedReadMagicDnsName, - }).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.provideService(HttpClient.HttpClient, httpClient), - ); + const tailscaleEndpoints = yield* resolveTailscaleEndpoints({ state }); return [...coreEndpoints, ...tailscaleEndpoints]; }).pipe(Effect.withSpan("desktop.serverExposure.getAdvertisedEndpoints")); + const resolveTailscaleHttpsEndpoint = Effect.gen(function* () { + const state = yield* Ref.get(stateRef); + // This resolve is explicitly user-initiated ("turn on Tailscale HTTPS"), and + // the failure toast tells the user to start Tailscale and retry. Serving a + // stale cached miss for the rest of the TTL would make that retry fail for + // no reason, so force a fresh `tailscale status` here. + yield* invalidateCachedMagicDnsName; + const tailscaleEndpoints = yield* resolveTailscaleEndpoints({ state }); + return ( + tailscaleEndpoints.find( + (endpoint) => + endpoint.provider.id === "tailscale" && endpoint.httpBaseUrl.startsWith("https:"), + ) ?? null + ); + }).pipe(Effect.withSpan("desktop.serverExposure.resolveTailscaleHttpsEndpoint")); + return DesktopServerExposure.of({ getState, backendConfig, @@ -557,6 +811,7 @@ export const make = Effect.gen(function* () { setMode, setTailscaleServeEnabled, getAdvertisedEndpoints, + resolveTailscaleHttpsEndpoint, }); }); diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 077b343959c..e3ab1cc06ff 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -15,6 +15,7 @@ const { relaunchMock, removeListenerMock, requestSingleInstanceLockMock, + releaseSingleInstanceLockMock, setAboutPanelOptionsMock, setAppUserModelIdMock, setAsDefaultProtocolClientMock, @@ -36,6 +37,7 @@ const { relaunchMock: vi.fn(), removeListenerMock: vi.fn(), requestSingleInstanceLockMock: vi.fn(() => true), + releaseSingleInstanceLockMock: vi.fn(), setAboutPanelOptionsMock: vi.fn(), setAppUserModelIdMock: vi.fn(), setAsDefaultProtocolClientMock: vi.fn(() => true), @@ -68,6 +70,7 @@ vi.mock("electron", () => ({ relaunch: relaunchMock, removeListener: removeListenerMock, requestSingleInstanceLock: requestSingleInstanceLockMock, + releaseSingleInstanceLock: releaseSingleInstanceLockMock, runningUnderARM64Translation: false, setAboutPanelOptions: setAboutPanelOptionsMock, setAsDefaultProtocolClient: setAsDefaultProtocolClientMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index 5f8052f902d..2ad16c1e585 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -58,6 +58,7 @@ export class ElectronApp extends Context.Service< readonly setAppUserModelId: (id: string) => Effect.Effect; readonly requestSingleInstanceLock: Effect.Effect; readonly getAppMetrics: Effect.Effect>; + readonly releaseSingleInstanceLock: Effect.Effect; readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( protocol: string, @@ -155,6 +156,9 @@ export const make = ElectronApp.of({ }), requestSingleInstanceLock: Effect.sync(() => Electron.app.requestSingleInstanceLock()), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), + releaseSingleInstanceLock: Effect.sync(() => { + Electron.app.releaseSingleInstanceLock(); + }), isDefaultProtocolClient: (protocol) => Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), setAsDefaultProtocolClient: (protocol, path, args) => diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..c389ee5d1c6 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -10,6 +10,7 @@ import { import { getAdvertisedEndpoints, getServerExposureState, + resolveTailscaleHttpsEndpoint, setServerExposureMode, setTailscaleServeEnabled, } from "./methods/serverExposure.ts"; @@ -72,6 +73,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); yield* ipc.handle(getAdvertisedEndpoints); + yield* ipc.handle(resolveTailscaleHttpsEndpoint); yield* ipc.handle(getWslState); yield* ipc.handle(setWslBackendEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5988b1e42f9..c5c0b1db91f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -34,6 +34,7 @@ export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-st export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; +export const RESOLVE_TAILSCALE_HTTPS_ENDPOINT_CHANNEL = "desktop:resolve-tailscale-https-endpoint"; export const GET_WSL_STATE_CHANNEL = "desktop:get-wsl-state"; export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"; export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; diff --git a/apps/desktop/src/ipc/methods/serverExposure.ts b/apps/desktop/src/ipc/methods/serverExposure.ts index 9a9ce768973..e582286ca09 100644 --- a/apps/desktop/src/ipc/methods/serverExposure.ts +++ b/apps/desktop/src/ipc/methods/serverExposure.ts @@ -13,7 +13,10 @@ import * as DesktopIpc from "../DesktopIpc.ts"; const SetTailscaleServeEnabledInput = Schema.Struct({ enabled: Schema.Boolean, - port: Schema.optionalKey(Schema.Number), + // Reject out-of-range ports at the boundary. Settings persistence silently + // normalizes anything invalid to 443, so a bare Schema.Number would let the + // CLI bind one port while settings recorded another. + port: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }))), }); export const getServerExposureState = DesktopIpc.makeIpcMethod({ @@ -67,3 +70,13 @@ export const getAdvertisedEndpoints = DesktopIpc.makeIpcMethod({ return yield* serverExposure.getAdvertisedEndpoints; }), }); + +export const resolveTailscaleHttpsEndpoint = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESOLVE_TAILSCALE_HTTPS_ENDPOINT_CHANNEL, + payload: Schema.Void, + result: Schema.NullOr(AdvertisedEndpoint), + handler: Effect.fn("desktop.ipc.serverExposure.resolveTailscaleHttpsEndpoint")(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + return yield* serverExposure.resolveTailscaleHttpsEndpoint; + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9f01baeed90..28890cf5669 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -91,6 +91,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { setTailscaleServeEnabled: (input) => ipcRenderer.invoke(IpcChannels.SET_TAILSCALE_SERVE_ENABLED_CHANNEL, input), getAdvertisedEndpoints: () => ipcRenderer.invoke(IpcChannels.GET_ADVERTISED_ENDPOINTS_CHANNEL), + resolveTailscaleHttpsEndpoint: () => + ipcRenderer.invoke(IpcChannels.RESOLVE_TAILSCALE_HTTPS_ENDPOINT_CHANNEL), getWslState: () => ipcRenderer.invoke(IpcChannels.GET_WSL_STATE_CHANNEL), setWslBackendEnabled: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_BACKEND_ENABLED_CHANNEL, enabled), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index 475be3da151..00224f5553a 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -35,6 +35,7 @@ function makeElectronAppLayer( setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), + releaseSingleInstanceLock: Effect.void, getAppMetrics: Effect.sync(() => { onMetricsRead(); return metrics; diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 34fc4447146..ed9b7ad81a4 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -41,6 +41,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setAppUserModelId: () => Effect.void, requestSingleInstanceLock: Effect.succeed(true), getAppMetrics: Effect.succeed([]), + releaseSingleInstanceLock: Effect.void, isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 3cd06cfcf32..888766120f7 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -144,6 +144,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.die("unexpected getAdvertisedEndpoints"), + resolveTailscaleHttpsEndpoint: Effect.die("unexpected resolveTailscaleHttpsEndpoint"), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.test.ts b/apps/desktop/src/wsl/DesktopWslBackend.test.ts index 2f58c6adcfb..c738508f9a5 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.test.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.test.ts @@ -63,6 +63,7 @@ const serverExposureLayer = Layer.succeed(DesktopServerExposure.DesktopServerExp setMode: () => Effect.die("unexpected setMode"), setTailscaleServeEnabled: () => Effect.die("unexpected setTailscaleServeEnabled"), getAdvertisedEndpoints: Effect.succeed([]), + resolveTailscaleHttpsEndpoint: Effect.succeed(null), } satisfies DesktopServerExposure.DesktopServerExposure["Service"]); const backendConfigurationLayer = Layer.succeed( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e0d36e99bc9..69280b3826f 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -106,7 +106,11 @@ import { import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; -import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; +import { + disableTailscaleServe, + ensureTailscaleServe, + formatTailscaleServeUserMessage, +} from "@t3tools/tailscale"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. // T3's primary transport is long-lived WebSocket RPC, whose Effect scope finalizer @@ -480,6 +484,7 @@ export const makeServerLayer = Layer.unwrap( Effect.catch((cause) => Effect.logWarning("Failed to configure Tailscale Serve", { cause, + message: formatTailscaleServeUserMessage(cause), localPort, servePort: config.tailscaleServePort, }).pipe(Effect.as(null)), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..b682ea5f909 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -40,7 +40,11 @@ import * as Option from "effect/Option"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; -import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; +import { + isShareableOrigin, + resolveDesktopPairingUrl, + resolveHostedPairingUrl, +} from "./pairingUrls"; import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; import { SettingsPageContainer, @@ -71,6 +75,7 @@ import { AlertDialogPopup, AlertDialogTitle, } from "../ui/alert-dialog"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { QRCodeSvg } from "../ui/qr-code"; import { Spinner } from "../ui/spinner"; @@ -137,6 +142,43 @@ import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectLis import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +/** Matches the admin URL Tailscale CLI prints when Serve is disabled on the tailnet. */ +const TAILSCALE_SERVE_CONFIGURE_URL_PATTERN = + /https:\/\/login\.tailscale\.com\/f\/serve\?[A-Za-z0-9._~:/?#[\]@!$&'()*+,;=%-]+/i; + +const TAILSCALE_SERVE_CONFIGURE_URL_BASE = "https://login.tailscale.com/f/serve"; +const TAILSCALE_NODE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; + +/** + * Mirrors `extractTailscaleServeConfigureUrl` in `@t3tools/tailscale`, which + * cannot be imported here — it is a Node-only package. + * + * Rebuilds the URL from constants instead of returning the match: this one is + * parsed out of a message rendered in the UI and handed to `openExternal`, so + * the query has to be an allowlist rather than whatever the text carried. + */ +function extractTailscaleServeConfigureUrl(text: string): string | null { + const match = text.match(TAILSCALE_SERVE_CONFIGURE_URL_PATTERN); + if (!match?.[0] || match[0].length > 500) { + return null; + } + try { + const parsed = new URL(match[0]); + if (parsed.protocol !== "https:") return null; + if (parsed.hostname !== "login.tailscale.com") return null; + if (!parsed.pathname.startsWith("/f/serve")) return null; + + const sanitized = new URL(TAILSCALE_SERVE_CONFIGURE_URL_BASE); + const node = parsed.searchParams.get("node"); + if (node !== null && TAILSCALE_NODE_ID_PATTERN.test(node)) { + sanitized.searchParams.set("node", node); + } + return sanitized.toString(); + } catch { + return null; + } +} const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; @@ -373,6 +415,62 @@ function formatDesktopSshConnectionError(error: unknown): string { return withoutTaggedErrorPrefix.trim() || fallback; } +const IPC_INVOKE_ERROR_PREFIX_PATTERN = /^Error invoking remote method '[^']*':\s*/u; +const TAGGED_ERROR_PREFIX_PATTERN = /^[A-Z][A-Za-z0-9]*Error:\s*/u; + +/** + * Electron rejects `ipcRenderer.invoke` with `Error invoking remote method + * '': : `. Strip that machinery so the toast + * shows the CLI guidance (and setup URL) the main process derived. + */ +function formatDesktopTailscaleServeError(error: unknown, fallback: string): string { + if (!(error instanceof Error)) return fallback; + let message = error.message.replace(IPC_INVOKE_ERROR_PREFIX_PATTERN, "").trim(); + while (TAGGED_ERROR_PREFIX_PATTERN.test(message)) { + message = message.replace(TAGGED_ERROR_PREFIX_PATTERN, "").trim(); + } + return message || fallback; +} + +/** + * Inline failure for the Tailscale HTTPS dialogs. Both dialogs stay open when + * the CLI call fails, so the reason has to be visible inside them — a toast + * behind a modal is easy to miss, and the admin setup link is the actual fix. + */ +function TailscaleServeErrorAlert({ + message, + title, + onOpenConfigureUrl, +}: { + readonly message: string; + readonly title: string; + readonly onOpenConfigureUrl: (configureUrl: string) => void; +}) { + const configureUrl = extractTailscaleServeConfigureUrl(message); + // Keep the URL in the message. The CLI phrases this as "... To enable, + // visit: ", so dropping the URL leaves a dangling colon; `wrap-anywhere` + // lets it break inside the dialog's narrow column instead. The button is a + // shortcut, not a replacement — the text stays selectable and copyable. + return ( + + {title} + {message} + {configureUrl ? ( + + + + ) : null} + + ); +} + const ENDPOINT_ROW_CLASSNAME = "rounded-xl px-3 py-2.5 sm:px-4"; type AccessSectionPresentation = "current" | "endpoint-rail"; @@ -569,9 +667,9 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ endpointPairingUrl ?? (endpointUrl != null && endpointUrl !== "" ? (hostedPairingUrl ?? resolveDesktopPairingUrl(endpointUrl, pairingLink.credential)) - : isLoopbackHostname(window.location.hostname) - ? null - : currentOriginPairingUrl); + : isShareableOrigin(window.location) + ? currentOriginPairingUrl + : null); const revealValue = shareablePairingUrl ?? pairingLink.credential; const isShareableHostedAppPairingUrl = shareablePairingUrl !== null && isHostedAppPairingUrl(shareablePairingUrl); @@ -1775,6 +1873,12 @@ export function ConnectionsSettings() { const [desktopServerExposureMutationError, setDesktopServerExposureMutationError] = useState< string | null >(null); + // Kept separate from the network-access error above: these two settings are + // independent, and a Tailscale failure reported under "Network access" points + // the user at the wrong control. + const [tailscaleServeMutationError, setTailscaleServeMutationError] = useState( + null, + ); const [desktopAccessManagementMutationError, setDesktopAccessManagementMutationError] = useState< string | null >(null); @@ -1968,11 +2072,41 @@ export function ConnectionsSettings() { void handleDesktopServerExposureChange(checked); }, [handleDesktopServerExposureChange, pendingDesktopServerExposureMode]); + const openTailscaleServeConfigureUrl = useCallback( + (configureUrl: string) => { + // In a plain browser this is the only way out. + if (!desktopBridge) { + window.open(configureUrl, "_blank", "noopener,noreferrer"); + return; + } + // openExternal resolves `false` instead of rejecting when the shell + // refuses the URL, so branch on the result. `window.open` is not a usable + // fallback here: the main window's setWindowOpenHandler routes it back + // into the same openExternal and denies the popup, so a failure would be + // a silent no-op. Say so instead — the URL is in the message above, and + // the alert text is selectable. + void desktopBridge + .openExternal(configureUrl) + .catch(() => false) + .then((opened) => { + if (opened) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not open the Tailscale setup page", + description: `Copy this link into your browser: ${configureUrl}`, + }), + ); + }); + }, + [desktopBridge], + ); + const handleConfirmTailscaleServeSetup = useCallback(async () => { if (!desktopBridge) return; if (!isTailscaleServePortValid) return; setIsUpdatingTailscaleServe(true); - setDesktopServerExposureMutationError(null); + setTailscaleServeMutationError(null); try { await desktopBridge.setTailscaleServeEnabled({ enabled: true, @@ -1981,26 +2115,48 @@ export function ConnectionsSettings() { refreshDesktopNetworkAccessState(); setPendingTailscaleServeEndpoint(null); } catch (error) { - const message = - error instanceof Error ? error.message : "Failed to configure Tailscale HTTPS."; - setDesktopServerExposureMutationError(message); + const message = formatDesktopTailscaleServeError( + error, + "Failed to configure Tailscale HTTPS.", + ); + setTailscaleServeMutationError(message); + const configureUrl = extractTailscaleServeConfigureUrl(message); toastManager.add( stackedThreadToast({ type: "error", title: "Could not set up Tailscale HTTPS", description: message, + actionVariant: "outline", + ...(configureUrl + ? { + actionProps: { + children: "Open setup", + onClick: () => { + openTailscaleServeConfigureUrl(configureUrl); + }, + }, + } + : {}), }), ); } finally { setIsUpdatingTailscaleServe(false); } - }, [desktopBridge, isTailscaleServePortValid, parsedTailscaleServePort]); + }, [ + desktopBridge, + isTailscaleServePortValid, + openTailscaleServeConfigureUrl, + parsedTailscaleServePort, + ]); const handleStartTailscaleServeSetup = useCallback( (endpoint: AdvertisedEndpoint) => { setTailscaleServePortInput( String(desktopServerExposureState?.tailscaleServePort ?? DEFAULT_TAILSCALE_SERVE_PORT), ); + // The dialog surfaces this inline, so a failure from a previous attempt + // must not be sitting there when it reopens. + setTailscaleServeMutationError(null); setPendingTailscaleServeEndpoint(endpoint); }, [desktopServerExposureState?.tailscaleServePort], @@ -2009,7 +2165,7 @@ export function ConnectionsSettings() { const handleConfirmTailscaleServeDisable = useCallback(async () => { if (!desktopBridge) return; setIsUpdatingTailscaleServe(true); - setDesktopServerExposureMutationError(null); + setTailscaleServeMutationError(null); try { await desktopBridge.setTailscaleServeEnabled({ enabled: false, @@ -2018,8 +2174,8 @@ export function ConnectionsSettings() { refreshDesktopNetworkAccessState(); setDisableTailscaleServeDialogOpen(false); } catch (error) { - const message = error instanceof Error ? error.message : "Failed to disable Tailscale HTTPS."; - setDesktopServerExposureMutationError(message); + const message = formatDesktopTailscaleServeError(error, "Failed to disable Tailscale HTTPS."); + setTailscaleServeMutationError(message); toastManager.add( stackedThreadToast({ type: "error", @@ -2032,10 +2188,19 @@ export function ConnectionsSettings() { } }, [desktopBridge, desktopServerExposureState?.tailscaleServePort]); - const handleStartTailscaleServeDisable = useCallback((_endpoint: AdvertisedEndpoint) => { + const openTailscaleServeDisableDialog = useCallback(() => { + // The dialog surfaces this inline; clear any previous attempt's failure. + setTailscaleServeMutationError(null); setDisableTailscaleServeDialogOpen(true); }, []); + const handleStartTailscaleServeDisable = useCallback( + (_endpoint: AdvertisedEndpoint) => { + openTailscaleServeDisableDialog(); + }, + [openTailscaleServeDisableDialog], + ); + const handleRevokeDesktopPairingLink = useCallback(async (id: string) => { setRevokingDesktopPairingLinkId(id); setDesktopAccessManagementMutationError(null); @@ -2295,6 +2460,57 @@ export function ConnectionsSettings() { () => desktopAdvertisedEndpoints.find(isTailscaleHttpsEndpoint) ?? null, [desktopAdvertisedEndpoints], ); + + const handleEnableTailscaleHttps = useCallback(async () => { + if (!desktopBridge) return; + if (tailscaleHttpsEndpoint) { + handleStartTailscaleServeSetup(tailscaleHttpsEndpoint); + return; + } + + // MagicDNS is resolved only on explicit opt-in so local-only Connections + // mounts do not spawn `tailscale status` (macOS MAS TCC; #2745). + setIsUpdatingTailscaleServe(true); + setTailscaleServeMutationError(null); + try { + const endpoint = await desktopBridge.resolveTailscaleHttpsEndpoint(); + if (!endpoint) { + const message = + "Tailscale is not running or has no MagicDNS name. Start Tailscale and try again."; + setTailscaleServeMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not set up Tailscale HTTPS", + description: message, + }), + ); + return; + } + handleStartTailscaleServeSetup(endpoint); + refreshDesktopNetworkAccessState(); + } catch (error) { + const message = formatDesktopTailscaleServeError( + error, + "Failed to resolve Tailscale HTTPS endpoint.", + ); + setTailscaleServeMutationError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not set up Tailscale HTTPS", + description: message, + }), + ); + } finally { + setIsUpdatingTailscaleServe(false); + } + }, [ + desktopBridge, + handleStartTailscaleServeSetup, + refreshDesktopNetworkAccessState, + tailscaleHttpsEndpoint, + ]); const visibleDesktopNetworkAdvertisedEndpoints = useMemo( () => isLocalBackendNetworkAccessible @@ -2309,8 +2525,16 @@ export function ConnectionsSettings() { : visibleDesktopNetworkAdvertisedEndpoints, [tailscaleHttpsEndpoint, visibleDesktopNetworkAdvertisedEndpoints], ); + // Persisted Serve counts as reachable on its own. Until this branch, Serve + // implied network-accessible mode, so the first operand covered it and the + // probe was only ever a tiebreaker. Now that Serve stands alone without LAN, + // an unfinished cert or a momentary tailnet blip is enough to make `status` + // miss — and "Authorized clients" would vanish while the toggle above it + // correctly reads on. Same reason the switch tracks the setting, not the probe. const isLocalBackendRemotelyReachable = - isLocalBackendNetworkAccessible || tailscaleHttpsEndpoint?.status === "available"; + isLocalBackendNetworkAccessible || + (desktopServerExposureState?.tailscaleServeEnabled ?? false) || + tailscaleHttpsEndpoint?.status === "available"; const defaultDesktopNetworkAdvertisedEndpoint = useMemo( () => selectPairingEndpoint(visibleDesktopNetworkAdvertisedEndpoints, defaultAdvertisedEndpointKey), @@ -2866,28 +3090,56 @@ export function ConnectionsSettings() { const renderTailscaleRow = () => ( + {tailscaleServeMutationError} + + ) : null + } description={ tailscaleHttpsEndpoint ? tailscaleHttpsEndpoint.status === "available" ? tailscaleHttpsEndpoint.httpBaseUrl : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL." - : "Start Tailscale to set up HTTPS access through MagicDNS." + : "Use Tailscale Serve to expose this backend through a MagicDNS HTTPS URL. Independent of LAN network access." } control={ - tailscaleHttpsEndpoint ? ( + // Enabling resolves MagicDNS over the tailscale CLI before any dialog + // opens, so the dimmed switch alone leaves that wait unexplained. +
+ {isUpdatingTailscaleServe ? ( + + ) : null} { if (checked) { - handleStartTailscaleServeSetup(tailscaleHttpsEndpoint); + void handleEnableTailscaleHttps(); return; } - handleStartTailscaleServeDisable(tailscaleHttpsEndpoint); + // Disable confirmation does not require a resolved MagicDNS endpoint. + openTailscaleServeDisableDialog(); }} aria-label="Enable Tailscale HTTPS" /> - ) : null +
} /> ); @@ -3214,6 +3466,13 @@ export function ConnectionsSettings() { T3 Code will restart the local backend without Tailscale Serve. + {tailscaleServeMutationError ? ( + + ) : null} + {tailscaleServeMutationError ? ( + + ) : null} { afterEach(() => { vi.unstubAllEnvs(); }); + it("treats only reachable web origins as shareable", () => { + expect(isShareableOrigin({ protocol: "https:", hostname: "app.example.com" })).toBe(true); + expect(isShareableOrigin({ protocol: "http:", hostname: "192.168.1.44" })).toBe(true); + + // The packaged desktop app loads from `t3code-dev://app/`. Its hostname is + // not a loopback name, so a loopback-only check would call this shareable + // and offer copy/QR for a URL no other device can open. + expect(isShareableOrigin({ protocol: "t3code-dev:", hostname: "app" })).toBe(false); + expect(isShareableOrigin({ protocol: "file:", hostname: "" })).toBe(false); + + expect(isShareableOrigin({ protocol: "http:", hostname: "localhost" })).toBe(false); + expect(isShareableOrigin({ protocol: "http:", hostname: "127.0.0.1" })).toBe(false); + }); + it("uses direct backend pairing URLs for HTTP endpoints", () => { expect(resolveHostedPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBeNull(); expect(resolveDesktopPairingUrl("http://192.168.1.44:3773", "PAIRCODE")).toBe( diff --git a/apps/web/src/components/settings/pairingUrls.ts b/apps/web/src/components/settings/pairingUrls.ts index 891fe04ad6b..4b5314b2bbc 100644 --- a/apps/web/src/components/settings/pairingUrls.ts +++ b/apps/web/src/components/settings/pairingUrls.ts @@ -1,6 +1,26 @@ +import { isLoopbackHostname } from "../../environments/primary/target"; import { buildHostedPairingUrl } from "../../hostedPairing"; import { setPairingTokenOnUrl } from "../../pairingUrl"; +/** + * Whether an origin is one another device could actually open, and so worth + * offering as a pairing URL instead of the pairing code. + * + * Loopback is the obvious no. The packaged desktop app is the non-obvious one: + * it loads from a custom scheme (`t3code-dev://app/`), whose hostname is `app`, + * which is not a loopback name — so testing only for loopback would call it + * shareable and hand out a copy/QR link that resolves nowhere off this machine. + */ +export function isShareableOrigin(input: { + readonly protocol: string; + readonly hostname: string; +}): boolean { + if (input.protocol !== "http:" && input.protocol !== "https:") { + return false; + } + return !isLoopbackHostname(input.hostname); +} + export function resolveDesktopPairingUrl(endpointUrl: string, credential: string): string { const url = new URL(endpointUrl); url.pathname = "/pair"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a92eb972d67..df9db79edb1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -995,6 +995,8 @@ export interface DesktopBridge { readonly port?: number; }) => Promise; getAdvertisedEndpoints: () => Promise; + /** User-initiated MagicDNS resolve for Tailscale HTTPS setup (may spawn CLI once). */ + resolveTailscaleHttpsEndpoint: () => Promise; getWslState: () => Promise; setWslBackendEnabled: (enabled: boolean) => Promise; setWslDistro: (distro: string | null) => Promise; diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts index 24c22454d9d..01613508aff 100644 --- a/packages/tailscale/src/tailscale.test.ts +++ b/packages/tailscale/src/tailscale.test.ts @@ -13,6 +13,10 @@ import { buildTailscaleHttpsBaseUrl, disableTailscaleServe, ensureTailscaleServe, + extractTailscaleServeConfigureUrl, + describeTailscaleStderrDiagnostic, + stderrDiagnosticOf, + formatTailscaleServeUserMessage, isTailscaleIpv4Address, parseTailscaleMagicDnsName, parseTailscaleStatus, @@ -315,9 +319,106 @@ describe("tailscale", () => { // key cannot reach a log through it either. assert.equal(error.stderrDiagnostic, "permission-denied"); assertCarriesNoSecret(error, "tskey-auth-secret-token-value"); + // The URL channel is opt-in on a validated pattern, so an unrelated + // failure leaves it unset rather than carrying anything from stderr. + assert.equal(error.configureUrl, undefined); }); }); + it.effect("surfaces Tailscale Serve-not-enabled diagnostics without raw stderr", () => { + const configureUrl = "https://login.tailscale.com/f/serve?node=nExampleNodeIdForTests"; + const stderr = [ + "Serve is not enabled on your tailnet.", + "To enable, visit:", + "", + ` ${configureUrl}`, + "", + "tskey-auth-secret-token-value", + ].join("\n"); + const layer = mockSpawnerLayer(() => ({ + code: 1, + stderr, + })); + + return Effect.gen(function* () { + const error = yield* ensureTailscaleServe({ localPort: 13773, servePort: 443 }).pipe( + Effect.flip, + Effect.provide(layer), + ); + + assert.instanceOf(error, TailscaleCommandExitError); + assert.equal(error.stderrDiagnostic, "serve-not-enabled"); + assert.equal(error.configureUrl, configureUrl); + // The shared message stays label-free (it is logged); the prose lives at + // the UI edge. The validated admin URL rides along because it is the one + // actionable thing in the failure. + assert.equal( + error.message, + `tailscale serve exited with code 1. To enable, visit: ${configureUrl}`, + ); + assert.equal( + describeTailscaleStderrDiagnostic("serve-not-enabled"), + "Serve is not enabled on your tailnet.", + ); + assert.equal(formatTailscaleServeUserMessage(error), error.message); + assert.notInclude(error.message, "tskey-auth-secret-token-value"); + assert.notProperty(error, "stderr"); + }); + }); + + it.effect("extracts only Tailscale Serve configure URLs from CLI text", () => + Effect.sync(() => { + const configureUrl = "https://login.tailscale.com/f/serve?node=abc123"; + assert.equal(extractTailscaleServeConfigureUrl(`visit ${configureUrl} please`), configureUrl); + assert.equal( + extractTailscaleServeConfigureUrl("https://evil.example/f/serve?node=abc123"), + null, + ); + + // The matched text runs to the end of the URL token, so a plausible + // origin and path are not enough: everything after `?` would otherwise be + // stderr riding into a logged error message. Only a node ID survives. + assert.equal( + extractTailscaleServeConfigureUrl( + "https://login.tailscale.com/f/serve?node=abc123&authkey=tskey-secret-value", + ), + configureUrl, + ); + assert.equal( + extractTailscaleServeConfigureUrl("https://login.tailscale.com/f/serve?authkey=tskey-x"), + "https://login.tailscale.com/f/serve", + ); + // A "node" that is not shaped like a stable ID is not one; drop it rather + // than pass it through. + assert.equal( + extractTailscaleServeConfigureUrl( + "https://login.tailscale.com/f/serve?node=tskey-secret%20leaked", + ), + "https://login.tailscale.com/f/serve", + ); + // Serve-specific failures get their own labels, so the desktop toast can + // name the reason without any text being lifted out of stderr. + assert.equal( + stderrDiagnosticOf( + "Serve is not enabled on your tailnet.\nTo enable, visit:\n\n " + configureUrl, + ), + "serve-not-enabled", + ); + assert.equal( + describeTailscaleStderrDiagnostic("serve-not-enabled"), + "Serve is not enabled on your tailnet.", + ); + assert.equal( + stderrDiagnosticOf( + "500 Internal Server Error: your Tailscale account does not support getting TLS certs", + ), + "no-https-certs", + ); + // An unrecognized failure stays unnamed rather than guessing. + assert.equal(describeTailscaleStderrDiagnostic("unknown"), null); + }), + ); + it.effect("disables tailscale serve through the process spawner service", () => { const commands: { readonly command: string; diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts index 7260a9de11b..e76837db927 100644 --- a/packages/tailscale/src/tailscale.ts +++ b/packages/tailscale/src/tailscale.ts @@ -32,6 +32,8 @@ export const TailscaleStderrDiagnostic = Schema.Literals([ "no-existing-handler", "not-logged-in", "permission-denied", + "serve-not-enabled", + "no-https-certs", "unknown", ]); export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type; @@ -44,6 +46,8 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray< [/handler does not exist/i, "no-existing-handler"], [/not logged in|logged out|needs? login/i, "not-logged-in"], [/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"], + [/serve is not enabled on your tailnet/i, "serve-not-enabled"], + [/does not support getting TLS certs/i, "no-https-certs"], ]; /** Classifies stderr into a safe label, dropping the text itself. */ @@ -91,13 +95,121 @@ export class TailscaleCommandExitError extends Schema.TaggedErrorClass 500) { + return null; + } + + try { + const parsed = new URL(match[0]); + if (parsed.protocol !== "https:") return null; + if (parsed.hostname !== "login.tailscale.com") return null; + if (!parsed.pathname.startsWith("/f/serve")) return null; + + const sanitized = new URL(TAILSCALE_SERVE_CONFIGURE_URL_BASE); + const node = parsed.searchParams.get("node"); + if (node !== null && TAILSCALE_NODE_ID_PATTERN.test(node)) { + sanitized.searchParams.set("node", node); + } + return sanitized.toString(); + } catch { + return null; + } +} + +/** + * Prose for a classified diagnostic. + * + * The label is the safe thing to log; this turns it into something worth + * showing a user. `unknown` deliberately has no prose — inventing one would + * imply we recognized a failure we did not. + */ +export function describeTailscaleStderrDiagnostic( + diagnostic: TailscaleStderrDiagnostic, +): string | null { + switch (diagnostic) { + case "no-existing-handler": + return "No matching Tailscale Serve handler exists."; + case "not-logged-in": + return "Tailscale is not logged in."; + case "permission-denied": + return "Tailscale denied permission for this command."; + case "serve-not-enabled": + return "Serve is not enabled on your tailnet."; + case "no-https-certs": + return "This Tailscale account does not support getting TLS certificates required for HTTPS Serve."; + case "unknown": + return null; } } +export function formatTailscaleCommandExitMessage(input: { + readonly subcommand: "status" | "serve"; + readonly exitCode: number; + readonly stderrDiagnostic?: TailscaleStderrDiagnostic | undefined; + readonly configureUrl?: string | undefined; +}): string { + // Stays generic on purpose: this message is logged, and #4556 keeps logs to + // the label alone. Prose for the label belongs at the UI edge, via + // describeTailscaleStderrDiagnostic. The admin URL is appended because it is + // validated rather than lifted, and it is the one actionable thing here. + const base = `tailscale ${input.subcommand} exited with code ${input.exitCode}.`; + return input.configureUrl ? `${base} To enable, visit: ${input.configureUrl}` : base; +} + export class TailscaleCommandTimeoutError extends Schema.TaggedErrorClass()( "TailscaleCommandTimeoutError", { @@ -119,6 +231,20 @@ export const TailscaleCommandError = Schema.Union([ ]); export type TailscaleCommandError = typeof TailscaleCommandError.Type; +/** User-facing message for any Tailscale CLI failure while configuring Serve. */ +export function formatTailscaleServeUserMessage(error: TailscaleCommandError): string { + switch (error._tag) { + case "TailscaleCommandSpawnError": + return "Could not run the tailscale CLI. Is Tailscale installed and on PATH?"; + case "TailscaleCommandOutputError": + return "Could not read output from the tailscale CLI."; + case "TailscaleCommandTimeoutError": + return "The tailscale CLI timed out while configuring Serve."; + case "TailscaleCommandExitError": + return error.message; + } +} + export class TailscaleStatusParseError extends Schema.TaggedErrorClass()( "TailscaleStatusParseError", { cause: Schema.Defect() }, @@ -311,13 +437,14 @@ const runTailscaleCommand = ( Effect.mapError((cause) => new TailscaleCommandOutputError({ ...commandContext, cause })), ); if (exitCode !== 0) { + const stderrDiagnostic = stderrDiagnosticOf(stderr); + const configureUrl = extractTailscaleServeConfigureUrl(stderr); return yield* new TailscaleCommandExitError({ ...commandContext, exitCode, stderrLength: stderr.length, - ...(stderrDiagnosticOf(stderr) !== undefined - ? { stderrDiagnostic: stderrDiagnosticOf(stderr) } - : {}), + ...(stderrDiagnostic === undefined ? {} : { stderrDiagnostic }), + ...(configureUrl === null ? {} : { configureUrl }), }); } }).pipe( diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts index 0f843b3ba91..cb87c3228b8 100644 --- a/scripts/lib/dev-share.ts +++ b/scripts/lib/dev-share.ts @@ -34,6 +34,8 @@ const DIAGNOSTIC_EXPLANATIONS: Record