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 000000000..c69b6ee2b --- /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 000000000..9cf83f202 --- /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 9cb4a041b..38fd85007 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) { diff --git a/packages/server/src/config/schema.ts b/packages/server/src/config/schema.ts index b26062ef2..e13483667 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 d734ea3ef..f830a19f3 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 f4f0409c2..46a8810ee 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/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 358523e32..f04ef59c9 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,101 @@ 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(); + 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) { - 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(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); + }; + + resolve_configured_ports_from_config(&config) +} + +fn resolve_configured_ports_from_config(config: &AppConfig) -> (Option, Option) { + 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 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" { @@ -1115,6 +1165,21 @@ 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(); + apply_configured_ports( + &mut args, + https_port_env.as_deref(), + http_port_env.as_deref(), + configured_https_port, + configured_http_port, + ); } args } @@ -1316,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); + } +} 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 06a81aee9..6893ccd28 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 cad9f855d..3c477a24e 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 f372d60c4..aed62f91c 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 3b6c17add..de4192606 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 dc026c46d..0e473609e 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 996b481e1..d73749d63 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 26f4999e9..d1d4fe2a8 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 16a8b2656..0705b7ddd 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 39b76e5af..cbe58423f 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,