From 94cb741c7fe6905ee492d46d9fd2ccb0cc7621e7 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Wed, 3 Dec 2025 21:52:42 +0000 Subject: [PATCH] Add remote access controls --- package-lock.json | 207 ++++++++++++- packages/electron-app/electron/main/ipc.ts | 6 + .../electron/main/process-manager.ts | 48 ++- .../electron-app/electron/preload/index.cjs | 1 + packages/server/src/api-types.ts | 15 + packages/server/src/config/schema.ts | 1 + packages/server/src/index.ts | 4 + packages/server/src/server/http-server.ts | 3 + packages/server/src/server/routes/meta.ts | 96 +++++- packages/tauri-app/Cargo.lock | 111 ++++++- packages/tauri-app/src-tauri/Cargo.toml | 1 + .../tauri-app/src-tauri/src/cli_manager.rs | 76 ++++- packages/tauri-app/src-tauri/src/main.rs | 19 +- packages/ui/package.json | 1 + packages/ui/src/App.tsx | 10 +- packages/ui/src/components/instance-tabs.tsx | 13 +- .../src/components/remote-access-overlay.tsx | 236 ++++++++++++++ packages/ui/src/lib/native/cli.ts | 28 ++ packages/ui/src/lib/server-meta.ts | 4 +- packages/ui/src/stores/preferences.tsx | 21 +- .../src/styles/components/remote-access.css | 289 ++++++++++++++++++ packages/ui/src/styles/controls.css | 1 + packages/ui/src/types/qrcode.d.ts | 3 + 23 files changed, 1165 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/remote-access-overlay.tsx create mode 100644 packages/ui/src/lib/native/cli.ts create mode 100644 packages/ui/src/styles/components/remote-access.css create mode 100644 packages/ui/src/types/qrcode.d.ts diff --git a/package-lock.json b/package-lock.json index fd8d68e3..f8bff560 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2898,6 +2898,15 @@ "node": ">= 0.4" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -3393,6 +3402,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3536,6 +3554,12 @@ "node": ">=0.3.1" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-3.3.0.tgz", @@ -4440,6 +4464,19 @@ "node": ">=14" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4660,7 +4697,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5626,6 +5662,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", @@ -6254,6 +6302,42 @@ "node": ">=8" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -6273,6 +6357,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -6701,6 +6794,98 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -6868,7 +7053,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6883,6 +7067,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -7238,6 +7428,12 @@ "seroval": "^1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -8436,6 +8632,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -8696,6 +8898,7 @@ "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "qrcode": "^1.5.3", "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0" diff --git a/packages/electron-app/electron/main/ipc.ts b/packages/electron-app/electron/main/ipc.ts index 32726727..26f6499e 100644 --- a/packages/electron-app/electron/main/ipc.ts +++ b/packages/electron-app/electron/main/ipc.ts @@ -34,6 +34,12 @@ export function setupCliIPC(mainWindow: BrowserWindow, cliManager: CliProcessMan ipcMain.handle("cli:getStatus", async () => cliManager.getStatus()) + ipcMain.handle("cli:restart", async () => { + const devMode = process.env.NODE_ENV === "development" + await cliManager.stop() + return cliManager.start({ dev: devMode }) + }) + ipcMain.handle("dialog:open", async (_, request: DialogOpenRequest): Promise => { const properties: OpenDialogOptions["properties"] = request.mode === "directory" ? ["openDirectory", "createDirectory"] : ["openFile"] diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 23ce6091..1630b875 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -2,7 +2,8 @@ import { spawn, type ChildProcess } from "child_process" import { app } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" -import { existsSync } from "fs" +import { existsSync, readFileSync } from "fs" +import os from "os" import path from "path" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" @@ -10,6 +11,7 @@ const nodeRequire = createRequire(import.meta.url) type CliState = "starting" | "ready" | "error" | "stopped" +type ListeningMode = "local" | "all" export interface CliStatus { state: CliState @@ -34,6 +36,36 @@ interface CliEntryResolution { runnerPath?: string } +const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" + +function resolveConfigPath(configPath?: string): string { + const target = configPath && configPath.trim().length > 0 ? configPath : DEFAULT_CONFIG_PATH + if (target.startsWith("~/")) { + return path.join(os.homedir(), target.slice(2)) + } + return path.resolve(target) +} + +function resolveHostForMode(mode: ListeningMode): string { + return mode === "local" ? "127.0.0.1" : "0.0.0.0" +} + +function readListeningModeFromConfig(): ListeningMode { + try { + const configPath = resolveConfigPath(process.env.CLI_CONFIG) + if (!existsSync(configPath)) return "local" + const content = readFileSync(configPath, "utf-8") + const parsed = JSON.parse(content) + const mode = parsed?.preferences?.listeningMode + if (mode === "local" || mode === "all") { + return mode + } + } catch (error) { + console.warn("[cli] failed to read listening mode from config", error) + } + return "local" +} + export declare interface CliProcessManager { on(event: "status", listener: (status: CliStatus) => void): this on(event: "ready", listener: (status: CliStatus) => void): this @@ -58,10 +90,12 @@ export class CliProcessManager extends EventEmitter { this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) const cliEntry = this.resolveCliEntry(options) - const args = this.buildCliArgs(options) + const listeningMode = this.resolveListeningMode() + const host = resolveHostForMode(listeningMode) + const args = this.buildCliArgs(options, host) console.info( - `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry}`, + `[cli] launching CodeNomad CLI (${options.dev ? "dev" : "prod"}) using ${cliEntry.runner} at ${cliEntry.entry} (host=${host})`, ) const env = supportsUserShell() ? getUserShellEnv() : { ...process.env } @@ -158,6 +192,10 @@ export class CliProcessManager extends EventEmitter { return { ...this.status } } + private resolveListeningMode(): ListeningMode { + return readListeningModeFromConfig() + } + private handleTimeout() { if (this.child) { this.child.kill("SIGKILL") @@ -232,8 +270,8 @@ export class CliProcessManager extends EventEmitter { this.emit("status", this.status) } - private buildCliArgs(options: StartOptions): string[] { - const args = ["serve", "--host", "0.0.0.0", "--port", "0"] + private buildCliArgs(options: StartOptions, host: string): string[] { + const args = ["serve", "--host", host, "--port", "0"] if (options.dev) { args.push("--ui-dev-server", "http://localhost:3000", "--log-level", "debug") diff --git a/packages/electron-app/electron/preload/index.cjs b/packages/electron-app/electron/preload/index.cjs index dfd2ff10..8a7d6bf6 100644 --- a/packages/electron-app/electron/preload/index.cjs +++ b/packages/electron-app/electron/preload/index.cjs @@ -10,6 +10,7 @@ const electronAPI = { return () => ipcRenderer.removeAllListeners("cli:error") }, getCliStatus: () => ipcRenderer.invoke("cli:getStatus"), + restartCli: () => ipcRenderer.invoke("cli:restart"), openDialog: (options) => ipcRenderer.invoke("dialog:open", options), } diff --git a/packages/server/src/api-types.ts b/packages/server/src/api-types.ts index 75d9bd59..a59b5627 100644 --- a/packages/server/src/api-types.ts +++ b/packages/server/src/api-types.ts @@ -180,15 +180,30 @@ export type WorkspaceEventPayload = | { type: "instance.event"; instanceId: string; event: InstanceStreamEvent } | { type: "instance.eventStatus"; instanceId: string; status: InstanceStreamStatus; reason?: string } +export interface NetworkAddress { + ip: string + family: "ipv4" | "ipv6" + scope: "external" | "internal" | "loopback" + url: string +} + export interface ServerMeta { /** Base URL clients should target for REST calls (useful for Electron embedding). */ httpBaseUrl: string /** SSE endpoint advertised to clients (`/api/events` by default). */ eventsUrl: string + /** Host the server is bound to (e.g., 127.0.0.1 or 0.0.0.0). */ + host: string + /** Listening mode derived from host binding. */ + listeningMode: "local" | "all" + /** Actual port in use after binding. */ + port: number /** Display label for the host (e.g., hostname or friendly name). */ hostLabel: string /** Absolute path of the filesystem root exposed to clients. */ workspaceRoot: string + /** Reachable addresses for this server, external first. */ + addresses: NetworkAddress[] } export type { diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index 266d69b4..28a09b21 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -19,6 +19,7 @@ const PreferencesSchema = z.object({ diagnosticsExpansion: z.enum(["expanded", "collapsed"]).default("expanded"), showUsageMetrics: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true), + listeningMode: z.enum(["local", "all"]).default("local"), }) const RecentFolderSchema = z.object({ diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7c29c87d..73eca663 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -141,8 +141,12 @@ async function main() { const serverMeta: ServerMeta = { httpBaseUrl: `http://${options.host}:${options.port}`, eventsUrl: `/api/events`, + host: options.host, + listeningMode: options.host === "0.0.0.0" ? "all" : "local", + port: options.port, hostLabel: options.host, workspaceRoot: options.rootDir, + addresses: [], } const server = createHttpServer({ diff --git a/packages/server/src/server/http-server.ts b/packages/server/src/server/http-server.ts index f8abb8a9..8929dcc0 100644 --- a/packages/server/src/server/http-server.ts +++ b/packages/server/src/server/http-server.ts @@ -143,6 +143,9 @@ export function createHttpServer(deps: HttpServerDeps) { const serverUrl = `http://${displayHost}:${actualPort}` deps.serverMeta.httpBaseUrl = serverUrl + deps.serverMeta.host = deps.host + deps.serverMeta.port = actualPort + deps.serverMeta.listeningMode = deps.host === "0.0.0.0" ? "all" : "local" deps.logger.info({ port: actualPort, host: deps.host }, "HTTP server listening") console.log(`CodeNomad Server is ready at ${serverUrl}`) diff --git a/packages/server/src/server/routes/meta.ts b/packages/server/src/server/routes/meta.ts index ed8f142f..fdab6d40 100644 --- a/packages/server/src/server/routes/meta.ts +++ b/packages/server/src/server/routes/meta.ts @@ -1,10 +1,102 @@ import { FastifyInstance } from "fastify" -import { ServerMeta } from "../../api-types" +import os from "os" +import { NetworkAddress, ServerMeta } from "../../api-types" interface RouteDeps { serverMeta: ServerMeta } export function registerMetaRoutes(app: FastifyInstance, deps: RouteDeps) { - app.get("/api/meta", async () => deps.serverMeta) + app.get("/api/meta", async () => buildMetaResponse(deps.serverMeta)) +} + +function buildMetaResponse(meta: ServerMeta): ServerMeta { + const port = resolvePort(meta) + const addresses = port > 0 ? resolveAddresses(port, meta.host) : [] + + return { + ...meta, + port, + listeningMode: meta.host === "0.0.0.0" ? "all" : "local", + addresses, + } +} + +function resolvePort(meta: ServerMeta): number { + if (Number.isInteger(meta.port) && meta.port > 0) { + return meta.port + } + try { + const parsed = new URL(meta.httpBaseUrl) + const port = Number(parsed.port) + return Number.isInteger(port) && port > 0 ? port : 0 + } catch { + return 0 + } +} + +function resolveAddresses(port: number, host: string): NetworkAddress[] { + const interfaces = os.networkInterfaces() + const seen = new Set() + const results: NetworkAddress[] = [] + + const addAddress = (ip: string, scope: NetworkAddress["scope"]) => { + if (!ip || ip === "0.0.0.0") return + const key = `ipv4-${ip}` + if (seen.has(key)) return + seen.add(key) + results.push({ ip, family: "ipv4", scope, url: `http://${ip}:${port}` }) + } + + const normalizeFamily = (value: string | number) => { + if (typeof value === "string") { + const lowered = value.toLowerCase() + if (lowered === "ipv4") { + return "ipv4" as const + } + } + if (value === 4) return "ipv4" as const + return null + } + + // Enumerate system interfaces (IPv4 only) + for (const entries of Object.values(interfaces)) { + if (!entries) continue + for (const entry of entries) { + const family = normalizeFamily(entry.family) + if (!family) continue + if (!entry.address || entry.address === "0.0.0.0") continue + const scope: NetworkAddress["scope"] = entry.internal ? "loopback" : "external" + addAddress(entry.address, scope) + } + } + + // Always include loopback address + addAddress("127.0.0.1", "loopback") + + // Include explicitly configured host if it was IPv4 + if (isIPv4Address(host) && host !== "0.0.0.0") { + const isLoopback = host.startsWith("127.") + addAddress(host, isLoopback ? "loopback" : "external") + } + + const scopeWeight: Record = { external: 0, internal: 1, loopback: 2 } + + return results.sort((a, b) => { + const scopeDelta = scopeWeight[a.scope] - scopeWeight[b.scope] + if (scopeDelta !== 0) return scopeDelta + return a.ip.localeCompare(b.ip) + }) +} + +function isIPv4Address(value: string | undefined): value is string { + if (!value) return false + const parts = value.split(".") + if (parts.length !== 4) return false + return parts.every((part) => { + if (part.length === 0 || part.length > 3) return false + if (!/^[0-9]+$/.test(part)) return false + const num = Number(part) + return Number.isInteger(num) && num >= 0 && num <= 255 + }) } diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 7d0b041a..970330da 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -372,6 +372,7 @@ name = "codenomad-tauri" version = "0.1.0" dependencies = [ "anyhow", + "dirs 5.0.1", "libc", "once_cell", "parking_lot", @@ -608,13 +609,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -625,7 +647,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -2804,6 +2826,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3530,7 +3563,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3580,7 +3613,7 @@ checksum = "87d6f8cafe6a75514ce5333f115b7b1866e8e68d9672bf4ca89fc0f35697ea9d" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4106,7 +4139,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3d5572781bee8e3f994d7467084e1b1fd7a93ce66bd480f8156ba89dee55a2b" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2 0.6.3", @@ -4750,6 +4783,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -4792,6 +4834,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -4849,6 +4906,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -4867,6 +4930,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -4885,6 +4954,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -4915,6 +4990,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -4933,6 +5014,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -4951,6 +5038,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -4969,6 +5062,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5031,7 +5130,7 @@ dependencies = [ "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index 57b4eede..f020bd0c 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -18,3 +18,4 @@ anyhow = "1" which = "4" libc = "0.2" tauri-plugin-dialog = "2" +dirs = "5" diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index a71ae049..4220f801 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -1,9 +1,12 @@ +use dirs::home_dir; use parking_lot::Mutex; use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::VecDeque; +use std::env; use std::ffi::OsStr; +use std::fs; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Child, Command, Stdio}; @@ -41,6 +44,66 @@ fn navigate_main(app: &AppHandle, url: &str) { } } +const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; + +#[derive(Debug, Deserialize)] +struct PreferencesConfig { + #[serde(rename = "listeningMode")] + listening_mode: Option, +} + +#[derive(Debug, Deserialize)] +struct AppConfig { + preferences: Option, +} + +fn resolve_config_path() -> PathBuf { + let raw = env::var("CLI_CONFIG") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_CONFIG_PATH.to_string()); + expand_home(&raw) +} + +fn expand_home(path: &str) -> PathBuf { + if path.starts_with("~/") { + if let Some(home) = home_dir().or_else(|| env::var("HOME").ok().map(PathBuf::from)) { + return home.join(path.trim_start_matches("~/")); + } + } + PathBuf::from(path) +} + +fn resolve_listening_mode() -> String { + let path = resolve_config_path(); + if let Ok(content) = fs::read_to_string(path) { + if let Ok(config) = serde_json::from_str::(&content) { + if let Some(mode) = config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + { + if mode == "local" { + return "local".to_string(); + } + if mode == "all" { + return "all".to_string(); + } + } + } + } + "local".to_string() +} + +fn resolve_listening_host() -> String { + let mode = resolve_listening_mode(); + if mode == "local" { + "127.0.0.1".to_string() + } else { + "0.0.0.0".to_string() + } +} + #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CliState { @@ -178,11 +241,12 @@ impl CliProcessManager { ) -> anyhow::Result<()> { log_line("resolving CLI entry"); let resolution = CliEntry::resolve(&app, dev)?; + let host = resolve_listening_host(); log_line(&format!( - "resolved CLI entry runner={:?} entry={}", - resolution.runner, resolution.entry + "resolved CLI entry runner={:?} entry={} host={}", + resolution.runner, resolution.entry, host )); - let args = resolution.build_args(dev); + let args = resolution.build_args(dev, &host); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); @@ -480,11 +544,11 @@ impl CliEntry { )) } - fn build_args(&self, dev: bool) -> Vec { + fn build_args(&self, dev: bool, host: &str) -> Vec { let mut args = vec![ "serve".to_string(), "--host".to_string(), - "0.0.0.0".to_string(), + host.to_string(), "--port".to_string(), "0".to_string(), ]; diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index 59cc2162..b1ab9f43 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -17,6 +17,21 @@ fn cli_get_status(state: tauri::State) -> CliStatus { state.manager.status() } +#[tauri::command] +fn cli_restart(app: AppHandle, state: tauri::State) -> Result { + let dev_mode = is_dev_mode(); + state.manager.stop().map_err(|e| e.to_string())?; + state + .manager + .start(app, dev_mode) + .map_err(|e| e.to_string())?; + Ok(state.manager.status()) +} + +fn is_dev_mode() -> bool { + cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() +} + fn main() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -25,7 +40,7 @@ fn main() { }) .setup(|app| { build_menu(&app.handle())?; - let dev_mode = cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok(); + let dev_mode = is_dev_mode(); let app_handle = app.handle().clone(); let manager = app.state::().manager.clone(); std::thread::spawn(move || { @@ -38,7 +53,7 @@ fn main() { }); Ok(()) }) - .invoke_handler(tauri::generate_handler![cli_get_status]) + .invoke_handler(tauri::generate_handler![cli_get_status, cli_restart]) .on_menu_event(|_app_handle, _event| { // No menu items defined currently }) diff --git a/packages/ui/package.json b/packages/ui/package.json index 8eab63c8..f45b053f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -17,6 +17,7 @@ "github-markdown-css": "^5.8.1", "lucide-solid": "^0.300.0", "marked": "^12.0.0", + "qrcode": "^1.5.3", "shiki": "^3.13.0", "solid-js": "^1.8.0", "solid-toast": "^0.5.0" diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index dd8f5e2c..2d7ba0b6 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -7,6 +7,7 @@ import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell" +import { RemoteAccessOverlay } from "./components/remote-access-overlay" import { initMarkdown } from "./lib/markdown" import { useTheme } from "./lib/theme" import { useCommands } from "./lib/hooks/use-commands" @@ -57,6 +58,7 @@ const App: Component = () => { const [escapeInDebounce, setEscapeInDebounce] = createSignal(false) const [launchErrorBinary, setLaunchErrorBinary] = createSignal(null) const [isAdvancedSettingsOpen, setIsAdvancedSettingsOpen] = createSignal(false) + const [remoteAccessOpen, setRemoteAccessOpen] = createSignal(false) createEffect(() => { void initMarkdown(isDark()).catch(console.error) @@ -284,6 +286,7 @@ const App: Component = () => { onSelect={setActiveInstanceId} onClose={handleCloseInstance} onNew={handleNewInstanceRequest} + onOpenRemoteAccess={() => setRemoteAccessOpen(true)} /> @@ -338,10 +341,13 @@ const App: Component = () => { - + + setRemoteAccessOpen(false)} /> + - + void onClose: (instanceId: string) => void onNew: () => void + onOpenRemoteAccess?: () => void } const InstanceTabs: Component = (props) => { @@ -37,6 +38,16 @@ const InstanceTabs: Component = (props) => { > + + + 1}>
diff --git a/packages/ui/src/components/remote-access-overlay.tsx b/packages/ui/src/components/remote-access-overlay.tsx new file mode 100644 index 00000000..901dab87 --- /dev/null +++ b/packages/ui/src/components/remote-access-overlay.tsx @@ -0,0 +1,236 @@ +import { Dialog } from "@kobalte/core/dialog" +import { Switch } from "@kobalte/core/switch" +import { For, Show, createEffect, createMemo, createSignal } from "solid-js" +import { toDataURL } from "qrcode" +import { ExternalLink, Link2, Loader2, RefreshCw, Shield, Wifi } from "lucide-solid" +import type { NetworkAddress, ServerMeta } from "../../../server/src/api-types" +import { serverApi } from "../lib/api-client" +import { restartCli } from "../lib/native/cli" +import { preferences, setListeningMode } from "../stores/preferences" +import { showConfirmDialog } from "../stores/alerts" + +interface RemoteAccessOverlayProps { + open: boolean + onClose: () => void +} + +export function RemoteAccessOverlay(props: RemoteAccessOverlayProps) { + const [meta, setMeta] = createSignal(null) + const [loading, setLoading] = createSignal(false) + const [qrCodes, setQrCodes] = createSignal>({}) + const [expanded, setExpanded] = createSignal>(new Set()) + const [error, setError] = createSignal(null) + + const addresses = createMemo(() => meta()?.addresses ?? []) + const currentMode = createMemo(() => meta()?.listeningMode ?? preferences().listeningMode) + const allowExternalConnections = createMemo(() => currentMode() === "all") + + const refreshMeta = async () => { + setLoading(true) + setError(null) + try { + const result = await serverApi.fetchServerMeta() + setMeta(result) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setLoading(false) + } + } + + createEffect(() => { + if (props.open) { + void refreshMeta() + } + }) + + const toggleExpanded = async (url: string) => { + const next = new Set(expanded()) + if (next.has(url)) { + next.delete(url) + setExpanded(next) + return + } + next.add(url) + setExpanded(next) + if (!qrCodes()[url]) { + try { + const dataUrl = await toDataURL(url, { margin: 1, scale: 4 }) + setQrCodes((prev) => ({ ...prev, [url]: dataUrl })) + } catch (err) { + console.error("Failed to generate QR code", err) + } + } + } + + const handleAllowConnectionsChange = async (checked: boolean) => { + const allow = Boolean(checked) + const targetMode: "local" | "all" = allow ? "all" : "local" + if (targetMode === currentMode()) { + return + } + + const confirmed = await showConfirmDialog("Restart to apply listening mode? This will stop all running instances.", { + title: allow ? "Open to other devices" : "Limit to this device", + variant: "warning", + confirmLabel: "Restart now", + cancelLabel: "Cancel", + }) + + if (!confirmed) { + // Switch will revert automatically since `checked` is derived from store state + return + } + + setListeningMode(targetMode) + const restarted = await restartCli() + if (!restarted) { + setError("Unable to restart automatically. Please restart the app to apply the change.") + } else { + setMeta((prev) => (prev ? { ...prev, listeningMode: targetMode } : prev)) + } + + void refreshMeta() + } + + const handleOpenUrl = (url: string) => { + try { + window.open(url, "_blank", "noopener,noreferrer") + } catch (err) { + console.error("Failed to open URL", err) + } + } + + return ( + { + if (!nextOpen) { + props.onClose() + } + }} + > + + +
+ +
+
+

Remote access

+

Share this CodeNomad server

+

Choose who can connect and share ready-to-open links or QR codes.

+
+ +
+ +
+
+
+
+ +
+

Listening mode

+

Toggle whether other devices on your network can reach this server.

+
+
+ +
+ + { + void handleAllowConnectionsChange(nextChecked) + }} + > + + + {allowExternalConnections() ? "On" : "Off"} + + +
+ Allow connections from other IPs + + {allowExternalConnections() ? "Binding to 0.0.0.0" : "Binding to 127.0.0.1"} + +
+
+

+ Changing this requires a restart and temporarily stops all active instances. Share the addresses below once the + server restarts. +

+
+ +
+
+
+ +
+

Reachable addresses

+

Use these URLs to connect from this or other devices.

+
+
+
+ + Loading addresses…
}> + {error()}
}> + 0} fallback={
No addresses available yet.
}> +
+ + {(address) => { + const expandedState = () => expanded().has(address.url) + const qr = () => qrCodes()[address.url] + return ( +
+
+
+

{address.url}

+

+ {address.family.toUpperCase()} • {address.scope === "external" ? "Network" : address.scope === "loopback" ? "Loopback" : "Internal"} • {address.ip} +

+
+
+ + +
+
+ +
+ +
+
+
+ ) + }} +
+
+
+ + + +
+ + + + + ) +} diff --git a/packages/ui/src/lib/native/cli.ts b/packages/ui/src/lib/native/cli.ts new file mode 100644 index 00000000..6ec57143 --- /dev/null +++ b/packages/ui/src/lib/native/cli.ts @@ -0,0 +1,28 @@ +import { runtimeEnv } from "../runtime-env" + +export async function restartCli(): Promise { + try { + if (runtimeEnv.host === "electron") { + const api = (window as typeof window & { electronAPI?: { restartCli?: () => Promise } }).electronAPI + if (api?.restartCli) { + await api.restartCli() + return true + } + return false + } + + if (runtimeEnv.host === "tauri") { + const tauri = (window as typeof window & { __TAURI__?: { invoke?: (cmd: string, args?: Record) => Promise } }).__TAURI__ + if (tauri?.invoke) { + await tauri.invoke("cli_restart") + return true + } + return false + } + } catch (error) { + console.error("Failed to restart CLI", error) + return false + } + + return false +} diff --git a/packages/ui/src/lib/server-meta.ts b/packages/ui/src/lib/server-meta.ts index fe83a234..9dbcacbf 100644 --- a/packages/ui/src/lib/server-meta.ts +++ b/packages/ui/src/lib/server-meta.ts @@ -4,8 +4,8 @@ import { serverApi } from "./api-client" let cachedMeta: ServerMeta | null = null let pendingMeta: Promise | null = null -export async function getServerMeta(): Promise { - if (cachedMeta) { +export async function getServerMeta(forceRefresh = false): Promise { + if (cachedMeta && !forceRefresh) { return cachedMeta } if (pendingMeta) { diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 5849dc3f..85b13e98 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -27,6 +27,8 @@ export interface AgentModelSelections { export type DiffViewMode = "split" | "unified" export type ExpansionPreference = "expanded" | "collapsed" +export type ListeningMode = "local" | "all" + export interface Preferences { showThinkingBlocks: boolean thinkingBlocksExpansion: ExpansionPreference @@ -37,10 +39,13 @@ export interface Preferences { toolOutputExpansion: ExpansionPreference diagnosticsExpansion: ExpansionPreference showUsageMetrics: boolean - autoCleanupBlankSessions?: boolean + autoCleanupBlankSessions: boolean + listeningMode: ListeningMode } + export interface OpenCodeBinary { + path: string version?: string lastUsed: number @@ -66,8 +71,10 @@ const defaultPreferences: Preferences = { diagnosticsExpansion: "expanded", showUsageMetrics: true, autoCleanupBlankSessions: true, + listeningMode: "local", } + function deepEqual(a: unknown, b: unknown): boolean { if (a === b) return true if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) { @@ -101,10 +108,12 @@ function normalizePreferences(pref?: Partial & { agentModelSelectio diagnosticsExpansion: sanitized.diagnosticsExpansion ?? defaultPreferences.diagnosticsExpansion, showUsageMetrics: sanitized.showUsageMetrics ?? defaultPreferences.showUsageMetrics, autoCleanupBlankSessions: sanitized.autoCleanupBlankSessions ?? defaultPreferences.autoCleanupBlankSessions, + listeningMode: sanitized.listeningMode ?? defaultPreferences.listeningMode, } } const [internalConfig, setInternalConfig] = createSignal(buildFallbackConfig()) + const config = createMemo>(() => internalConfig()) const [isConfigLoaded, setIsConfigLoaded] = createSignal(false) const preferences = createMemo(() => internalConfig().preferences) @@ -260,6 +269,11 @@ function updatePreferences(updates: Partial): void { }) } +function setListeningMode(mode: ListeningMode): void { + if (preferences().listeningMode === mode) return + updatePreferences({ listeningMode: mode }) +} + function setDiffViewMode(mode: DiffViewMode): void { if (preferences().diffViewMode === mode) return updatePreferences({ diffViewMode: mode }) @@ -399,6 +413,7 @@ interface ConfigContextValue { setToolOutputExpansion: typeof setToolOutputExpansion setDiagnosticsExpansion: typeof setDiagnosticsExpansion setThinkingBlocksExpansion: typeof setThinkingBlocksExpansion + setListeningMode: typeof setListeningMode addRecentFolder: typeof addRecentFolder removeRecentFolder: typeof removeRecentFolder addOpenCodeBinary: typeof addOpenCodeBinary @@ -432,6 +447,7 @@ const configContextValue: ConfigContextValue = { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setListeningMode, addRecentFolder, removeRecentFolder, addOpenCodeBinary, @@ -502,8 +518,11 @@ export { setToolOutputExpansion, setDiagnosticsExpansion, setThinkingBlocksExpansion, + setListeningMode, themePreference, setThemePreference, recordWorkspaceLaunch, } + + diff --git a/packages/ui/src/styles/components/remote-access.css b/packages/ui/src/styles/components/remote-access.css new file mode 100644 index 00000000..700e287c --- /dev/null +++ b/packages/ui/src/styles/components/remote-access.css @@ -0,0 +1,289 @@ +.remote-overlay { + position: fixed; + inset: 0; + z-index: 41; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.modal-overlay.remote-overlay-backdrop { + background: var(--overlay-scrim); + backdrop-filter: blur(6px); + z-index: 40; +} + +.remote-panel { + width: min(960px, 100%); + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.remote-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 20px 24px; + border-bottom: 1px solid var(--border-base); +} + +.remote-eyebrow { + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 11px; + color: var(--text-subtle); + margin: 0 0 4px; +} + +.remote-title { + margin: 0; + font-size: 20px; + color: var(--text-primary); +} + +.remote-subtitle { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 14px; +} + +.remote-close { + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + border-radius: 10px; + padding: 8px 12px; + cursor: pointer; +} + +.remote-body { + padding: 16px 24px 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 16px; +} + +.remote-section { + border: 1px solid var(--border-base); + border-radius: 12px; + background: var(--surface-secondary); + padding: 16px; +} + +.remote-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.remote-section-title { + display: flex; + gap: 10px; + align-items: center; +} + +.remote-icon { + width: 18px; + height: 18px; +} + +.remote-label { + margin: 0; + color: var(--text-primary); + font-weight: 600; +} + +.remote-help { + margin: 2px 0 0; + color: var(--text-secondary); + font-size: 13px; +} + +.remote-refresh { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid var(--border-base); + background: var(--surface-primary); + color: var(--text-primary); + cursor: pointer; +} + +.remote-toggle { + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 12px; + border: 1px solid var(--border-base); + background: var(--surface-primary); + cursor: pointer; +} + +.remote-toggle-switch { + width: 58px; + height: 28px; + border-radius: 999px; + background: var(--surface-secondary); + border: 1px solid var(--border-base); + display: inline-flex; + align-items: center; + justify-content: space-between; + padding: 0 8px 0 6px; + transition: background 0.2s ease, border-color 0.2s ease; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.remote-toggle-state { + pointer-events: none; +} + +.remote-toggle-thumb { + width: 18px; + height: 18px; + border-radius: 999px; + background: var(--surface-primary); + transition: transform 0.2s ease; + transform: translateX(0); +} + +.remote-toggle-switch[data-checked="true"] { + background: var(--accent-primary); + border-color: var(--accent-primary); + color: var(--surface-primary); +} + +.remote-toggle-switch[data-checked="true"] .remote-toggle-thumb { + transform: translateX(20px); +} + +.remote-toggle-copy { + display: flex; + flex-direction: column; + gap: 2px; +} + +.remote-toggle-title { + font-weight: 600; + color: var(--text-primary); +} + +.remote-toggle-caption { + font-size: 13px; + color: var(--text-secondary); +} + +.remote-toggle-note { + margin: 12px 0 0; + font-size: 13px; + color: var(--text-secondary); +} + +.remote-address-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.remote-address { + border: 1px solid var(--border-base); + border-radius: 12px; + padding: 12px; + background: var(--surface-primary); +} + +.remote-address-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.remote-address-url { + margin: 0; + font-weight: 600; + color: var(--text-primary); +} + +.remote-address-meta { + margin: 4px 0 0; + color: var(--text-secondary); + font-size: 12px; +} + +.remote-actions { + display: flex; + gap: 8px; +} + +.remote-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 10px; + border-radius: 999px; + border: 1px solid var(--border-base); + background: var(--surface-secondary); + color: var(--text-primary); + cursor: pointer; +} + +.remote-qr { + margin-top: 12px; + display: flex; + align-items: center; + justify-content: center; + padding: 12px; + border: 1px dashed var(--border-base); + border-radius: 10px; + background: var(--surface-secondary); +} + +.remote-qr-img { + width: 160px; + height: 160px; + image-rendering: pixelated; +} + +.remote-card { + border: 1px dashed var(--border-base); + border-radius: 10px; + padding: 12px; + color: var(--text-secondary); +} + +.remote-error { + border: 1px solid var(--border-critical, #e65c5c); + background: color-mix(in srgb, var(--border-critical, #e65c5c) 10%, transparent); + border-radius: 10px; + padding: 12px; + color: var(--text-primary); +} + +.remote-spin { + animation: remote-spin 1s linear infinite; +} + +@keyframes remote-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index 750f8b1d..e7b71479 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -5,3 +5,4 @@ @import "./components/selector.css"; @import "./components/env-vars.css"; @import "./components/directory-browser.css"; +@import "./components/remote-access.css"; diff --git a/packages/ui/src/types/qrcode.d.ts b/packages/ui/src/types/qrcode.d.ts new file mode 100644 index 00000000..5d0349fb --- /dev/null +++ b/packages/ui/src/types/qrcode.d.ts @@ -0,0 +1,3 @@ +declare module "qrcode" { + export function toDataURL(text: string, opts?: Record): Promise +}