diff --git a/README.md b/README.md index 03e81b5fb9..6d78fd6ce0 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,21 @@ You can also just install the desktop app. It's cooler. Install the [desktop app from the Releases page](https://github.com/pingdotgg/t3code/releases) +## Shared history (web + desktop) + +You can point T3 Code Desktop at the same server-backed history that the web UI is already using and keep one shared chat timeline between web and desktop. + +Set desktop remote mode before launch: + +```bash +export T3CODE_DESKTOP_REMOTE_URL="" +export T3CODE_DESKTOP_REMOTE_AUTH_TOKEN="" +``` + +Desktop will attach to that server as the source of truth (no silent fallback local history backend). +You can also configure the same shared-history mode from the desktop app Settings screen by copying the current server endpoint shown in the web UI. +See [REMOTE.md](./REMOTE.md) for full LAN/Tailscale setup. + ## Some notes We are very very early in this project. Expect bugs. diff --git a/REMOTE.md b/REMOTE.md index 8bbd481dea..51401125cf 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -63,3 +63,34 @@ Open from any device in your tailnet: `http://:3773` You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. + +## 3) Desktop app in remote/shared-history mode + +Use this when the web UI is already using the history you want and desktop should attach to that same backend. + +1. Start one server (same as above) with a persistent `--state-dir` and `--auth-token`. +2. Launch the desktop app with these environment variables: + +```bash +export T3CODE_DESKTOP_REMOTE_URL="" +export T3CODE_DESKTOP_REMOTE_AUTH_TOKEN="$TOKEN" +``` + +Then start T3 Code Desktop normally. + +You can also open `Settings -> Shared History` inside the desktop app, copy the current server endpoint from the web UI, save the same URL/token there, and let the app restart itself into remote mode. + +Behavior in remote mode: + +- Desktop connects directly to the configured remote WebSocket/backend. +- Desktop does **not** start its own persistent local chat backend. +- History shown in desktop comes from that shared remote server state. +- On auth or connectivity failure, desktop surfaces the error and does not silently fall back to local mode. + +### Protocol expectations + +- `http://...` remote URL -> desktop WebSocket uses `ws://...` +- `https://...` remote URL -> desktop WebSocket uses `wss://...` +- If your remote URL already uses `ws://` or `wss://`, it is used as-is. + +Set `T3CODE_DESKTOP_REMOTE_URL` to the same server endpoint the web UI is already using. diff --git a/apps/desktop/src/desktopConnectionSettings.test.ts b/apps/desktop/src/desktopConnectionSettings.test.ts new file mode 100644 index 0000000000..ccb20e0f53 --- /dev/null +++ b/apps/desktop/src/desktopConnectionSettings.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_DESKTOP_CONNECTION_SETTINGS, + normalizeDesktopConnectionSettings, + resolveDesktopConnectionSettingsSnapshot, +} from "./desktopConnectionSettings"; + +describe("normalizeDesktopConnectionSettings", () => { + it("defaults unknown values to local mode with empty strings", () => { + expect(normalizeDesktopConnectionSettings(null)).toEqual(DEFAULT_DESKTOP_CONNECTION_SETTINGS); + }); + + it("trims persisted values", () => { + expect( + normalizeDesktopConnectionSettings({ + mode: "remote", + remoteUrl: " https://chat.example.com ", + authToken: " secret ", + }), + ).toEqual({ + mode: "remote", + remoteUrl: "https://chat.example.com", + authToken: "secret", + }); + }); +}); + +describe("resolveDesktopConnectionSettingsSnapshot", () => { + it("treats the default local config as an implicit default", () => { + expect( + resolveDesktopConnectionSettingsSnapshot({ + saved: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + savedExists: false, + environmentOverride: null, + }), + ).toEqual({ + source: "default", + effective: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + saved: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + }); + }); + + it("prefers an environment override over saved settings", () => { + expect( + resolveDesktopConnectionSettingsSnapshot({ + saved: { + mode: "local", + remoteUrl: "", + authToken: "", + }, + savedExists: true, + environmentOverride: { + mode: "remote", + remoteUrl: "https://chat.example.com", + authToken: "secret", + }, + }), + ).toEqual({ + source: "environment", + effective: { + mode: "remote", + remoteUrl: "https://chat.example.com", + authToken: "secret", + }, + saved: { + mode: "local", + remoteUrl: "", + authToken: "", + }, + }); + }); +}); diff --git a/apps/desktop/src/desktopConnectionSettings.ts b/apps/desktop/src/desktopConnectionSettings.ts new file mode 100644 index 0000000000..26283ad85f --- /dev/null +++ b/apps/desktop/src/desktopConnectionSettings.ts @@ -0,0 +1,104 @@ +import * as FS from "node:fs"; +import * as Path from "node:path"; + +import type { + DesktopConnectionMode, + DesktopConnectionSettings, + DesktopConnectionSettingsSnapshot, + DesktopConnectionSettingsSource, +} from "@t3tools/contracts"; + +const DESKTOP_CONNECTION_SETTINGS_FILENAME = "desktop-connection.json"; + +export const DEFAULT_DESKTOP_CONNECTION_SETTINGS: DesktopConnectionSettings = { + mode: "local", + remoteUrl: "", + authToken: "", +}; + +interface ReadDesktopConnectionSettingsResult { + exists: boolean; + settings: DesktopConnectionSettings; +} + +function normalizeMode(value: unknown): DesktopConnectionMode { + return value === "remote" ? "remote" : "local"; +} + +function normalizeString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +export function normalizeDesktopConnectionSettings( + value: Partial | null | undefined, +): DesktopConnectionSettings { + return { + mode: normalizeMode(value?.mode), + remoteUrl: normalizeString(value?.remoteUrl), + authToken: normalizeString(value?.authToken), + }; +} + +export function resolveDesktopConnectionSettingsPath(stateDir: string): string { + return Path.join(stateDir, DESKTOP_CONNECTION_SETTINGS_FILENAME); +} + +export function readDesktopConnectionSettings(path: string): ReadDesktopConnectionSettingsResult { + if (!FS.existsSync(path)) { + return { + exists: false, + settings: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + }; + } + + try { + const raw = FS.readFileSync(path, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return { + exists: true, + settings: normalizeDesktopConnectionSettings(parsed), + }; + } catch { + return { + exists: true, + settings: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + }; + } +} + +export function writeDesktopConnectionSettings( + path: string, + settings: DesktopConnectionSettings, +): DesktopConnectionSettings { + const normalized = normalizeDesktopConnectionSettings(settings); + const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`; + FS.mkdirSync(Path.dirname(path), { recursive: true }); + FS.writeFileSync(tempPath, `${JSON.stringify(normalized, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + FS.renameSync(tempPath, path); + return normalized; +} + +export function resolveDesktopConnectionSettingsSnapshot(input: { + saved: DesktopConnectionSettings; + savedExists: boolean; + environmentOverride: DesktopConnectionSettings | null; +}): DesktopConnectionSettingsSnapshot { + let source: DesktopConnectionSettingsSource = "default"; + let effective = input.saved; + + if (input.environmentOverride) { + source = "environment"; + effective = input.environmentOverride; + } else if (input.savedExists || input.saved.mode === "remote") { + source = "settings"; + } + + return { + source, + effective, + saved: input.saved, + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index c3dba6016e..8219a9e98a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -18,6 +18,9 @@ import { import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { + DesktopConnectionMode, + DesktopConnectionSettings, + DesktopConnectionSettingsSnapshot, DesktopTheme, DesktopUpdateActionResult, DesktopUpdateState, @@ -28,6 +31,19 @@ import type { ContextMenuItem } from "@t3tools/contracts"; import { NetService } from "@t3tools/shared/Net"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { showDesktopConfirmDialog } from "./confirmDialog"; +import { + DEFAULT_DESKTOP_CONNECTION_SETTINGS, + normalizeDesktopConnectionSettings, + readDesktopConnectionSettings, + resolveDesktopConnectionSettingsPath, + resolveDesktopConnectionSettingsSnapshot, + writeDesktopConnectionSettings, +} from "./desktopConnectionSettings"; +import { + redactTokenInWsUrl, + resolveDesktopConnectionSettingsFromEnv, + resolveDesktopRemoteConnection, +} from "./remoteConnection"; import { syncShellEnvironment } from "./syncShellEnvironment"; import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState"; import { @@ -52,12 +68,16 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; +const CONNECTION_GET_CHANNEL = "desktop:connection:get"; +const CONNECTION_SET_CHANNEL = "desktop:connection:set"; +const RELAUNCH_CHANNEL = "desktop:relaunch"; 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 STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); +const DESKTOP_CONNECTION_SETTINGS_PATH = resolveDesktopConnectionSettingsPath(STATE_DIR); const DESKTOP_SCHEME = "t3"; const ROOT_DIR = Path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); @@ -83,6 +103,12 @@ let backendProcess: ChildProcess.ChildProcess | null = null; let backendPort = 0; let backendAuthToken = ""; let backendWsUrl = ""; +let connectionMode: DesktopConnectionMode = "local"; +let desktopConnectionSettingsSnapshot: DesktopConnectionSettingsSnapshot = { + source: "default", + effective: DEFAULT_DESKTOP_CONNECTION_SETTINGS, + saved: DEFAULT_DESKTOP_CONNECTION_SETTINGS, +}; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; @@ -118,6 +144,16 @@ function writeDesktopLogHeader(message: string): void { desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); } +function refreshDesktopConnectionSettingsSnapshot(): DesktopConnectionSettingsSnapshot { + const { exists, settings } = readDesktopConnectionSettings(DESKTOP_CONNECTION_SETTINGS_PATH); + desktopConnectionSettingsSnapshot = resolveDesktopConnectionSettingsSnapshot({ + saved: settings, + savedExists: exists, + environmentOverride: resolveDesktopConnectionSettingsFromEnv(process.env), + }); + return desktopConnectionSettingsSnapshot; +} + function writeBackendSessionBoundary(phase: "START" | "END", details: string): void { if (!backendLogSink) return; const normalizedDetails = sanitizeLogValue(details); @@ -809,6 +845,11 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed } } +function requestDesktopRelaunch(): void { + app.relaunch(); + app.quit(); +} + function configureAutoUpdater(): void { const enabled = shouldEnableAutoUpdates(); setUpdateState({ @@ -1182,6 +1223,35 @@ function registerIpcHandlers(): void { } }); + ipcMain.removeHandler(CONNECTION_GET_CHANNEL); + ipcMain.handle(CONNECTION_GET_CHANNEL, async () => refreshDesktopConnectionSettingsSnapshot()); + + ipcMain.removeHandler(CONNECTION_SET_CHANNEL); + ipcMain.handle(CONNECTION_SET_CHANNEL, async (_event, rawSettings: unknown) => { + const normalized = normalizeDesktopConnectionSettings( + typeof rawSettings === "object" && rawSettings !== null + ? (rawSettings as Partial) + : undefined, + ); + + if (normalized.mode === "remote") { + resolveDesktopRemoteConnection(normalized); + } + + const saved = writeDesktopConnectionSettings(DESKTOP_CONNECTION_SETTINGS_PATH, normalized); + desktopConnectionSettingsSnapshot = resolveDesktopConnectionSettingsSnapshot({ + saved, + savedExists: true, + environmentOverride: resolveDesktopConnectionSettingsFromEnv(process.env), + }); + return desktopConnectionSettingsSnapshot; + }); + + ipcMain.removeHandler(RELAUNCH_CHANNEL); + ipcMain.handle(RELAUNCH_CHANNEL, async () => { + requestDesktopRelaunch(); + }); + ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL); ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState); @@ -1313,21 +1383,60 @@ configureAppIdentity(); async function bootstrap(): Promise { writeDesktopLogHeader("bootstrap start"); - backendPort = await Effect.service(NetService).pipe( - Effect.flatMap((net) => net.reserveLoopbackPort()), - Effect.provide(NetService.layer), - Effect.runPromise, - ); - writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); - backendAuthToken = Crypto.randomBytes(24).toString("hex"); - backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; + refreshDesktopConnectionSettingsSnapshot(); + + if (desktopConnectionSettingsSnapshot.effective.mode === "remote") { + try { + const remoteConnection = resolveDesktopRemoteConnection( + desktopConnectionSettingsSnapshot.effective, + ); + if (remoteConnection?.disableLocalBackend) { + connectionMode = "remote"; + backendWsUrl = remoteConnection.wsUrl; + writeDesktopLogHeader( + `bootstrap remote mode enabled source=${desktopConnectionSettingsSnapshot.source} origin=${remoteConnection.httpOrigin} websocket=${redactTokenInWsUrl( + backendWsUrl, + )}`, + ); + } + } catch (error) { + if (desktopConnectionSettingsSnapshot.source === "environment") { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + writeDesktopLogHeader( + `bootstrap ignored invalid saved remote settings and fell back to local mode error=${message}`, + ); + } + } + + if (connectionMode !== "remote") { + connectionMode = "local"; + backendPort = await Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort()), + Effect.provide(NetService.layer), + Effect.runPromise, + ); + writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); + backendAuthToken = Crypto.randomBytes(24).toString("hex"); + backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; + } + + process.env.T3CODE_DESKTOP_CONNECTION_MODE = connectionMode; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; - writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); + writeDesktopLogHeader(`bootstrap resolved websocket url=${redactTokenInWsUrl(backendWsUrl)}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); - startBackend(); - writeDesktopLogHeader("bootstrap backend start requested"); + + if (connectionMode === "local") { + startBackend(); + writeDesktopLogHeader("bootstrap backend start requested"); + } else { + writeDesktopLogHeader("bootstrap remote mode active; local backend disabled"); + } + mainWindow = createWindow(); writeDesktopLogHeader("bootstrap main window created"); } diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..ea41caf651 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -7,14 +7,22 @@ const SET_THEME_CHANNEL = "desktop:set-theme"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; const MENU_ACTION_CHANNEL = "desktop:menu-action"; +const CONNECTION_GET_CHANNEL = "desktop:connection:get"; +const CONNECTION_SET_CHANNEL = "desktop:connection:set"; +const RELAUNCH_CHANNEL = "desktop:relaunch"; 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 wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; +const connectionMode = process.env.T3CODE_DESKTOP_CONNECTION_MODE === "remote" ? "remote" : "local"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => wsUrl, + getConnectionMode: () => connectionMode, + getConnectionSettings: () => ipcRenderer.invoke(CONNECTION_GET_CHANNEL), + setConnectionSettings: (settings) => ipcRenderer.invoke(CONNECTION_SET_CHANNEL, settings), + relaunch: () => ipcRenderer.invoke(RELAUNCH_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/desktop/src/remoteConnection.test.ts b/apps/desktop/src/remoteConnection.test.ts new file mode 100644 index 0000000000..0e06dcd932 --- /dev/null +++ b/apps/desktop/src/remoteConnection.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + DesktopRemoteConnectionConfigError, + redactTokenInWsUrl, + resolveDesktopConnectionSettingsFromEnv, + resolveDesktopRemoteConnection, + resolveDesktopRemoteConnectionFromEnv, +} from "./remoteConnection"; + +describe("resolveDesktopRemoteConnectionFromEnv", () => { + it("returns null when remote mode is not configured", () => { + expect(resolveDesktopRemoteConnectionFromEnv({})).toBeNull(); + }); + + it("maps https remote URLs to wss with auth token", () => { + const result = resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "https://chat.example.com/t3", + T3CODE_DESKTOP_REMOTE_AUTH_TOKEN: "secret-token", + }); + + expect(result).toEqual({ + mode: "remote", + wsUrl: "wss://chat.example.com/t3?token=secret-token", + httpOrigin: "https://chat.example.com", + disableLocalBackend: true, + }); + }); + + it("preserves existing query parameters while overriding token", () => { + const result = resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "https://chat.example.com/socket?foo=1&token=old", + T3CODE_DESKTOP_REMOTE_AUTH_TOKEN: "new-token", + }); + + expect(result).toEqual({ + mode: "remote", + wsUrl: "wss://chat.example.com/socket?foo=1&token=new-token", + httpOrigin: "https://chat.example.com", + disableLocalBackend: true, + }); + }); + + it("accepts URLs that already include a token", () => { + const result = resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "wss://chat.example.com/socket?token=embedded", + }); + + expect(result).toEqual({ + mode: "remote", + wsUrl: "wss://chat.example.com/socket?token=embedded", + httpOrigin: "https://chat.example.com", + disableLocalBackend: true, + }); + }); + + it("throws for missing token", () => { + expect(() => + resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "https://chat.example.com/socket", + }), + ).toThrowError(DesktopRemoteConnectionConfigError); + }); + + it("throws for invalid URLs", () => { + expect(() => + resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "not a url", + T3CODE_DESKTOP_REMOTE_AUTH_TOKEN: "abc", + }), + ).toThrowError(DesktopRemoteConnectionConfigError); + }); + + it("throws for unsupported protocols", () => { + expect(() => + resolveDesktopRemoteConnectionFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: "ftp://chat.example.com/socket", + T3CODE_DESKTOP_REMOTE_AUTH_TOKEN: "abc", + }), + ).toThrowError(DesktopRemoteConnectionConfigError); + }); +}); + +describe("redactTokenInWsUrl", () => { + it("redacts token query params", () => { + expect(redactTokenInWsUrl("wss://example.com/?token=abc&x=1")).toBe( + "wss://example.com/?token=%5Bredacted%5D&x=1", + ); + }); + + it("leaves invalid URLs untouched", () => { + expect(redactTokenInWsUrl("not-a-url")).toBe("not-a-url"); + }); +}); + +describe("resolveDesktopConnectionSettingsFromEnv", () => { + it("returns null when the remote URL is unset", () => { + expect(resolveDesktopConnectionSettingsFromEnv({})).toBeNull(); + }); + + it("returns normalized remote settings when configured", () => { + expect( + resolveDesktopConnectionSettingsFromEnv({ + T3CODE_DESKTOP_REMOTE_URL: " https://chat.example.com ", + T3CODE_DESKTOP_REMOTE_AUTH_TOKEN: " secret-token ", + }), + ).toEqual({ + mode: "remote", + remoteUrl: "https://chat.example.com", + authToken: "secret-token", + }); + }); +}); + +describe("resolveDesktopRemoteConnection", () => { + it("returns null for local mode", () => { + expect( + resolveDesktopRemoteConnection({ + mode: "local", + remoteUrl: "https://chat.example.com", + authToken: "secret-token", + }), + ).toBeNull(); + }); + + it("throws for blank remote URLs in remote mode", () => { + expect(() => + resolveDesktopRemoteConnection({ + mode: "remote", + remoteUrl: " ", + authToken: "secret-token", + }), + ).toThrowError(DesktopRemoteConnectionConfigError); + }); +}); diff --git a/apps/desktop/src/remoteConnection.ts b/apps/desktop/src/remoteConnection.ts new file mode 100644 index 0000000000..15bd71706d --- /dev/null +++ b/apps/desktop/src/remoteConnection.ts @@ -0,0 +1,127 @@ +import type { DesktopConnectionSettings } from "@t3tools/contracts"; + +const SUPPORTED_PROTOCOLS = new Set(["http:", "https:", "ws:", "wss:"]); + +const toWebSocketProtocol = (protocol: string): string => { + if (protocol === "http:") return "ws:"; + if (protocol === "https:") return "wss:"; + return protocol; +}; + +const toHttpProtocol = (protocol: string): string => { + if (protocol === "ws:") return "http:"; + if (protocol === "wss:") return "https:"; + return protocol; +}; + +const sanitizeEnvValue = (value: string | undefined): string | null => { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +}; + +export class DesktopRemoteConnectionConfigError extends Error { + readonly _tag = "DesktopRemoteConnectionConfigError" as const; + + constructor(message: string) { + super(message); + this.name = "DesktopRemoteConnectionConfigError"; + } +} + +export interface DesktopRemoteConnectionConfig { + readonly mode: "remote"; + readonly wsUrl: string; + readonly httpOrigin: string; + readonly disableLocalBackend: true; +} + +export function resolveDesktopConnectionSettingsFromEnv( + env: NodeJS.ProcessEnv, +): DesktopConnectionSettings | null { + const remoteUrl = sanitizeEnvValue(env.T3CODE_DESKTOP_REMOTE_URL); + if (!remoteUrl) { + return null; + } + + return { + mode: "remote", + remoteUrl, + authToken: sanitizeEnvValue(env.T3CODE_DESKTOP_REMOTE_AUTH_TOKEN) ?? "", + }; +} + +export function resolveDesktopRemoteConnection( + settings: DesktopConnectionSettings | null, +): DesktopRemoteConnectionConfig | null { + if (settings?.mode !== "remote") { + return null; + } + + const remoteUrlValue = sanitizeEnvValue(settings.remoteUrl); + if (!remoteUrlValue) { + throw new DesktopRemoteConnectionConfigError( + "Remote mode requires a remote URL. Use a full URL such as https://host:3773 or wss://host:3773.", + ); + } + + let parsedRemoteUrl: URL; + try { + parsedRemoteUrl = new URL(remoteUrlValue); + } catch { + throw new DesktopRemoteConnectionConfigError( + "T3CODE_DESKTOP_REMOTE_URL is invalid. Use a full URL such as https://host:3773 or wss://host:3773.", + ); + } + + if (!SUPPORTED_PROTOCOLS.has(parsedRemoteUrl.protocol)) { + throw new DesktopRemoteConnectionConfigError( + `Unsupported remote URL protocol: ${parsedRemoteUrl.protocol}. Use http(s) or ws(s).`, + ); + } + + const explicitToken = sanitizeEnvValue(settings.authToken); + const existingToken = sanitizeEnvValue(parsedRemoteUrl.searchParams.get("token") ?? undefined); + const token = explicitToken ?? existingToken; + + if (!token) { + throw new DesktopRemoteConnectionConfigError( + "Remote mode requires an auth token. Set T3CODE_DESKTOP_REMOTE_AUTH_TOKEN or include ?token=... in T3CODE_DESKTOP_REMOTE_URL.", + ); + } + + const wsUrl = new URL(parsedRemoteUrl.toString()); + wsUrl.protocol = toWebSocketProtocol(parsedRemoteUrl.protocol); + wsUrl.hash = ""; + wsUrl.searchParams.set("token", token); + + const httpOriginUrl = new URL(wsUrl.toString()); + httpOriginUrl.protocol = toHttpProtocol(wsUrl.protocol); + httpOriginUrl.pathname = "/"; + httpOriginUrl.search = ""; + httpOriginUrl.hash = ""; + + return { + mode: "remote", + wsUrl: wsUrl.toString(), + httpOrigin: httpOriginUrl.origin, + disableLocalBackend: true, + }; +} + +export function resolveDesktopRemoteConnectionFromEnv( + env: NodeJS.ProcessEnv, +): DesktopRemoteConnectionConfig | null { + return resolveDesktopRemoteConnection(resolveDesktopConnectionSettingsFromEnv(env)); +} + +export function redactTokenInWsUrl(rawUrl: string): string { + try { + const parsed = new URL(rawUrl); + if (parsed.searchParams.has("token")) { + parsed.searchParams.set("token", "[redacted]"); + } + return parsed.toString(); + } catch { + return rawUrl; + } +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 83a5b2c72d..c226df057f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -41,6 +41,7 @@ import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; +import { resolveRuntimeHttpOrigin } from "../connection"; import { shortcutLabelForCommand } from "../keybindings"; import { derivePendingApprovals, derivePendingUserInputs } from "../session-logic"; import { gitRemoveWorktreeMutationOptions, gitStatusQueryOptions } from "../lib/gitReactQuery"; @@ -183,21 +184,7 @@ function T3Wordmark() { * sources WsTransport uses, converting ws(s) to http(s). */ function getServerHttpOrigin(): string { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - const wsUrl = - bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`; - // Parse to extract just the origin, dropping path/query (e.g. ?token=…) - const httpUrl = wsUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:"); - try { - return new URL(httpUrl).origin; - } catch { - return httpUrl; - } + return resolveRuntimeHttpOrigin(); } const serverHttpOrigin = getServerHttpOrigin(); diff --git a/apps/web/src/connection.test.ts b/apps/web/src/connection.test.ts new file mode 100644 index 0000000000..98b2d4ef9b --- /dev/null +++ b/apps/web/src/connection.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + buildDefaultWsUrl, + resolveHttpOriginFromWsUrl, + resolveRuntimeWsUrl, + resolveWsUrlFromSources, +} from "./connection"; + +describe("buildDefaultWsUrl", () => { + it("uses wss for https pages and preserves host", () => { + expect( + buildDefaultWsUrl({ + pageProtocol: "https:", + pageHost: "chat.example.com:443", + pageHostname: "chat.example.com", + }), + ).toBe("wss://chat.example.com:443"); + }); + + it("falls back to hostname when host is empty", () => { + expect( + buildDefaultWsUrl({ + pageProtocol: "http:", + pageHost: "", + pageHostname: "chat.example.com", + }), + ).toBe("ws://chat.example.com"); + }); +}); + +describe("resolveWsUrlFromSources", () => { + const defaultInput = { + pageProtocol: "http:", + pageHost: "localhost:3773", + pageHostname: "localhost", + }; + + it("prefers explicit URL over desktop bridge and env values", () => { + expect( + resolveWsUrlFromSources({ + ...defaultInput, + explicitUrl: "wss://explicit.example.com/socket", + bridgeWsUrl: "wss://bridge.example.com/socket", + envWsUrl: "wss://env.example.com/socket", + }), + ).toBe("wss://explicit.example.com/socket"); + }); + + it("uses desktop bridge URL before env URL", () => { + expect( + resolveWsUrlFromSources({ + ...defaultInput, + bridgeWsUrl: "wss://bridge.example.com/socket", + envWsUrl: "wss://env.example.com/socket", + }), + ).toBe("wss://bridge.example.com/socket"); + }); + + it("falls back to env URL when desktop bridge is unavailable", () => { + expect( + resolveWsUrlFromSources({ + ...defaultInput, + envWsUrl: "wss://env.example.com/socket", + }), + ).toBe("wss://env.example.com/socket"); + }); +}); + +describe("resolveHttpOriginFromWsUrl", () => { + it("maps ws and wss URLs to http(s) origins", () => { + expect( + resolveHttpOriginFromWsUrl({ + wsUrl: "wss://chat.example.com/socket?token=abc", + fallbackOrigin: "https://fallback.example.com", + }), + ).toBe("https://chat.example.com"); + expect( + resolveHttpOriginFromWsUrl({ + wsUrl: "ws://chat.example.com:3773/socket?token=abc", + fallbackOrigin: "https://fallback.example.com", + }), + ).toBe("http://chat.example.com:3773"); + }); + + it("uses fallback origin when the URL is invalid", () => { + expect( + resolveHttpOriginFromWsUrl({ + wsUrl: "not-a-url", + fallbackOrigin: "https://fallback.example.com", + }), + ).toBe("https://fallback.example.com"); + }); +}); + +describe("resolveRuntimeWsUrl", () => { + it("treats an empty explicit URL as missing during SSR", () => { + expect(resolveRuntimeWsUrl("")).toBe("ws://localhost:3773"); + }); +}); diff --git a/apps/web/src/connection.ts b/apps/web/src/connection.ts new file mode 100644 index 0000000000..342cbc0d4e --- /dev/null +++ b/apps/web/src/connection.ts @@ -0,0 +1,90 @@ +import type { DesktopConnectionMode } from "@t3tools/contracts"; + +function asNonEmptyString(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function buildDefaultWsUrl(input: { + pageProtocol: string; + pageHost: string; + pageHostname: string; +}): string { + const protocol = input.pageProtocol === "https:" ? "wss" : "ws"; + const host = asNonEmptyString(input.pageHost) ?? input.pageHostname; + return `${protocol}://${host}`; +} + +export function resolveWsUrlFromSources(input: { + explicitUrl?: string | null | undefined; + bridgeWsUrl?: string | null | undefined; + envWsUrl?: string | null | undefined; + pageProtocol: string; + pageHost: string; + pageHostname: string; +}): string { + return ( + asNonEmptyString(input.explicitUrl) ?? + asNonEmptyString(input.bridgeWsUrl) ?? + asNonEmptyString(input.envWsUrl) ?? + buildDefaultWsUrl({ + pageProtocol: input.pageProtocol, + pageHost: input.pageHost, + pageHostname: input.pageHostname, + }) + ); +} + +export function resolveRuntimeWsUrl(explicitUrl?: string): string { + if (typeof window === "undefined") { + return asNonEmptyString(explicitUrl) ?? "ws://localhost:3773"; + } + + const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); + const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; + return resolveWsUrlFromSources({ + explicitUrl, + bridgeWsUrl, + envWsUrl, + pageProtocol: window.location.protocol, + pageHost: window.location.host, + pageHostname: window.location.hostname, + }); +} + +export function resolveHttpOriginFromWsUrl(input: { + wsUrl: string; + fallbackOrigin: string; +}): string { + try { + const parsed = new URL(input.wsUrl); + if (parsed.protocol === "wss:") { + parsed.protocol = "https:"; + } else if (parsed.protocol === "ws:") { + parsed.protocol = "http:"; + } + return parsed.origin; + } catch { + return input.fallbackOrigin; + } +} + +export function resolveRuntimeHttpOrigin(): string { + if (typeof window === "undefined") { + return ""; + } + return resolveHttpOriginFromWsUrl({ + wsUrl: resolveRuntimeWsUrl(), + fallbackOrigin: window.location.origin, + }); +} + +export function resolveDesktopConnectionMode(): DesktopConnectionMode | null { + if (typeof window === "undefined") { + return null; + } + return window.desktopBridge?.getConnectionMode?.() ?? null; +} diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index b4afcdefa1..3bcce93d30 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,11 +1,21 @@ 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 { + DesktopConnectionMode, + DesktopConnectionSettings, + DesktopConnectionSettingsSnapshot, + 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 { + resolveDesktopConnectionMode, + resolveRuntimeHttpOrigin, + resolveRuntimeWsUrl, +} from "../connection"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; @@ -62,6 +72,16 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +function describeConnectionMode(mode: DesktopConnectionMode | null): string { + if (mode === "remote") { + return "Remote shared backend"; + } + if (mode === "local") { + return "Local desktop backend"; + } + return "Browser session"; +} + function getCustomModelsForProvider( settings: ReturnType["settings"], provider: ProviderKind, @@ -92,6 +112,36 @@ function patchCustomModels(provider: ProviderKind, models: string[]) { } } +const EMPTY_DESKTOP_CONNECTION_SETTINGS: DesktopConnectionSettings = { + mode: "local", + remoteUrl: "", + authToken: "", +}; + +function describeConnectionSettingsSource( + source: DesktopConnectionSettingsSnapshot["source"] | null, +): string | null { + if (source === "environment") { + return "Managed by environment variables"; + } + if (source === "settings") { + return "Saved in desktop settings"; + } + if (source === "default") { + return "Using desktop defaults"; + } + return null; +} + +function readRuntimeWsAuthState(): "token-present" | "token-missing" | "unknown" { + try { + const parsed = new URL(resolveRuntimeWsUrl()); + return parsed.searchParams.get("token") ? "token-present" : "token-missing"; + } catch { + return "unknown"; + } +} + function SettingsRouteView() { const { theme, setTheme, resolvedTheme } = useTheme(); const { settings, defaults, updateSettings } = useAppSettings(); @@ -106,11 +156,53 @@ function SettingsRouteView() { const [customModelErrorByProvider, setCustomModelErrorByProvider] = useState< Partial> >({}); + const [desktopConnectionSettings, setDesktopConnectionSettings] = + useState(null); + const [desktopConnectionDraft, setDesktopConnectionDraft] = useState( + EMPTY_DESKTOP_CONNECTION_SETTINGS, + ); + const [desktopConnectionError, setDesktopConnectionError] = useState(null); + const [isSavingDesktopConnection, setIsSavingDesktopConnection] = useState(false); const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; const availableEditors = serverConfigQuery.data?.availableEditors; + const desktopConnectionMode = isElectron ? resolveDesktopConnectionMode() : null; + const serverHttpOrigin = resolveRuntimeHttpOrigin(); + const runtimeWsAuthState = readRuntimeWsAuthState(); + const desktopConnectionSource = desktopConnectionSettings?.source ?? null; + const desktopConnectionSourceLabel = describeConnectionSettingsSource(desktopConnectionSource); + const desktopConnectionManagedByEnvironment = desktopConnectionSource === "environment"; + const hasUnsavedDesktopConnectionChanges = + desktopConnectionDraft.mode !== (desktopConnectionSettings?.saved.mode ?? "local") || + desktopConnectionDraft.remoteUrl !== (desktopConnectionSettings?.saved.remoteUrl ?? "") || + desktopConnectionDraft.authToken !== (desktopConnectionSettings?.saved.authToken ?? ""); + + useEffect(() => { + if (!isElectron || !window.desktopBridge) { + return; + } + + let cancelled = false; + void window.desktopBridge + .getConnectionSettings() + .then((snapshot) => { + if (cancelled) return; + setDesktopConnectionSettings(snapshot); + setDesktopConnectionDraft(snapshot.saved); + }) + .catch((error) => { + if (cancelled) return; + setDesktopConnectionError( + error instanceof Error ? error.message : "Unable to load connection settings.", + ); + }); + + return () => { + cancelled = true; + }; + }, []); const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; @@ -199,6 +291,34 @@ function SettingsRouteView() { [settings, updateSettings], ); + const saveDesktopConnectionSettings = useCallback(async () => { + if (!window.desktopBridge) return; + + const confirmed = await ensureNativeApi().dialogs.confirm( + desktopConnectionDraft.mode === "remote" + ? "Save shared history settings and restart T3 Code now?" + : "Switch back to local desktop history and restart T3 Code now?", + ); + if (!confirmed) { + return; + } + + setDesktopConnectionError(null); + setIsSavingDesktopConnection(true); + try { + const snapshot = await window.desktopBridge.setConnectionSettings(desktopConnectionDraft); + setDesktopConnectionSettings(snapshot); + setDesktopConnectionDraft(snapshot.saved); + await window.desktopBridge.relaunch(); + } catch (error) { + setDesktopConnectionError( + error instanceof Error ? error.message : "Unable to save connection settings.", + ); + } finally { + setIsSavingDesktopConnection(false); + } + }, [desktopConnectionDraft]); + return (
@@ -219,6 +339,189 @@ function SettingsRouteView() {

+
+
+

Shared History

+

+ Attach desktop to one server-backed chat history instead of running a separate + local desktop history. +

+
+ +
+
+
+

Current mode

+

+ {desktopConnectionMode === "remote" + ? "Shared history from a remote server." + : isElectron + ? "Desktop-local backend and history." + : "Browser session connected to this server."} +

+
+ + {describeConnectionMode(desktopConnectionMode)} + +
+ +
+

Current server endpoint

+

+ {serverHttpOrigin} +

+
+ + {isElectron ? ( + <> +
+
+

+ Use a shared remote backend +

+

+ Remote mode disables the persistent local desktop backend and uses one + server-backed history instead. +

+
+ + setDesktopConnectionDraft((existing) => ({ + ...existing, + mode: checked ? "remote" : "local", + })) + } + aria-label="Use a shared remote backend" + /> +
+ + {desktopConnectionSourceLabel ? ( +

+ {desktopConnectionSourceLabel} +

+ ) : null} + + {desktopConnectionDraft.mode === "remote" ? ( +
+ + + +
+ ) : ( +

+ Local mode keeps desktop history on this device and starts the desktop + backend on loopback. +

+ )} + +
+

+ Saving connection changes restarts the desktop app. +

+
+ {hasUnsavedDesktopConnectionChanges ? ( + + ) : null} + +
+
+ + {desktopConnectionMode === "remote" ? ( +

+ Remote mode uses server-backed history and does not merge old desktop-local + chats automatically. +

+ ) : null} + + ) : ( + <> +
+ This web UI is already using the server-backed history for this origin. Enable the same mode in T3 Code Desktop with the server endpoint shown above to share chats between desktop and web cleanly. +
+

+ WebSocket auth:{" "} + + {runtimeWsAuthState === "token-present" + ? "authenticated URL detected" + : runtimeWsAuthState === "token-missing" + ? "no token detected in runtime URL" + : "unknown"} + +

+ + )} + + {desktopConnectionError ? ( +

{desktopConnectionError}

+ ) : null} +
+
+

Appearance

diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index faebe4b0fb..a76424f15b 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -15,6 +15,7 @@ import { import { create } from "zustand"; import { type ChatMessage, type Project, type Thread } from "./types"; import { Debouncer } from "@tanstack/react-pacer"; +import { resolveRuntimeHttpOrigin } from "./connection"; // ── State ──────────────────────────────────────────────────────────── @@ -212,24 +213,7 @@ function inferProviderForThreadModel(input: { } function resolveWsHttpOrigin(): string { - if (typeof window === "undefined") return ""; - const bridgeWsUrl = window.desktopBridge?.getWsUrl?.(); - const envWsUrl = import.meta.env.VITE_WS_URL as string | undefined; - const wsCandidate = - typeof bridgeWsUrl === "string" && bridgeWsUrl.length > 0 - ? bridgeWsUrl - : typeof envWsUrl === "string" && envWsUrl.length > 0 - ? envWsUrl - : null; - if (!wsCandidate) return window.location.origin; - try { - const wsUrl = new URL(wsCandidate); - const protocol = - wsUrl.protocol === "wss:" ? "https:" : wsUrl.protocol === "ws:" ? "http:" : wsUrl.protocol; - return `${protocol}//${wsUrl.host}`; - } catch { - return window.location.origin; - } + return resolveRuntimeHttpOrigin(); } function toAttachmentPreviewUrl(rawUrl: string): string { diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts index 46c74d9090..240ee92e63 100644 --- a/apps/web/src/wsTransport.ts +++ b/apps/web/src/wsTransport.ts @@ -8,6 +8,7 @@ import { } from "@t3tools/contracts"; import { decodeUnknownJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; import { Result, Schema } from "effect"; +import { resolveRuntimeWsUrl } from "./connection"; type PushListener = (message: WsPushMessage) => void; @@ -60,15 +61,7 @@ export class WsTransport { private readonly url: string; constructor(url?: string) { - const bridgeUrl = window.desktopBridge?.getWsUrl(); - const envUrl = import.meta.env.VITE_WS_URL as string | undefined; - this.url = - url ?? - (bridgeUrl && bridgeUrl.length > 0 - ? bridgeUrl - : envUrl && envUrl.length > 0 - ? envUrl - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`); + this.url = resolveRuntimeWsUrl(url); this.connect(); } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b9127fb176..7eb8bacb35 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -65,6 +65,20 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; +export type DesktopConnectionMode = "local" | "remote"; +export type DesktopConnectionSettingsSource = "default" | "settings" | "environment"; + +export interface DesktopConnectionSettings { + mode: DesktopConnectionMode; + remoteUrl: string; + authToken: string; +} + +export interface DesktopConnectionSettingsSnapshot { + source: DesktopConnectionSettingsSource; + effective: DesktopConnectionSettings; + saved: DesktopConnectionSettings; +} export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; @@ -96,6 +110,12 @@ export interface DesktopUpdateActionResult { export interface DesktopBridge { getWsUrl: () => string | null; + getConnectionMode: () => DesktopConnectionMode; + getConnectionSettings: () => Promise; + setConnectionSettings: ( + settings: DesktopConnectionSettings, + ) => Promise; + relaunch: () => Promise; pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise;