From 2806625f1ec861c399e528c91372a89478ad750c Mon Sep 17 00:00:00 2001 From: pascalandr Date: Sat, 18 Apr 2026 13:02:21 +0200 Subject: [PATCH 1/4] fix(desktop): support configured Tauri server ports Read persisted httpPort and httpsPort from the desktop config so launcher-started Tauri sessions can honor fixed ports without relying on shell environment variables. --- .../tauri-app/src-tauri/src/cli_manager.rs | 116 ++++++++++++------ 1 file changed, 78 insertions(+), 38 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 358523e3..7f1a177a 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -302,12 +302,20 @@ const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; struct PreferencesConfig { #[serde(rename = "listeningMode")] listening_mode: Option, + #[serde(rename = "httpPort")] + http_port: Option, + #[serde(rename = "httpsPort")] + https_port: Option, } #[derive(Debug, Deserialize)] struct ServerConfig { #[serde(rename = "listeningMode")] listening_mode: Option, + #[serde(rename = "httpPort")] + http_port: Option, + #[serde(rename = "httpsPort")] + https_port: Option, } #[derive(Debug, Deserialize)] @@ -354,59 +362,69 @@ fn expand_home(path: &str) -> PathBuf { PathBuf::from(path) } -fn resolve_listening_mode() -> String { +fn read_app_config() -> Option { let (yaml_path, json_path) = resolve_config_locations(); if let Ok(content) = fs::read_to_string(&yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { - let mode = config - .server - .as_ref() - .and_then(|srv| srv.listening_mode.as_ref()) - .or_else(|| { - config - .preferences - .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - }); - - if let Some(mode) = mode { - if mode == "local" { - return "local".to_string(); - } - if mode == "all" { - return "all".to_string(); - } - } + return Some(config); } } - // Legacy fallback. if let Ok(content) = fs::read_to_string(&json_path) { if let Ok(config) = serde_json::from_str::(&content) { - let mode = config - .server - .as_ref() - .and_then(|srv| srv.listening_mode.as_ref()) - .or_else(|| { - config - .preferences - .as_ref() - .and_then(|prefs| prefs.listening_mode.as_ref()) - }); - if let Some(mode) = mode { - if mode == "local" { - return "local".to_string(); - } - if mode == "all" { - return "all".to_string(); - } + return Some(config); + } + } + + None +} + +fn resolve_listening_mode() -> String { + if let Some(config) = read_app_config() { + let mode = config + .server + .as_ref() + .and_then(|srv| srv.listening_mode.as_ref()) + .or_else(|| { + config + .preferences + .as_ref() + .and_then(|prefs| prefs.listening_mode.as_ref()) + }); + + if let Some(mode) = mode { + if mode == "local" { + return "local".to_string(); + } + if mode == "all" { + return "all".to_string(); } } } + "local".to_string() } +fn resolve_configured_ports() -> (Option, Option) { + let Some(config) = read_app_config() else { + return (None, None); + }; + + let https_port = config + .server + .as_ref() + .and_then(|srv| srv.https_port) + .or_else(|| config.preferences.as_ref().and_then(|prefs| prefs.https_port)); + let http_port = config + .server + .as_ref() + .and_then(|srv| srv.http_port) + .or_else(|| config.preferences.as_ref().and_then(|prefs| prefs.http_port)); + + (https_port, http_port) +} + fn resolve_listening_host() -> String { let mode = resolve_listening_mode(); if mode == "local" { @@ -1115,6 +1133,28 @@ impl CliEntry { args.push("true".to_string()); args.push("--http".to_string()); args.push("true".to_string()); + + let https_port_env = std::env::var("CLI_HTTPS_PORT") + .ok() + .filter(|value| !value.trim().is_empty()); + let http_port_env = std::env::var("CLI_HTTP_PORT") + .ok() + .filter(|value| !value.trim().is_empty()); + let (configured_https_port, configured_http_port) = resolve_configured_ports(); + + if https_port_env.is_none() { + if let Some(port) = configured_https_port { + args.push("--https-port".to_string()); + args.push(port.to_string()); + } + } + + if http_port_env.is_none() { + if let Some(port) = configured_http_port { + args.push("--http-port".to_string()); + args.push(port.to_string()); + } + } } args } From 174f58cf4f57b9ffdaf5d5e336baa3de25042406 Mon Sep 17 00:00:00 2001 From: pascalandr Date: Sat, 18 Apr 2026 20:03:36 +0200 Subject: [PATCH 2/4] test(desktop): cover configured port precedence Refactor the configured-port resolution into small pure helpers and add focused tests for the precedence path so env overrides, persisted config, and config parsing are covered explicitly. --- .../tauri-app/src-tauri/src/cli_manager.rs | 126 +++++++++++++++--- 1 file changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 7f1a177a..f04ef59c 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -364,14 +364,17 @@ fn expand_home(path: &str) -> PathBuf { fn read_app_config() -> Option { let (yaml_path, json_path) = resolve_config_locations(); + read_app_config_from_paths(&yaml_path, &json_path) +} - if let Ok(content) = fs::read_to_string(&yaml_path) { +fn read_app_config_from_paths(yaml_path: &PathBuf, json_path: &PathBuf) -> Option { + if let Ok(content) = fs::read_to_string(yaml_path) { if let Ok(config) = serde_yaml::from_str::(&content) { return Some(config); } } - if let Ok(content) = fs::read_to_string(&json_path) { + if let Ok(content) = fs::read_to_string(json_path) { if let Ok(config) = serde_json::from_str::(&content) { return Some(config); } @@ -411,6 +414,10 @@ fn resolve_configured_ports() -> (Option, Option) { return (None, None); }; + resolve_configured_ports_from_config(&config) +} + +fn resolve_configured_ports_from_config(config: &AppConfig) -> (Option, Option) { let https_port = config .server .as_ref() @@ -425,6 +432,31 @@ fn resolve_configured_ports() -> (Option, Option) { (https_port, http_port) } +fn apply_configured_ports( + args: &mut Vec, + https_port_env: Option<&str>, + http_port_env: Option<&str>, + configured_https_port: Option, + configured_http_port: Option, +) { + let https_env_present = https_port_env.is_some_and(|value| !value.trim().is_empty()); + let http_env_present = http_port_env.is_some_and(|value| !value.trim().is_empty()); + + if !https_env_present { + if let Some(port) = configured_https_port { + args.push("--https-port".to_string()); + args.push(port.to_string()); + } + } + + if !http_env_present { + if let Some(port) = configured_http_port { + args.push("--http-port".to_string()); + args.push(port.to_string()); + } + } +} + fn resolve_listening_host() -> String { let mode = resolve_listening_mode(); if mode == "local" { @@ -1141,20 +1173,13 @@ impl CliEntry { .ok() .filter(|value| !value.trim().is_empty()); let (configured_https_port, configured_http_port) = resolve_configured_ports(); - - if https_port_env.is_none() { - if let Some(port) = configured_https_port { - args.push("--https-port".to_string()); - args.push(port.to_string()); - } - } - - if http_port_env.is_none() { - if let Some(port) = configured_http_port { - args.push("--http-port".to_string()); - args.push(port.to_string()); - } - } + apply_configured_ports( + &mut args, + https_port_env.as_deref(), + http_port_env.as_deref(), + configured_https_port, + configured_http_port, + ); } args } @@ -1356,3 +1381,72 @@ fn normalize_path(path: PathBuf) -> String { rendered } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn unique_temp_dir(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("codenomad-{name}-{nanos}")) + } + + #[test] + fn resolve_configured_ports_prefers_server_values() { + let config = AppConfig { + preferences: Some(PreferencesConfig { + listening_mode: None, + http_port: Some(3000), + https_port: Some(3443), + }), + server: Some(ServerConfig { + listening_mode: None, + http_port: Some(4000), + https_port: Some(4443), + }), + }; + + let ports = resolve_configured_ports_from_config(&config); + + assert_eq!(ports, (Some(4443), Some(4000))); + } + + #[test] + fn apply_configured_ports_keeps_env_override_priority() { + let mut args = vec!["serve".to_string()]; + + apply_configured_ports(&mut args, Some("8443"), None, Some(4443), Some(4000)); + + assert_eq!(args, vec!["serve", "--http-port", "4000"]); + } + + #[test] + fn resolve_configured_ports_reads_yaml_config() { + let _guard = env_lock().lock().unwrap(); + let dir = unique_temp_dir("cli-config"); + fs::create_dir_all(&dir).unwrap(); + let yaml_path = dir.join("config.yaml"); + let json_path = dir.join("config.json"); + + fs::write( + &yaml_path, + "server:\n httpsPort: 60598\n httpPort: 60599\npreferences:\n httpsPort: 7443\n httpPort: 7000\n", + ) + .unwrap(); + + let config = read_app_config_from_paths(&yaml_path, &json_path).unwrap(); + + assert_eq!(resolve_configured_ports_from_config(&config), (Some(60598), Some(60599))); + + let _ = fs::remove_dir_all(dir); + } +} From d4274813f20b8f2d6064519427543cbf4ecc1dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 14:07:50 +0200 Subject: [PATCH 3/4] fix(desktop): honor configured Electron ports Read persisted httpPort and httpsPort in the Electron launcher so packaged desktop sessions follow the same env-overrides-config precedence already added for Tauri. --- .../electron/main/cli-config.test.ts | 58 ++++++++ .../electron-app/electron/main/cli-config.ts | 131 ++++++++++++++++++ .../electron/main/process-manager.ts | 79 ++--------- 3 files changed, 199 insertions(+), 69 deletions(-) create mode 100644 packages/electron-app/electron/main/cli-config.test.ts create mode 100644 packages/electron-app/electron/main/cli-config.ts diff --git a/packages/electron-app/electron/main/cli-config.test.ts b/packages/electron-app/electron/main/cli-config.test.ts new file mode 100644 index 00000000..c69b6ee2 --- /dev/null +++ b/packages/electron-app/electron/main/cli-config.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { describe, it } from "node:test" + +import { applyConfiguredPorts, readAppConfigFromPaths, resolveConfiguredPortsFromConfig } from "./cli-config" + +describe("resolveConfiguredPortsFromConfig", () => { + it("prefers server port values over preferences", () => { + const ports = resolveConfiguredPortsFromConfig({ + preferences: { + httpPort: 3000, + httpsPort: 3443, + }, + server: { + httpPort: 4000, + httpsPort: 4443, + }, + }) + + assert.deepEqual(ports, [4443, 4000]) + }) +}) + +describe("applyConfiguredPorts", () => { + it("keeps env vars as the highest-priority override", () => { + const args = ["serve"] + + applyConfiguredPorts(args, { + httpsPortEnv: "8443", + configuredHttpsPort: 4443, + configuredHttpPort: 4000, + }) + + assert.deepEqual(args, ["serve", "--http-port", "4000"]) + }) +}) + +describe("readAppConfigFromPaths", () => { + it("reads configured ports from yaml config", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "codenomad-electron-cli-config-")) + const yamlPath = path.join(dir, "config.yaml") + const jsonPath = path.join(dir, "config.json") + + try { + fs.writeFileSync( + yamlPath, + "server:\n httpsPort: 60598\n httpPort: 60599\npreferences:\n httpsPort: 7443\n httpPort: 7000\n", + ) + + const config = readAppConfigFromPaths(yamlPath, jsonPath) + assert.deepEqual(resolveConfiguredPortsFromConfig(config), [60598, 60599]) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/electron-app/electron/main/cli-config.ts b/packages/electron-app/electron/main/cli-config.ts new file mode 100644 index 00000000..9cf83f20 --- /dev/null +++ b/packages/electron-app/electron/main/cli-config.ts @@ -0,0 +1,131 @@ +import { existsSync, readFileSync } from "fs" +import os from "os" +import path from "path" +import { parse as parseYaml } from "yaml" + +export type ListeningMode = "local" | "all" + +interface PreferencesConfig { + listeningMode?: string + httpPort?: number + httpsPort?: number +} + +interface ServerConfig { + listeningMode?: string + httpPort?: number + httpsPort?: number +} + +interface AppConfig { + preferences?: PreferencesConfig + server?: ServerConfig +} + +const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" + +function isYamlPath(filePath: string): boolean { + const lower = filePath.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +function isJsonPath(filePath: string): boolean { + return filePath.toLowerCase().endsWith(".json") +} + +export function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } { + const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH + const resolved = resolveConfigPath(target) + + if (isYamlPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") } + } + + if (isJsonPath(resolved)) { + const baseDir = path.dirname(resolved) + return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved } + } + + return { + configYamlPath: path.join(resolved, "config.yaml"), + legacyJsonPath: path.join(resolved, "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 readAppConfig(configPath?: string): AppConfig | null { + const { configYamlPath, legacyJsonPath } = resolveConfigPaths(configPath) + return readAppConfigFromPaths(configYamlPath, legacyJsonPath) +} + +export function readAppConfigFromPaths(configYamlPath: string, legacyJsonPath: string): AppConfig | null { + if (existsSync(configYamlPath)) { + const content = readFileSync(configYamlPath, "utf-8") + return parseYaml(content) as AppConfig + } + + if (existsSync(legacyJsonPath)) { + const content = readFileSync(legacyJsonPath, "utf-8") + return JSON.parse(content) as AppConfig + } + + return null +} + +export function readListeningModeFromConfig(configPath = process.env.CLI_CONFIG): ListeningMode { + try { + const parsed = readAppConfig(configPath) + const mode = parsed?.server?.listeningMode ?? 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 function resolveConfiguredPorts(configPath = process.env.CLI_CONFIG): [httpsPort?: number, httpPort?: number] { + try { + const parsed = readAppConfig(configPath) + return resolveConfiguredPortsFromConfig(parsed) + } catch (error) { + console.warn("[cli] failed to read configured ports from config", error) + return [] + } +} + +export function resolveConfiguredPortsFromConfig(config: AppConfig | null | undefined): [httpsPort?: number, httpPort?: number] { + const httpsPort = config?.server?.httpsPort ?? config?.preferences?.httpsPort + const httpPort = config?.server?.httpPort ?? config?.preferences?.httpPort + return [httpsPort, httpPort] +} + +export function applyConfiguredPorts( + args: string[], + options: { + httpsPortEnv?: string | null + httpPortEnv?: string | null + configuredHttpsPort?: number + configuredHttpPort?: number + }, +): void { + const httpsEnvPresent = Boolean(options.httpsPortEnv?.trim()) + const httpEnvPresent = Boolean(options.httpPortEnv?.trim()) + + if (!httpsEnvPresent && options.configuredHttpsPort !== undefined) { + args.push("--https-port", String(options.configuredHttpsPort)) + } + + if (!httpEnvPresent && options.configuredHttpPort !== undefined) { + args.push("--http-port", String(options.configuredHttpPort)) + } +} diff --git a/packages/electron-app/electron/main/process-manager.ts b/packages/electron-app/electron/main/process-manager.ts index 9cb4a041..38fd8500 100644 --- a/packages/electron-app/electron/main/process-manager.ts +++ b/packages/electron-app/electron/main/process-manager.ts @@ -2,12 +2,11 @@ import { spawn, spawnSync, type ChildProcess } from "child_process" import { app, utilityProcess, type UtilityProcess } from "electron" import { createRequire } from "module" import { EventEmitter } from "events" -import { existsSync, readFileSync } from "fs" -import os from "os" +import { existsSync } from "fs" import path from "path" import { fileURLToPath } from "url" -import { parse as parseYaml } from "yaml" import { buildUserShellCommand, getUserShellEnv, supportsUserShell } from "./user-shell" +import { applyConfiguredPorts, readListeningModeFromConfig, resolveConfiguredPorts, type ListeningMode } from "./cli-config" const nodeRequire = createRequire(import.meta.url) const mainFilename = fileURLToPath(import.meta.url) @@ -17,7 +16,6 @@ const BOOTSTRAP_TOKEN_PREFIX = "CODENOMAD_BOOTSTRAP_TOKEN:" const SESSION_COOKIE_NAME_PREFIX = "codenomad_session" type CliState = "starting" | "ready" | "error" | "stopped" -type ListeningMode = "local" | "all" export interface CliStatus { state: CliState @@ -45,75 +43,10 @@ interface CliEntryResolution { type ManagedChild = ChildProcess | UtilityProcess type ChildLaunchMode = "spawn" | "utility" -const DEFAULT_CONFIG_PATH = "~/.config/codenomad/config.json" - -function isYamlPath(filePath: string): boolean { - const lower = filePath.toLowerCase() - return lower.endsWith(".yaml") || lower.endsWith(".yml") -} - -function isJsonPath(filePath: string): boolean { - return filePath.toLowerCase().endsWith(".json") -} - -function resolveConfigPaths(raw?: string): { configYamlPath: string; legacyJsonPath: string } { - const target = raw && raw.trim().length > 0 ? raw.trim() : DEFAULT_CONFIG_PATH - const resolved = resolveConfigPath(target) - - if (isYamlPath(resolved)) { - const baseDir = path.dirname(resolved) - return { configYamlPath: resolved, legacyJsonPath: path.join(baseDir, "config.json") } - } - - if (isJsonPath(resolved)) { - const baseDir = path.dirname(resolved) - return { configYamlPath: path.join(baseDir, "config.yaml"), legacyJsonPath: resolved } - } - - // Treat as directory. - return { - configYamlPath: path.join(resolved, "config.yaml"), - legacyJsonPath: path.join(resolved, "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 { configYamlPath, legacyJsonPath } = resolveConfigPaths(process.env.CLI_CONFIG) - - let parsed: any = null - if (existsSync(configYamlPath)) { - const content = readFileSync(configYamlPath, "utf-8") - parsed = parseYaml(content) - } else if (existsSync(legacyJsonPath)) { - const content = readFileSync(legacyJsonPath, "utf-8") - parsed = JSON.parse(content) - } else { - return "local" - } - - const mode = parsed?.server?.listeningMode ?? 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 @@ -550,6 +483,14 @@ export class CliProcessManager extends EventEmitter { } else { // Prod desktop: always keep loopback HTTP enabled. args.push("--https", "true", "--http", "true") + + const [configuredHttpsPort, configuredHttpPort] = resolveConfiguredPorts() + applyConfiguredPorts(args, { + httpsPortEnv: process.env.CLI_HTTPS_PORT, + httpPortEnv: process.env.CLI_HTTP_PORT, + configuredHttpsPort, + configuredHttpPort, + }) } if (options.dev) { From 0a17f220be09cda0ffe83d251131fbb0100e1afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Thu, 23 Apr 2026 15:06:26 +0200 Subject: [PATCH 4/4] feat(settings): add desktop server port controls Let users persist fixed HTTP and HTTPS launcher ports from the remote access settings, with validation and restart confirmation, while keeping blank values mapped to automatic port selection. --- packages/server/src/config/schema.ts | 2 + packages/server/src/settings/migrate.ts | 10 ++ packages/server/src/settings/service.ts | 21 +++ .../remote-access-settings-section.tsx | 166 +++++++++++++++++- .../src/lib/i18n/messages/en/remoteAccess.ts | 16 ++ .../src/lib/i18n/messages/es/remoteAccess.ts | 16 ++ .../src/lib/i18n/messages/fr/remoteAccess.ts | 16 ++ .../src/lib/i18n/messages/he/remoteAccess.ts | 16 ++ .../src/lib/i18n/messages/ja/remoteAccess.ts | 16 ++ .../src/lib/i18n/messages/ru/remoteAccess.ts | 16 ++ .../lib/i18n/messages/zh-Hans/remoteAccess.ts | 16 ++ packages/ui/src/stores/preferences.tsx | 28 ++- 12 files changed, 335 insertions(+), 4 deletions(-) diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index b26062ef..e1348366 100644 --- a/packages/server/src/config/schema.ts +++ b/packages/server/src/config/schema.ts @@ -26,6 +26,8 @@ const PreferencesSchema = z showUsageMetrics: z.boolean().default(true), autoCleanupBlankSessions: z.boolean().default(true), listeningMode: z.enum(["local", "all"]).default("local"), + httpPort: z.number().int().min(1).max(65535).optional(), + httpsPort: z.number().int().min(1).max(65535).optional(), logLevel: z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).default("DEBUG"), // OS notifications diff --git a/packages/server/src/settings/migrate.ts b/packages/server/src/settings/migrate.ts index d734ea3e..f830a19f 100644 --- a/packages/server/src/settings/migrate.ts +++ b/packages/server/src/settings/migrate.ts @@ -107,6 +107,14 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co if (typeof listeningMode === "string") { serverConfig.listeningMode = listeningMode } + const httpPort = preferences.httpPort + if (typeof httpPort === "number") { + serverConfig.httpPort = httpPort + } + const httpsPort = preferences.httpsPort + if (typeof httpsPort === "number") { + serverConfig.httpsPort = httpsPort + } const logLevel = preferences.logLevel if (typeof logLevel === "string") { serverConfig.logLevel = logLevel @@ -139,6 +147,8 @@ function mapLegacyToOwnerDocs(legacyConfig: unknown, legacyState: unknown): { co const moved = new Set([ "environmentVariables", "listeningMode", + "httpPort", + "httpsPort", "logLevel", "lastUsedBinary", "modelRecents", diff --git a/packages/server/src/settings/service.ts b/packages/server/src/settings/service.ts index f4f0409c..46a8810e 100644 --- a/packages/server/src/settings/service.ts +++ b/packages/server/src/settings/service.ts @@ -14,6 +14,19 @@ const CanonicalLogLevelSchema = z.preprocess( z.enum(["DEBUG", "INFO", "WARN", "ERROR"]), ) +const CanonicalPortSchema = z.preprocess( + (value) => { + if (typeof value === "string") { + const trimmed = value.trim() + if (!trimmed) return undefined + const parsed = Number(trimmed) + return Number.isFinite(parsed) ? parsed : value + } + return value + }, + z.number().int().min(1).max(65535), +) + function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) } @@ -33,6 +46,14 @@ function normalizeServerConfigOwner(value: SettingsDoc): SettingsDoc { } const next: SettingsDoc = { ...value } + for (const key of ["httpPort", "httpsPort"] as const) { + const parsedPort = CanonicalPortSchema.safeParse(next[key]) + if (parsedPort.success) { + next[key] = parsedPort.data + } else if (next[key] !== undefined) { + delete next[key] + } + } const parsedLogLevel = CanonicalLogLevelSchema.safeParse(next.logLevel) if (parsedLogLevel.success) { next.logLevel = parsedLogLevel.data diff --git a/packages/ui/src/components/settings/remote-access-settings-section.tsx b/packages/ui/src/components/settings/remote-access-settings-section.tsx index 06a81aee..6893ccd2 100644 --- a/packages/ui/src/components/settings/remote-access-settings-section.tsx +++ b/packages/ui/src/components/settings/remote-access-settings-section.tsx @@ -1,11 +1,11 @@ import { Switch } from "@kobalte/core/switch" -import { For, Show, createMemo, createSignal, type Component, onMount } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, type Component, onMount } from "solid-js" import { toDataURL } from "qrcode" import { ChevronDown, 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 { serverSettings, setListeningMode } from "../../stores/preferences" +import { serverSettings, setListeningMode, setServerPorts } from "../../stores/preferences" import { showConfirmDialog } from "../../stores/alerts" import { getLogger } from "../../lib/logger" import { useI18n } from "../../lib/i18n" @@ -13,6 +13,25 @@ import { splitRemoteAddresses, type RemoteAddressGroups } from "../../lib/remote const log = getLogger("actions") +function formatPortValue(port?: number): string { + return port === undefined ? "" : String(port) +} + +function parsePortInput(input: string, invalidMessage: string, rangeMessage: string): { value: number | null; error: string | null } { + const trimmed = input.trim() + if (!trimmed) { + return { value: null, error: null } + } + if (!/^\d+$/.test(trimmed)) { + return { value: null, error: invalidMessage } + } + const value = Number(trimmed) + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return { value: null, error: rangeMessage } + } + return { value, error: null } +} + export const RemoteAccessSettingsSection: Component = () => { const { t } = useI18n() const [meta, setMeta] = createSignal(null) @@ -32,6 +51,13 @@ export const RemoteAccessSettingsSection: Component = () => { const [passwordError, setPasswordError] = createSignal(null) const [savingPassword, setSavingPassword] = createSignal(false) const [showAllAddresses, setShowAllAddresses] = createSignal(false) + const [httpsPortInput, setHttpsPortInput] = createSignal("") + const [httpPortInput, setHttpPortInput] = createSignal("") + const [applyingPorts, setApplyingPorts] = createSignal(false) + const [portError, setPortError] = createSignal(null) + + let lastSyncedHttpsPort: number | undefined + let lastSyncedHttpPort: number | undefined const addresses = createMemo(() => meta()?.addresses ?? []) const currentMode = createMemo(() => meta()?.listeningMode ?? serverSettings().listeningMode) @@ -41,6 +67,41 @@ export const RemoteAccessSettingsSection: Component = () => { if (!allowExternalConnections()) return { recommended: null, hidden: [] } return splitRemoteAddresses(list) }) + const parsedHttpsPort = createMemo(() => + parsePortInput( + httpsPortInput(), + t("remoteAccess.ports.validation.invalid"), + t("remoteAccess.ports.validation.range"), + ), + ) + const parsedHttpPort = createMemo(() => + parsePortInput( + httpPortInput(), + t("remoteAccess.ports.validation.invalid"), + t("remoteAccess.ports.validation.range"), + ), + ) + const portValidationMessage = createMemo(() => parsedHttpsPort().error ?? parsedHttpPort().error) + const portsChanged = createMemo(() => { + if (portValidationMessage()) return false + return ( + parsedHttpsPort().value !== (serverSettings().httpsPort ?? null) || + parsedHttpPort().value !== (serverSettings().httpPort ?? null) + ) + }) + + createEffect(() => { + const nextHttpsPort = serverSettings().httpsPort + const nextHttpPort = serverSettings().httpPort + if (nextHttpsPort !== lastSyncedHttpsPort) { + setHttpsPortInput(formatPortValue(nextHttpsPort)) + lastSyncedHttpsPort = nextHttpsPort + } + if (nextHttpPort !== lastSyncedHttpPort) { + setHttpPortInput(formatPortValue(nextHttpPort)) + lastSyncedHttpPort = nextHttpPort + } + }) const refreshMeta = async () => { setLoading(true) @@ -153,6 +214,46 @@ export const RemoteAccessSettingsSection: Component = () => { } } + const handleSavePorts = async () => { + setPortError(null) + const validationMessage = portValidationMessage() + if (validationMessage) { + setPortError(validationMessage) + return + } + if (!portsChanged()) { + return + } + + const confirmed = await showConfirmDialog(t("remoteAccess.ports.restartConfirm.message"), { + title: t("remoteAccess.ports.restartConfirm.title"), + variant: "warning", + confirmLabel: t("remoteAccess.ports.restartConfirm.confirmLabel"), + cancelLabel: t("remoteAccess.ports.restartConfirm.cancelLabel"), + dismissible: false, + }) + + if (!confirmed) return + + setApplyingPorts(true) + try { + await setServerPorts({ + httpsPort: parsedHttpsPort().value, + httpPort: parsedHttpPort().value, + }) + const restarted = await restartCli() + if (!restarted) { + setPortError(t("remoteAccess.restart.errorManual")) + } + } catch (err) { + setPortError(err instanceof Error ? err.message : String(err)) + } finally { + setApplyingPorts(false) + } + + void refreshMeta() + } + return (
@@ -204,6 +305,67 @@ export const RemoteAccessSettingsSection: Component = () => {

{t("remoteAccess.toggle.note")}

+
+
+
+ +
+

{t("remoteAccess.sections.ports.label")}

+

{t("remoteAccess.sections.ports.help")}

+
+
+ {t("settings.scope.server")} +
+ +
+
+
+ + setHttpsPortInput(event.currentTarget.value)} + placeholder={t("remoteAccess.ports.placeholder.auto")} + aria-invalid={Boolean(parsedHttpsPort().error)} + /> +
+ +
+ + setHttpPortInput(event.currentTarget.value)} + placeholder={t("remoteAccess.ports.placeholder.auto")} + aria-invalid={Boolean(parsedHttpPort().error)} + /> +
+
+ +

{t("remoteAccess.ports.note")}

+

{t("remoteAccess.ports.conflictNote")}

+ + + {(message) =>
{message()}
} +
+ +
+ +
+
+
+
diff --git a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts index cad9f855..3c477a24 100644 --- a/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/en/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancel", "remoteAccess.restart.errorManual": "Unable to restart automatically. Please restart the app to apply the change.", + "remoteAccess.sections.ports.label": "Server ports", + "remoteAccess.sections.ports.help": "Set fixed desktop launcher ports for the local HTTP and HTTPS endpoints.", + "remoteAccess.ports.fields.https": "HTTPS port", + "remoteAccess.ports.fields.http": "HTTP port", + "remoteAccess.ports.placeholder.auto": "Automatic", + "remoteAccess.ports.note": "Leave a field blank to keep automatic port selection.", + "remoteAccess.ports.conflictNote": "If another process is already using the configured port, startup will fail until the port is freed or changed.", + "remoteAccess.ports.validation.invalid": "Ports must contain digits only.", + "remoteAccess.ports.validation.range": "Ports must be between 1 and 65535.", + "remoteAccess.ports.actions.apply": "Apply and restart", + "remoteAccess.ports.actions.applying": "Applying…", + "remoteAccess.ports.restartConfirm.message": "Restart to apply the configured ports? This will stop all running instances.", + "remoteAccess.ports.restartConfirm.title": "Apply server ports", + "remoteAccess.ports.restartConfirm.confirmLabel": "Restart now", + "remoteAccess.ports.restartConfirm.cancelLabel": "Cancel", + "remoteAccess.sections.serverPassword.label": "Server password", "remoteAccess.sections.serverPassword.help": "Remote handovers require a password. Set a memorable one to enable logins from other devices.", "remoteAccess.authStatus.unavailable": "Authentication status unavailable.", diff --git a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts index f372d60c..aed62f91 100644 --- a/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/es/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Cancelar", "remoteAccess.restart.errorManual": "No se pudo reiniciar automáticamente. Reinicia la app para aplicar el cambio.", + "remoteAccess.sections.ports.label": "Puertos del servidor", + "remoteAccess.sections.ports.help": "Define puertos fijos para los endpoints HTTP y HTTPS locales del lanzador de escritorio.", + "remoteAccess.ports.fields.https": "Puerto HTTPS", + "remoteAccess.ports.fields.http": "Puerto HTTP", + "remoteAccess.ports.placeholder.auto": "Automático", + "remoteAccess.ports.note": "Deja un campo vacío para mantener la selección automática del puerto.", + "remoteAccess.ports.conflictNote": "Si otro proceso ya está usando el puerto configurado, el inicio fallará hasta que el puerto se libere o se cambie.", + "remoteAccess.ports.validation.invalid": "Los puertos solo pueden contener dígitos.", + "remoteAccess.ports.validation.range": "Los puertos deben estar entre 1 y 65535.", + "remoteAccess.ports.actions.apply": "Aplicar y reiniciar", + "remoteAccess.ports.actions.applying": "Aplicando…", + "remoteAccess.ports.restartConfirm.message": "¿Reiniciar para aplicar los puertos configurados? Esto detendrá todas las instancias en ejecución.", + "remoteAccess.ports.restartConfirm.title": "Aplicar puertos del servidor", + "remoteAccess.ports.restartConfirm.confirmLabel": "Reiniciar ahora", + "remoteAccess.ports.restartConfirm.cancelLabel": "Cancelar", + "remoteAccess.sections.serverPassword.label": "Contraseña del servidor", "remoteAccess.sections.serverPassword.help": "Las transferencias remotas requieren una contraseña. Define una fácil de recordar para habilitar inicios de sesión desde otros dispositivos.", "remoteAccess.authStatus.unavailable": "Estado de autenticación no disponible.", diff --git a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts index 3b6c17ad..de419260 100644 --- a/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/fr/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Annuler", "remoteAccess.restart.errorManual": "Impossible de redémarrer automatiquement. Veuillez redémarrer l'application pour appliquer le changement.", + "remoteAccess.sections.ports.label": "Ports du serveur", + "remoteAccess.sections.ports.help": "Définissez des ports fixes pour les points d'accès HTTP et HTTPS locaux du lanceur desktop.", + "remoteAccess.ports.fields.https": "Port HTTPS", + "remoteAccess.ports.fields.http": "Port HTTP", + "remoteAccess.ports.placeholder.auto": "Automatique", + "remoteAccess.ports.note": "Laissez un champ vide pour conserver la sélection automatique du port.", + "remoteAccess.ports.conflictNote": "Si un autre processus utilise déjà le port configuré, le démarrage échouera tant que le port ne sera pas libéré ou changé.", + "remoteAccess.ports.validation.invalid": "Les ports ne peuvent contenir que des chiffres.", + "remoteAccess.ports.validation.range": "Les ports doivent être compris entre 1 et 65535.", + "remoteAccess.ports.actions.apply": "Appliquer et redémarrer", + "remoteAccess.ports.actions.applying": "Application…", + "remoteAccess.ports.restartConfirm.message": "Redémarrer pour appliquer les ports configurés ? Cela arrêtera toutes les instances en cours.", + "remoteAccess.ports.restartConfirm.title": "Appliquer les ports du serveur", + "remoteAccess.ports.restartConfirm.confirmLabel": "Redémarrer maintenant", + "remoteAccess.ports.restartConfirm.cancelLabel": "Annuler", + "remoteAccess.sections.serverPassword.label": "Mot de passe du serveur", "remoteAccess.sections.serverPassword.help": "Les passations à distance nécessitent un mot de passe. Définissez-en un facile à retenir pour autoriser la connexion depuis d'autres appareils.", "remoteAccess.authStatus.unavailable": "Statut d'authentification indisponible.", diff --git a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts index dc026c46..0e473609 100644 --- a/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/he/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "ביטול", "remoteAccess.restart.errorManual": "לא ניתן להפעיל מחדש אוטומטית. אנא הפעל מחדש את האפליקציה כדי להחיל את השינוי.", + "remoteAccess.sections.ports.label": "פורטי השרת", + "remoteAccess.sections.ports.help": "הגדר פורטים קבועים עבור נקודות הקצה המקומיות של HTTP ו-HTTPS במשגר הדסקטופ.", + "remoteAccess.ports.fields.https": "פורט HTTPS", + "remoteAccess.ports.fields.http": "פורט HTTP", + "remoteAccess.ports.placeholder.auto": "אוטומטי", + "remoteAccess.ports.note": "השארת שדה ריק תשמור על בחירת פורט אוטומטית.", + "remoteAccess.ports.conflictNote": "אם תהליך אחר כבר משתמש בפורט שהוגדר, ההפעלה תיכשל עד שהפורט ישוחרר או ישתנה.", + "remoteAccess.ports.validation.invalid": "פורטים יכולים להכיל ספרות בלבד.", + "remoteAccess.ports.validation.range": "הפורטים חייבים להיות בין 1 ל-65535.", + "remoteAccess.ports.actions.apply": "החל והפעל מחדש", + "remoteAccess.ports.actions.applying": "מחיל…", + "remoteAccess.ports.restartConfirm.message": "להפעיל מחדש כדי להחיל את הפורטים שהוגדרו? פעולה זו תעצור את כל המופעים הפעילים.", + "remoteAccess.ports.restartConfirm.title": "החל פורטי שרת", + "remoteAccess.ports.restartConfirm.confirmLabel": "הפעל מחדש עכשיו", + "remoteAccess.ports.restartConfirm.cancelLabel": "ביטול", + "remoteAccess.sections.serverPassword.label": "סיסמת שרת", "remoteAccess.sections.serverPassword.help": "גישה מרוחקת דורשת סיסמה. הגדר סיסמה קלה לזכירה כדי לאפשר כניסות ממכשירים אחרים.", "remoteAccess.authStatus.unavailable": "סטטוס האימות אינו זמין.", diff --git a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts index 996b481e..d73749d6 100644 --- a/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/ja/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "キャンセル", "remoteAccess.restart.errorManual": "自動で再起動できませんでした。変更を適用するにはアプリを再起動してください。", + "remoteAccess.sections.ports.label": "サーバーポート", + "remoteAccess.sections.ports.help": "デスクトップランチャーのローカル HTTP / HTTPS エンドポイントに固定ポートを設定します。", + "remoteAccess.ports.fields.https": "HTTPS ポート", + "remoteAccess.ports.fields.http": "HTTP ポート", + "remoteAccess.ports.placeholder.auto": "自動", + "remoteAccess.ports.note": "空欄のままにすると自動ポート選択を維持します。", + "remoteAccess.ports.conflictNote": "他のプロセスが設定済みポートを使っている場合、そのポートが解放または変更されるまで起動は失敗します。", + "remoteAccess.ports.validation.invalid": "ポートには数字のみ入力できます。", + "remoteAccess.ports.validation.range": "ポートは 1 から 65535 の範囲で指定してください。", + "remoteAccess.ports.actions.apply": "適用して再起動", + "remoteAccess.ports.actions.applying": "適用中…", + "remoteAccess.ports.restartConfirm.message": "設定したポートを適用するため再起動しますか?実行中のインスタンスはすべて停止します。", + "remoteAccess.ports.restartConfirm.title": "サーバーポートを適用", + "remoteAccess.ports.restartConfirm.confirmLabel": "今すぐ再起動", + "remoteAccess.ports.restartConfirm.cancelLabel": "キャンセル", + "remoteAccess.sections.serverPassword.label": "サーバーパスワード", "remoteAccess.sections.serverPassword.help": "リモート引き継ぎにはパスワードが必要です。覚えやすいものを設定して他のデバイスからのログインを有効にします。", "remoteAccess.authStatus.unavailable": "認証状態を取得できません。", diff --git a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts index 26f4999e..d1d4fe2a 100644 --- a/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/ru/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "Отмена", "remoteAccess.restart.errorManual": "Не удалось перезапустить автоматически. Перезапустите приложение, чтобы применить изменение.", + "remoteAccess.sections.ports.label": "Порты сервера", + "remoteAccess.sections.ports.help": "Задайте фиксированные порты для локальных HTTP и HTTPS endpoints настольного лаунчера.", + "remoteAccess.ports.fields.https": "HTTPS порт", + "remoteAccess.ports.fields.http": "HTTP порт", + "remoteAccess.ports.placeholder.auto": "Автоматически", + "remoteAccess.ports.note": "Оставьте поле пустым, чтобы сохранить автоматический выбор порта.", + "remoteAccess.ports.conflictNote": "Если другой процесс уже использует указанный порт, запуск завершится ошибкой, пока порт не будет освобожден или изменен.", + "remoteAccess.ports.validation.invalid": "Порты должны содержать только цифры.", + "remoteAccess.ports.validation.range": "Порты должны быть в диапазоне от 1 до 65535.", + "remoteAccess.ports.actions.apply": "Применить и перезапустить", + "remoteAccess.ports.actions.applying": "Применение…", + "remoteAccess.ports.restartConfirm.message": "Перезапустить, чтобы применить заданные порты? Это остановит все запущенные экземпляры.", + "remoteAccess.ports.restartConfirm.title": "Применить порты сервера", + "remoteAccess.ports.restartConfirm.confirmLabel": "Перезапустить сейчас", + "remoteAccess.ports.restartConfirm.cancelLabel": "Отмена", + "remoteAccess.sections.serverPassword.label": "Пароль сервера", "remoteAccess.sections.serverPassword.help": "Для удаленной передачи управления требуется пароль. Установите запоминающийся пароль, чтобы разрешить вход с других устройств.", "remoteAccess.authStatus.unavailable": "Статус аутентификации недоступен.", diff --git a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts index 16a8b265..0705b7dd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts +++ b/packages/ui/src/lib/i18n/messages/zh-Hans/remoteAccess.ts @@ -20,6 +20,22 @@ export const remoteAccessMessages = { "remoteAccess.listeningMode.restartConfirm.cancelLabel": "取消", "remoteAccess.restart.errorManual": "无法自动重启。请手动重启应用以应用更改。", + "remoteAccess.sections.ports.label": "服务器端口", + "remoteAccess.sections.ports.help": "为桌面启动器的本地 HTTP 和 HTTPS 端点设置固定端口。", + "remoteAccess.ports.fields.https": "HTTPS 端口", + "remoteAccess.ports.fields.http": "HTTP 端口", + "remoteAccess.ports.placeholder.auto": "自动", + "remoteAccess.ports.note": "留空可保持自动端口选择。", + "remoteAccess.ports.conflictNote": "如果其他进程已占用所配置的端口,启动将失败,直到该端口被释放或更改。", + "remoteAccess.ports.validation.invalid": "端口只能包含数字。", + "remoteAccess.ports.validation.range": "端口必须在 1 到 65535 之间。", + "remoteAccess.ports.actions.apply": "应用并重启", + "remoteAccess.ports.actions.applying": "正在应用…", + "remoteAccess.ports.restartConfirm.message": "重启以应用配置的端口?这将停止所有正在运行的实例。", + "remoteAccess.ports.restartConfirm.title": "应用服务器端口", + "remoteAccess.ports.restartConfirm.confirmLabel": "立即重启", + "remoteAccess.ports.restartConfirm.cancelLabel": "取消", + "remoteAccess.sections.serverPassword.label": "服务器密码", "remoteAccess.sections.serverPassword.help": "远程接管需要密码。设置一个易记的密码,以允许其他设备登录。", "remoteAccess.authStatus.unavailable": "无法获取认证状态。", diff --git a/packages/ui/src/stores/preferences.tsx b/packages/ui/src/stores/preferences.tsx index 39b76e5a..cbe58423 100644 --- a/packages/ui/src/stores/preferences.tsx +++ b/packages/ui/src/stores/preferences.tsx @@ -97,6 +97,8 @@ interface UiConfigBucket { interface ServerConfigBucket { listeningMode?: ListeningMode + httpPort?: number + httpsPort?: number logLevel?: ServerLogLevel environmentVariables?: Record opencodeBinary?: string @@ -198,6 +200,12 @@ function normalizeRecord(value: unknown): Record { return out } +function normalizePort(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isInteger(value)) return undefined + if (value < 1 || value > 65535) return undefined + return value +} + function normalizeSpeechSettings(input?: Partial | null): SpeechSettings { const sanitized = input ?? {} return { @@ -303,9 +311,12 @@ function normalizeUiState(input?: UiStateBucket | null): NormalizedUiState { function normalizeServerConfig( input?: ServerConfigBucket | null, -): Required> & { speech: SpeechSettings } { +): Required> & + Pick & { speech: SpeechSettings } { const source = input ?? {} const listeningMode = source.listeningMode === "all" ? "all" : "local" + const httpPort = normalizePort(source.httpPort) + const httpsPort = normalizePort(source.httpsPort) const logLevel = source.logLevel === "INFO" || source.logLevel === "WARN" || source.logLevel === "ERROR" || source.logLevel === "DEBUG" ? source.logLevel @@ -313,7 +324,7 @@ function normalizeServerConfig( const opencodeBinary = typeof source.opencodeBinary === "string" && source.opencodeBinary.trim() ? source.opencodeBinary : "opencode" const environmentVariables = normalizeRecord(source.environmentVariables) const speech = normalizeSpeechSettings(source.speech) - return { listeningMode, logLevel, opencodeBinary, environmentVariables, speech } + return { listeningMode, httpPort, httpsPort, logLevel, opencodeBinary, environmentVariables, speech } } function getModelKey(model: { providerId: string; modelId: string }): string { @@ -456,6 +467,16 @@ function setThemePreference(preference: ThemePreference): void { await patchConfigOwner("server", { listeningMode: mode }) } +async function setServerPorts(ports: { httpPort: number | null; httpsPort: number | null }): Promise { + const currentHttpPort = serverSettings().httpPort ?? null + const currentHttpsPort = serverSettings().httpsPort ?? null + if (currentHttpPort === ports.httpPort && currentHttpsPort === ports.httpsPort) return + await patchConfigOwner("server", { + httpPort: ports.httpPort, + httpsPort: ports.httpsPort, + }) +} + function updateEnvironmentVariables(envVars: Record): void { void patchConfigOwner("server", { environmentVariables: envVars }).catch((error) => log.error("Failed to update environment variables", error), @@ -710,6 +731,7 @@ interface ConfigContextValue { // server-owned stable config serverSettings: typeof serverSettings setListeningMode: typeof setListeningMode + setServerPorts: typeof setServerPorts updateEnvironmentVariables: typeof updateEnvironmentVariables addEnvironmentVariable: typeof addEnvironmentVariable removeEnvironmentVariable: typeof removeEnvironmentVariable @@ -765,6 +787,7 @@ const configContextValue: ConfigContextValue = { setThemePreference, serverSettings, setListeningMode, + setServerPorts, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable, @@ -853,6 +876,7 @@ export { setThemePreference, updatePreferences, setListeningMode, + setServerPorts, updateEnvironmentVariables, addEnvironmentVariable, removeEnvironmentVariable,