diff --git a/apps/desktop/src/connection-config.test.ts b/apps/desktop/src/connection-config.test.ts new file mode 100644 index 000000000..d9e8389dc --- /dev/null +++ b/apps/desktop/src/connection-config.test.ts @@ -0,0 +1,95 @@ +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { + DesktopConnectionConfigError, + buildDesktopRemoteWsUrl, + getDefaultDesktopConnectionSettings, + readDesktopConnectionSettings, + resolveDesktopConnectionConfigPath, + validateDesktopConnectionSettings, + writeDesktopConnectionSettings, +} from "./connection-config"; + +describe("connection-config", () => { + it("returns local defaults when no config exists", () => { + const configPath = Path.join(process.cwd(), "missing", "desktop-connection.json"); + + expect(readDesktopConnectionSettings(configPath)).toEqual(getDefaultDesktopConnectionSettings()); + }); + + it("builds a websocket url from a remote http url", () => { + expect( + buildDesktopRemoteWsUrl({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773", + remoteAuthToken: "secret token", + }), + ).toBe("ws://100.64.0.10:3773/?token=secret+token"); + }); + + it("builds a secure websocket url from a remote https url", () => { + expect( + buildDesktopRemoteWsUrl({ + mode: "remote", + remoteUrl: "https://example.com/t3", + remoteAuthToken: "abc123", + }), + ).toBe("wss://example.com/?token=abc123"); + }); + + it("allows remote mode without an auth token", () => { + expect( + validateDesktopConnectionSettings({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773", + remoteAuthToken: "", + }), + ).toEqual({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773/", + remoteAuthToken: "", + }); + }); + + it("omits the token query param when no remote auth token is configured", () => { + expect( + buildDesktopRemoteWsUrl({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773", + remoteAuthToken: "", + }), + ).toBe("ws://100.64.0.10:3773/"); + }); + + it("rejects missing remote url in remote mode", () => { + expect(() => + validateDesktopConnectionSettings({ + mode: "remote", + remoteUrl: "", + remoteAuthToken: "", + }), + ).toThrow(DesktopConnectionConfigError); + }); + + it("writes validated settings to disk", () => { + const tempRoot = FS.mkdtempSync(Path.join(OS.tmpdir(), "t3code-connection-config-")); + const configPath = resolveDesktopConnectionConfigPath(tempRoot); + + const saved = writeDesktopConnectionSettings(configPath, { + mode: "remote", + remoteUrl: "http://100.64.0.10:3773", + remoteAuthToken: "abc123", + }); + + expect(saved).toEqual({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773/", + remoteAuthToken: "abc123", + }); + expect(readDesktopConnectionSettings(configPath)).toEqual(saved); + }); +}); diff --git a/apps/desktop/src/connection-config.ts b/apps/desktop/src/connection-config.ts new file mode 100644 index 000000000..fc63da2ca --- /dev/null +++ b/apps/desktop/src/connection-config.ts @@ -0,0 +1,127 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import type { + DesktopConnectionInfo, + DesktopConnectionMode, + DesktopConnectionSettings, +} from "@t3tools/contracts"; + +export class DesktopConnectionConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "DesktopConnectionConfigError"; + } +} + +const CONNECTION_CONFIG_FILENAME = "desktop-connection.json"; + +export function getDefaultDesktopConnectionSettings(): DesktopConnectionSettings { + return { + mode: "local", + remoteUrl: "", + remoteAuthToken: "", + }; +} + +function normalizeMode(value: unknown): DesktopConnectionMode { + return value === "remote" ? "remote" : "local"; +} + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function sanitizeDesktopConnectionSettings(value: unknown): DesktopConnectionSettings { + if (!value || typeof value !== "object") { + return getDefaultDesktopConnectionSettings(); + } + + const record = value as Record; + return { + mode: normalizeMode(record.mode), + remoteUrl: normalizeString(record.remoteUrl), + remoteAuthToken: normalizeString(record.remoteAuthToken), + }; +} + +export function validateDesktopConnectionSettings( + input: DesktopConnectionSettings, +): DesktopConnectionSettings { + const settings = sanitizeDesktopConnectionSettings(input); + if (settings.mode === "local") { + return settings; + } + + if (settings.remoteUrl.length === 0) { + throw new DesktopConnectionConfigError("Remote URL is required."); + } + + let remoteUrl: URL; + try { + remoteUrl = new URL(settings.remoteUrl); + } catch { + throw new DesktopConnectionConfigError("Remote URL must be a valid http:// or https:// URL."); + } + + if (remoteUrl.protocol !== "http:" && remoteUrl.protocol !== "https:") { + throw new DesktopConnectionConfigError("Remote URL must use http:// or https://."); + } + if (!remoteUrl.hostname) { + throw new DesktopConnectionConfigError("Remote URL must include a host."); + } + + return { + ...settings, + remoteUrl: remoteUrl.toString(), + }; +} + +export function buildDesktopRemoteWsUrl(settings: DesktopConnectionSettings): string { + const validated = validateDesktopConnectionSettings(settings); + if (validated.mode !== "remote") { + throw new DesktopConnectionConfigError("Remote WebSocket URL is only available in remote mode."); + } + + const remoteUrl = new URL(validated.remoteUrl); + remoteUrl.protocol = remoteUrl.protocol === "https:" ? "wss:" : "ws:"; + remoteUrl.pathname = "/"; + remoteUrl.search = ""; + if (validated.remoteAuthToken.length > 0) { + remoteUrl.searchParams.set("token", validated.remoteAuthToken); + } + remoteUrl.hash = ""; + return remoteUrl.toString(); +} + +export function getDesktopConnectionInfo( + settings: DesktopConnectionSettings, +): DesktopConnectionInfo { + return { mode: settings.mode }; +} + +export function resolveDesktopConnectionConfigPath(userDataPath: string): string { + return Path.join(userDataPath, CONNECTION_CONFIG_FILENAME); +} + +export function readDesktopConnectionSettings(configPath: string): DesktopConnectionSettings { + try { + const raw = FS.readFileSync(configPath, "utf8"); + return sanitizeDesktopConnectionSettings(JSON.parse(raw)); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return getDefaultDesktopConnectionSettings(); + } + throw error; + } +} + +export function writeDesktopConnectionSettings( + configPath: string, + input: DesktopConnectionSettings, +): DesktopConnectionSettings { + const settings = validateDesktopConnectionSettings(input); + FS.mkdirSync(Path.dirname(configPath), { recursive: true }); + FS.writeFileSync(configPath, JSON.stringify(settings, null, 2) + "\n", "utf8"); + return settings; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 460684929..59d10f853 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,7 @@ import { import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopConnectionSettings, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -43,6 +44,14 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { + buildDesktopRemoteWsUrl, + getDefaultDesktopConnectionSettings, + getDesktopConnectionInfo, + readDesktopConnectionSettings, + resolveDesktopConnectionConfigPath, + writeDesktopConnectionSettings, +} from "./connection-config"; fixPath(); @@ -56,6 +65,10 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const CONNECTION_INFO_CHANNEL = "desktop:connection-info"; +const CONNECTION_SETTINGS_GET_CHANNEL = "desktop:connection-settings:get"; +const CONNECTION_SETTINGS_SAVE_CHANNEL = "desktop:connection-settings:save"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); const DESKTOP_SCHEME = "t3"; @@ -83,6 +96,7 @@ let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; let backendWsUrl = ""; +let desktopConnectionSettings: DesktopConnectionSettings = getDefaultDesktopConnectionSettings(); let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -686,6 +700,20 @@ function resolveUserDataPath(): string { return Path.join(appDataBase, USER_DATA_DIR_NAME); } +function resolveDesktopConnectionSettingsPath(): string { + return resolveDesktopConnectionConfigPath(app.getPath("userData")); +} + +function loadDesktopConnectionSettings(): DesktopConnectionSettings { + return readDesktopConnectionSettings(resolveDesktopConnectionSettingsPath()); +} + +function saveDesktopConnectionSettings( + settings: DesktopConnectionSettings, +): DesktopConnectionSettings { + return writeDesktopConnectionSettings(resolveDesktopConnectionSettingsPath(), settings); +} + function configureAppIdentity(): void { app.setName(APP_DISPLAY_NAME); const commitHash = resolveAboutCommitHash(); @@ -1072,6 +1100,26 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { + ipcMain.removeHandler(CONNECTION_INFO_CHANNEL); + ipcMain.handle(CONNECTION_INFO_CHANNEL, async () => getDesktopConnectionInfo(desktopConnectionSettings)); + + ipcMain.removeHandler(CONNECTION_SETTINGS_GET_CHANNEL); + ipcMain.handle(CONNECTION_SETTINGS_GET_CHANNEL, async () => desktopConnectionSettings); + + ipcMain.removeHandler(CONNECTION_SETTINGS_SAVE_CHANNEL); + ipcMain.handle(CONNECTION_SETTINGS_SAVE_CHANNEL, async (_event, input: unknown) => { + desktopConnectionSettings = saveDesktopConnectionSettings( + input as DesktopConnectionSettings, + ); + return desktopConnectionSettings; + }); + + ipcMain.removeHandler(RESTART_APP_CHANNEL); + ipcMain.handle(RESTART_APP_CHANNEL, async () => { + app.relaunch(); + app.exit(0); + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1304,15 +1352,18 @@ function createWindow(): BrowserWindow { return window; } -// Override Electron's userData path before the `ready` event so that -// Chromium session data uses a filesystem-friendly directory name. -// Must be called synchronously at the top level — before `app.whenReady()`. -app.setPath("userData", resolveUserDataPath()); +async function resolveDesktopConnection(): Promise { + desktopConnectionSettings = loadDesktopConnectionSettings(); -configureAppIdentity(); + if (desktopConnectionSettings.mode === "remote") { + backendPort = 0; + backendAuthToken = ""; + backendWsUrl = buildDesktopRemoteWsUrl(desktopConnectionSettings); + process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; + writeDesktopLogHeader(`bootstrap resolved remote websocket url=${backendWsUrl}`); + return; + } -async function bootstrap(): Promise { - writeDesktopLogHeader("bootstrap start"); backendPort = await Effect.service(NetService).pipe( Effect.flatMap((net) => net.reserveLoopbackPort()), Effect.provide(NetService.layer), @@ -1323,11 +1374,27 @@ async function bootstrap(): Promise { backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); +} + +// Override Electron's userData path before the `ready` event so that +// Chromium session data uses a filesystem-friendly directory name. +// Must be called synchronously at the top level — before `app.whenReady()`. +app.setPath("userData", resolveUserDataPath()); + +configureAppIdentity(); + +async function bootstrap(): Promise { + writeDesktopLogHeader("bootstrap start"); + await resolveDesktopConnection(); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); - writeDesktopLogHeader("bootstrap backend start requested"); + if (desktopConnectionSettings.mode === "local") { + startBackend(); + writeDesktopLogHeader("bootstrap backend start requested"); + } else { + writeDesktopLogHeader("bootstrap running in remote connection mode"); + } mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8..f3c5e3a80 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,10 +11,18 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const CONNECTION_INFO_CHANNEL = "desktop:connection-info"; +const CONNECTION_SETTINGS_GET_CHANNEL = "desktop:connection-settings:get"; +const CONNECTION_SETTINGS_SAVE_CHANNEL = "desktop:connection-settings:save"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, + getConnectionInfo: () => ipcRenderer.invoke(CONNECTION_INFO_CHANNEL), + getConnectionSettings: () => ipcRenderer.invoke(CONNECTION_SETTINGS_GET_CHANNEL), + saveConnectionSettings: (settings) => ipcRenderer.invoke(CONNECTION_SETTINGS_SAVE_CHANNEL, settings), + restartApp: () => ipcRenderer.invoke(RESTART_APP_CHANNEL), pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 83976e3d4..c81390853 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -297,4 +297,27 @@ it.layer(testLayer)("server CLI command", (it) => { assert.equal(stop.mock.calls.length, 0); }), ); + + it.effect("uses remote-friendly defaults for --remote", () => + Effect.gen(function* () { + yield* runCli(["--remote"]); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.port, 3773); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(findAvailablePort.mock.calls.length, 0); + }), + ); + + it.effect("lets --remote override the default port", () => + Effect.gen(function* () { + yield* runCli(["--remote", "--port", "4010"]); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.port, 4010); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + }), + ); }); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cb..4adea58d2 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -6,8 +6,19 @@ * * @module CliConfig */ -import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; -import { Command, Flag } from "effect/unstable/cli"; +import { + Config, + Data, + Effect, + FileSystem, + Layer, + Option, + Path, + References, + Schema, + ServiceMap, +} from "effect"; +import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, @@ -26,6 +37,12 @@ import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { + buildRemoteConnectUrl, + formatHostForUrl, + formatRemoteStartupMessage, + isWildcardHost, +} from "./remote-access"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; @@ -33,6 +50,7 @@ export class StartupError extends Data.TaggedError("StartupError")<{ }> {} interface CliInput { + readonly remote: Option.Option; readonly mode: Option.Option; readonly port: Option.Option; readonly host: Option.Option; @@ -125,6 +143,8 @@ const CliEnvConfig = Config.all({ const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +const resolveRemoteFlag = (input: CliInput): boolean => resolveBooleanFlag(input.remote, false); + const ServerConfigLive = (input: CliInput) => Layer.effect( ServerConfig, @@ -138,7 +158,8 @@ const ServerConfigLive = (input: CliInput) => ), ); - const mode = Option.getOrElse(input.mode, () => env.mode); + const remote = resolveRemoteFlag(input); + const mode = remote ? "web" : Option.getOrElse(input.mode, () => env.mode); const port = yield* Option.match(input.port, { onSome: (value) => Effect.succeed(value), @@ -146,7 +167,7 @@ const ServerConfigLive = (input: CliInput) => if (env.port) { return Effect.succeed(env.port); } - if (mode === "desktop") { + if (mode === "desktop" || remote) { return Effect.succeed(DEFAULT_PORT); } return findAvailablePort(DEFAULT_PORT); @@ -156,7 +177,9 @@ const ServerConfigLive = (input: CliInput) => Option.getOrUndefined(input.stateDir) ?? env.stateDir, ); const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); + const noBrowser = remote + ? true + : resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, @@ -172,7 +195,7 @@ const ServerConfigLive = (input: CliInput) => const host = Option.getOrUndefined(input.host) ?? env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + (remote ? "0.0.0.0" : mode === "desktop" ? "127.0.0.1" : undefined); const config: ServerConfigShape = { mode, @@ -204,12 +227,6 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(ServerConfigLive(input)), ); -const isWildcardHost = (host: string | undefined): boolean => - host === "0.0.0.0" || host === "::" || host === "[::]"; - -const formatHostForUrl = (host: string): string => - host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; - export const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; @@ -235,7 +252,7 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -const makeServerProgram = (input: CliInput) => +const makeServerProgramBody = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; const { start, stopSignal } = yield* Server; @@ -262,12 +279,28 @@ const makeServerProgram = (input: CliInput) => ? `http://${formatHostForUrl(config.host)}:${config.port}` : localUrl; const { authToken, devUrl, ...safeConfig } = config; + const remoteConnectUrl = buildRemoteConnectUrl({ + host: config.host, + port: config.port, + authToken, + }); yield* Effect.logInfo("T3 Code running", { ...safeConfig, devUrl: devUrl?.toString(), authEnabled: Boolean(authToken), }); + if (resolveRemoteFlag(input)) { + yield* Effect.sync(() => { + console.log( + formatRemoteStartupMessage({ + connectUrl: remoteConnectUrl, + port: config.port, + }), + ); + }); + } + if (!config.noBrowser) { const target = config.devUrl?.toString() ?? bindUrl; yield* openDeps.openBrowser(target).pipe( @@ -282,10 +315,28 @@ const makeServerProgram = (input: CliInput) => return yield* stopSignal; }).pipe(Effect.provide(LayerLive(input))); +const makeServerProgram = (input: CliInput) => + Effect.gen(function* () { + const configuredLogLevel = yield* GlobalFlag.LogLevel; + const program = makeServerProgramBody(input); + + if (resolveRemoteFlag(input) && Option.isNone(configuredLogLevel)) { + return yield* program.pipe(Effect.provideService(References.MinimumLogLevel, "Warn")); + } + + return yield* program; + }); + /** * These flags mirrors the environment variables and the config shape. */ +const remoteFlag = Flag.boolean("remote").pipe( + Flag.withDescription( + "Run in remote-friendly mode: web server, no browser, host 0.0.0.0, default port 3773, and print a desktop connection URL.", + ), + Flag.optional, +); const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, @@ -332,6 +383,7 @@ const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( ); export const t3Cli = Command.make("t3", { + remote: remoteFlag, mode: modeFlag, port: portFlag, host: hostFlag, diff --git a/apps/server/src/remote-access.test.ts b/apps/server/src/remote-access.test.ts new file mode 100644 index 000000000..040d688dc --- /dev/null +++ b/apps/server/src/remote-access.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + buildRemoteConnectUrl, + detectPreferredRemoteHost, + formatRemoteStartupMessage, + isTailscaleAddress, +} from "./remote-access"; + +describe("remote-access", () => { + it("detects tailscale ipv4 addresses", () => { + expect(isTailscaleAddress("100.88.10.4")).toBe(true); + expect(isTailscaleAddress("192.168.1.20")).toBe(false); + }); + + it("prefers an explicit non-wildcard host", () => { + expect( + detectPreferredRemoteHost("100.88.10.4", { + eth0: [{ address: "192.168.1.20", family: "IPv4", internal: false, netmask: "255.255.255.0", mac: "", cidr: null }], + }), + ).toBe("100.88.10.4"); + }); + + it("prefers tailscale addresses when binding wildcard hosts", () => { + expect( + detectPreferredRemoteHost("0.0.0.0", { + eth0: [{ address: "192.168.1.20", family: "IPv4", internal: false, netmask: "255.255.255.0", mac: "", cidr: null }], + tailscale0: [{ address: "100.88.10.4", family: "IPv4", internal: false, netmask: "255.255.255.255", mac: "", cidr: null }], + }), + ).toBe("100.88.10.4"); + }); + + it("falls back to the first non-internal address when tailscale is unavailable", () => { + expect( + detectPreferredRemoteHost("0.0.0.0", { + lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "255.0.0.0", mac: "", cidr: "127.0.0.1/8" }], + eth0: [{ address: "192.168.1.20", family: "IPv4", internal: false, netmask: "255.255.255.0", mac: "", cidr: null }], + }), + ).toBe("192.168.1.20"); + }); + + it("builds a desktop-ready remote url with an optional token", () => { + expect( + buildRemoteConnectUrl( + { + host: "0.0.0.0", + port: 3773, + authToken: "secret", + }, + { + tailscale0: [{ address: "100.88.10.4", family: "IPv4", internal: false, netmask: "255.255.255.255", mac: "", cidr: null }], + }, + ), + ).toBe("http://100.88.10.4:3773/?token=secret"); + }); + + it("builds a desktop-ready remote url without a token", () => { + expect( + buildRemoteConnectUrl( + { + host: "0.0.0.0", + port: 3773, + authToken: undefined, + }, + { + tailscale0: [{ address: "100.88.10.4", family: "IPv4", internal: false, netmask: "255.255.255.255", mac: "", cidr: null }], + }, + ), + ).toBe("http://100.88.10.4:3773/"); + }); + + it("formats a clean startup message when a remote url is available", () => { + expect( + formatRemoteStartupMessage({ + connectUrl: "http://100.88.10.4:3773/", + port: 3773, + }), + ).toContain("Paste this into the desktop app's Connection URL field:"); + }); + + it("formats a fallback startup message when no remote host can be detected", () => { + expect( + formatRemoteStartupMessage({ + connectUrl: null, + port: 4010, + }), + ).toContain("http://:4010/"); + }); +}); diff --git a/apps/server/src/remote-access.ts b/apps/server/src/remote-access.ts new file mode 100644 index 000000000..de7097a12 --- /dev/null +++ b/apps/server/src/remote-access.ts @@ -0,0 +1,114 @@ +import * as OS from "node:os"; + +export interface RemoteConnectUrlInput { + readonly host: string | undefined; + readonly port: number; + readonly authToken: string | undefined; +} + +export interface RemoteStartupMessageInput { + readonly connectUrl: string | null; + readonly port: number; +} + +type NetworkInterfaces = ReturnType; + +function normalizeFamily(value: string | number): "IPv4" | "IPv6" | null { + if (value === "IPv4" || value === 4) return "IPv4"; + if (value === "IPv6" || value === 6) return "IPv6"; + return null; +} + +export function isWildcardHost(host: string | undefined): boolean { + return host === "0.0.0.0" || host === "::" || host === "[::]"; +} + +export function formatHostForUrl(host: string): string { + return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; +} + +export function isTailscaleAddress(address: string): boolean { + if (address.includes(":")) { + return address.toLowerCase().startsWith("fd7a:115c:a1e0:"); + } + + const octets = address.split(".").map((segment) => Number.parseInt(segment, 10)); + if (octets.length !== 4 || octets.some((value) => Number.isNaN(value) || value < 0 || value > 255)) { + return false; + } + + const first = octets[0]; + const second = octets[1]; + if (first === undefined || second === undefined) { + return false; + } + return first === 100 && second >= 64 && second <= 127; +} + +export function detectPreferredRemoteHost( + host: string | undefined, + interfaces: NetworkInterfaces = OS.networkInterfaces(), +): string | null { + if (host && !isWildcardHost(host)) { + return host; + } + + const tailscaleCandidates: Array<{ address: string; family: "IPv4" | "IPv6" }> = []; + const generalCandidates: Array<{ address: string; family: "IPv4" | "IPv6" }> = []; + + for (const [name, entries] of Object.entries(interfaces)) { + if (!entries) continue; + + for (const entry of entries) { + const family = normalizeFamily(entry.family); + if (!family || entry.internal) continue; + + const candidate = { address: entry.address, family }; + if (name.toLowerCase().includes("tailscale") || isTailscaleAddress(entry.address)) { + tailscaleCandidates.push(candidate); + continue; + } + generalCandidates.push(candidate); + } + } + + const pick = (candidates: Array<{ address: string; family: "IPv4" | "IPv6" }>) => + candidates.find((candidate) => candidate.family === "IPv4") ?? + candidates.find((candidate) => candidate.family === "IPv6") ?? + null; + + return pick(tailscaleCandidates)?.address ?? pick(generalCandidates)?.address ?? null; +} + +export function buildRemoteConnectUrl( + input: RemoteConnectUrlInput, + interfaces?: NetworkInterfaces, +): string | null { + const host = detectPreferredRemoteHost(input.host, interfaces); + if (!host) { + return null; + } + + const url = new URL(`http://${formatHostForUrl(host)}:${input.port}/`); + if (input.authToken && input.authToken.trim().length > 0) { + url.searchParams.set("token", input.authToken.trim()); + } + return url.toString(); +} + +export function formatRemoteStartupMessage(input: RemoteStartupMessageInput): string { + const fallbackUrl = `http://:${input.port}/`; + const url = input.connectUrl ?? fallbackUrl; + const lines = [ + "", + "Paste this into the desktop app's Connection URL field:", + url, + ]; + + if (input.connectUrl === null) { + lines.push(`Replace with your VPS IP or Tailscale IP.`); + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index a8f84d564..e74126935 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -5,6 +5,7 @@ import { resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, + shouldBrowseForProjectImmediately, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; @@ -83,6 +84,26 @@ describe("resolveSidebarNewThreadEnvMode", () => { }); }); +describe("shouldBrowseForProjectImmediately", () => { + it("uses the folder picker in local Electron mode", () => { + expect( + shouldBrowseForProjectImmediately({ + isElectron: true, + connectionMode: "local", + }), + ).toBe(true); + }); + + it("falls back to manual path entry in remote Electron mode", () => { + expect( + shouldBrowseForProjectImmediately({ + isElectron: true, + connectionMode: "remote", + }), + ).toBe(false); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { interactionMode: "plan" as const, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index c65ef8379..8376fc61e 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -4,6 +4,7 @@ import { findLatestProposedPlan, isLatestTurnSettled } from "../session-logic"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export type SidebarNewThreadEnvMode = "local" | "worktree"; +export type DesktopConnectionMode = "local" | "remote"; export interface ThreadStatusPill { label: @@ -46,6 +47,13 @@ export function resolveSidebarNewThreadEnvMode(input: { return input.requestedEnvMode ?? input.defaultEnvMode; } +export function shouldBrowseForProjectImmediately(input: { + isElectron: boolean; + connectionMode: DesktopConnectionMode; +}): boolean { + return input.isElectron && input.connectionMode !== "remote"; +} + export function resolveThreadRowClassName(input: { isActive: boolean; isSelected: boolean; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1b43eb4c1..08e364c79 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -84,9 +84,11 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { isNonEmpty as isNonEmptyString } from "effect/String"; import { + type DesktopConnectionMode, resolveSidebarNewThreadEnvMode, resolveThreadRowClassName, resolveThreadStatusPill, + shouldBrowseForProjectImmediately, shouldClearThreadSelectionOnMouseDown, } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; @@ -299,14 +301,19 @@ export default function Sidebar() { const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); + const [desktopConnectionMode, setDesktopConnectionMode] = + useState("local"); const selectedThreadIds = useThreadSelectionStore((s) => s.selectedThreadIds); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const clearSelection = useThreadSelectionStore((s) => s.clearSelection); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const shouldBrowseForProjectImmediately = isElectron; - const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const browseForProjectImmediately = shouldBrowseForProjectImmediately({ + isElectron, + connectionMode: desktopConnectionMode, + }); + const shouldShowProjectPathEntry = addingProject && !browseForProjectImmediately; const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -320,6 +327,26 @@ export default function Sidebar() { })), [projectCwdById, threads], ); + + useEffect(() => { + if (!isElectron || !window.desktopBridge?.getConnectionInfo) { + return; + } + + let cancelled = false; + void window.desktopBridge + .getConnectionInfo() + .then((info) => { + if (!cancelled) { + setDesktopConnectionMode(info.mode); + } + }) + .catch(() => {}); + + return () => { + cancelled = true; + }; + }, []); const threadGitStatusCwds = useMemo( () => [ ...new Set( @@ -442,7 +469,7 @@ export default function Sidebar() { const description = error instanceof Error ? error.message : "An error occurred while adding the project."; setIsAddingProject(false); - if (shouldBrowseForProjectImmediately) { + if (browseForProjectImmediately) { toastManager.add({ type: "error", title: "Failed to add project", @@ -460,7 +487,7 @@ export default function Sidebar() { handleNewThread, isAddingProject, projects, - shouldBrowseForProjectImmediately, + browseForProjectImmediately, appSettings.defaultThreadEnvMode, ], ); @@ -483,7 +510,7 @@ export default function Sidebar() { } if (pickedPath) { await addProjectFromPath(pickedPath); - } else if (!shouldBrowseForProjectImmediately) { + } else if (!browseForProjectImmediately) { addProjectInputRef.current?.focus(); } setIsPickingFolder(false); @@ -491,7 +518,7 @@ export default function Sidebar() { const handleStartAddProject = () => { setAddProjectError(null); - if (shouldBrowseForProjectImmediately) { + if (browseForProjectImmediately) { void handlePickFolder(); return; } @@ -1227,6 +1254,12 @@ export default function Sidebar() { {isPickingFolder ? "Picking folder..." : "Browse for folder"} )} + {isElectron && desktopConnectionMode === "remote" ? ( +

+ Remote mode uses paths from the connected machine. Enter the remote project path + manually. +

+ ) : null}
{ setNewCwd(event.target.value); diff --git a/apps/web/src/lib/desktop-connection-url.test.ts b/apps/web/src/lib/desktop-connection-url.test.ts new file mode 100644 index 000000000..071d2e8ba --- /dev/null +++ b/apps/web/src/lib/desktop-connection-url.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import type { DesktopConnectionSettings } from "@t3tools/contracts"; + +import { + buildDesktopConnectionUrlValue, + resolveDesktopConnectionSettingsFromUrl, +} from "./desktop-connection-url"; + +const REMOTE_SETTINGS: DesktopConnectionSettings = { + mode: "remote", + remoteUrl: "http://100.64.0.10:3773/", + remoteAuthToken: "abc123", +}; + +describe("desktop-connection-url", () => { + it("builds a single remembered connection url from remote settings", () => { + expect(buildDesktopConnectionUrlValue(REMOTE_SETTINGS)).toBe( + "http://100.64.0.10:3773/?token=abc123", + ); + }); + + it("shows a blank field in local mode", () => { + expect( + buildDesktopConnectionUrlValue({ + ...REMOTE_SETTINGS, + mode: "local", + }), + ).toBe(""); + }); + + it("parses a pasted remote connection url into saved settings", () => { + expect( + resolveDesktopConnectionSettingsFromUrl( + { + mode: "local", + remoteUrl: "", + remoteAuthToken: "", + }, + "http://100.64.0.10:3773/?token=abc123", + ), + ).toEqual(REMOTE_SETTINGS); + }); + + it("accepts websocket urls and normalizes them back to http transport", () => { + expect( + resolveDesktopConnectionSettingsFromUrl( + { + mode: "local", + remoteUrl: "", + remoteAuthToken: "", + }, + "ws://100.64.0.10:3773/?token=abc123", + ), + ).toEqual(REMOTE_SETTINGS); + }); + + it("treats an empty field as local mode", () => { + expect(resolveDesktopConnectionSettingsFromUrl(REMOTE_SETTINGS, " ")).toEqual({ + ...REMOTE_SETTINGS, + mode: "local", + }); + }); + + it("accepts pasted urls without a token", () => { + expect( + resolveDesktopConnectionSettingsFromUrl( + { + mode: "local", + remoteUrl: "", + remoteAuthToken: "", + }, + "http://100.64.0.10:3773/", + ), + ).toEqual({ + mode: "remote", + remoteUrl: "http://100.64.0.10:3773/", + remoteAuthToken: "", + }); + }); +}); diff --git a/apps/web/src/lib/desktop-connection-url.ts b/apps/web/src/lib/desktop-connection-url.ts new file mode 100644 index 000000000..7ea46a328 --- /dev/null +++ b/apps/web/src/lib/desktop-connection-url.ts @@ -0,0 +1,57 @@ +import type { DesktopConnectionSettings } from "@t3tools/contracts"; + +export function buildDesktopConnectionUrlValue(settings: DesktopConnectionSettings): string { + if (settings.mode !== "remote" || settings.remoteUrl.length === 0) { + return ""; + } + + try { + const parsed = new URL(settings.remoteUrl); + if (settings.remoteAuthToken.length > 0) { + parsed.searchParams.set("token", settings.remoteAuthToken); + } + parsed.hash = ""; + return parsed.toString(); + } catch { + return settings.remoteUrl; + } +} + +export function resolveDesktopConnectionSettingsFromUrl( + current: DesktopConnectionSettings, + rawValue: string, +): DesktopConnectionSettings { + const trimmed = rawValue.trim(); + if (trimmed.length === 0) { + return { + ...current, + mode: "local", + }; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + throw new Error("Connection URL must be a valid http://, https://, ws://, or wss:// URL."); + } + + if (parsed.protocol === "ws:") { + parsed.protocol = "http:"; + } else if (parsed.protocol === "wss:") { + parsed.protocol = "https:"; + } else if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Connection URL must use http://, https://, ws://, or wss://."); + } + + const token = parsed.searchParams.get("token")?.trim() ?? ""; + parsed.searchParams.delete("token"); + parsed.hash = ""; + + return { + ...current, + mode: "remote", + remoteUrl: parsed.toString(), + remoteAuthToken: token, + }; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa..1aa4c3f0a 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,13 +1,17 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; -import { useCallback, useState } from "react"; -import { type ProviderKind } from "@t3tools/contracts"; +import { useCallback, useEffect, useState } from "react"; +import { type DesktopConnectionSettings, type ProviderKind } from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { + buildDesktopConnectionUrlValue, + resolveDesktopConnectionSettingsFromUrl, +} from "../lib/desktop-connection-url"; import { ensureNativeApi } from "../nativeApi"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -98,6 +102,12 @@ function SettingsRouteView() { const serverConfigQuery = useQuery(serverConfigQueryOptions()); const [isOpeningKeybindings, setIsOpeningKeybindings] = useState(false); const [openKeybindingsError, setOpenKeybindingsError] = useState(null); + const [connectionSettings, setConnectionSettings] = useState( + null, + ); + const [connectionUrlInput, setConnectionUrlInput] = useState(""); + const [connectionError, setConnectionError] = useState(null); + const [isSavingConnection, setIsSavingConnection] = useState(false); const [customModelInputByProvider, setCustomModelInputByProvider] = useState< Record >({ @@ -112,6 +122,33 @@ function SettingsRouteView() { const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + useEffect(() => { + if (!isElectron || !window.desktopBridge?.getConnectionSettings) { + return; + } + + let cancelled = false; + void window.desktopBridge + .getConnectionSettings() + .then((nextSettings) => { + if (!cancelled) { + setConnectionSettings(nextSettings); + setConnectionUrlInput(buildDesktopConnectionUrlValue(nextSettings)); + } + }) + .catch((error) => { + if (!cancelled) { + setConnectionError( + error instanceof Error ? error.message : "Unable to load desktop connection settings.", + ); + } + }); + + return () => { + cancelled = true; + }; + }, []); + const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); @@ -135,6 +172,38 @@ function SettingsRouteView() { }); }, [availableEditors, keybindingsConfigPath]); + const saveConnectionSettings = useCallback(() => { + if (!window.desktopBridge?.saveConnectionSettings || !window.desktopBridge.restartApp) { + return; + } + if (!connectionSettings) { + setConnectionError("Desktop connection settings are still loading."); + return; + } + + setConnectionError(null); + setIsSavingConnection(true); + let nextSettings: DesktopConnectionSettings; + try { + nextSettings = resolveDesktopConnectionSettingsFromUrl(connectionSettings, connectionUrlInput); + } catch (error) { + setConnectionError( + error instanceof Error ? error.message : "Unable to parse the connection URL.", + ); + setIsSavingConnection(false); + return; + } + void window.desktopBridge + .saveConnectionSettings(nextSettings) + .then(() => window.desktopBridge?.restartApp?.()) + .catch((error) => { + setConnectionError( + error instanceof Error ? error.message : "Unable to save desktop connection settings.", + ); + setIsSavingConnection(false); + }); + }, [connectionSettings, connectionUrlInput]); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -199,6 +268,61 @@ function SettingsRouteView() { [settings, updateSettings], ); + const connectionSection = isElectron ? ( +
+
+

Connection

+

+ Paste a remote T3 connection URL to use a VPS or Tailnet host. Leave the field blank to + keep using the bundled local server. The saved connection is remembered on this device + until you change it. +

+
+ +
+
+ + setConnectionUrlInput(event.target.value)} + /> +

+ Paste the remote T3 URL. If the server uses auth, include the token query + parameter. Clear the field and save to switch back to the local bundled server. +

+
+ +
+ Current mode:{" "} + + {connectionSettings?.mode === "remote" ? "Remote" : "Local"} + +
+ +
+

+ Save changes, then restart the desktop app to reconnect. Your connection details stay + saved on this machine. +

+ +
+ + {connectionError ?

{connectionError}

: null} +
+
+ ) : null; + return (
@@ -502,6 +626,8 @@ function SettingsRouteView() {
+ {connectionSection} +

Threads

diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb17..cde486baf 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -94,8 +94,24 @@ export interface DesktopUpdateActionResult { state: DesktopUpdateState; } +export type DesktopConnectionMode = "local" | "remote"; + +export interface DesktopConnectionInfo { + mode: DesktopConnectionMode; +} + +export interface DesktopConnectionSettings { + mode: DesktopConnectionMode; + remoteUrl: string; + remoteAuthToken: string; +} + export interface DesktopBridge { getWsUrl: () => string | null; + getConnectionInfo: () => Promise; + getConnectionSettings: () => Promise; + saveConnectionSettings: (settings: DesktopConnectionSettings) => Promise; + restartApp: () => Promise; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise;