diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index 42cbbc17..2395c1a7 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -6,14 +6,16 @@ import { killSidecar, mergeProxyEnv, POWERSHELL_PATH_OVERRIDE_ENV, + preferredServerPorts, proxyUrlFromElectronProxyRules, pushStartupLog, - reserveLocalPort, + reserveServerPort, SERVER_BIND_HOST, SERVER_CONTROL_HOST, spawnSidecar, waitForServer, windowsPowerShellOverride, + writeLastServerPort, type SidecarChild, } from './sidecarManager' import { readDesktopTerminalConfig, resolveDesktopTerminalShell } from './terminal' @@ -76,7 +78,9 @@ export class ElectronServerRuntime { } private async startServerOnce(): Promise { - const port = await reserveLocalPort(SERVER_BIND_HOST) + // Prefer the configured fixed port, then the previous run's port, so + // phone bookmarks / QR codes / reverse proxies survive restarts (#767). + const port = await reserveServerPort(SERVER_BIND_HOST, preferredServerPorts()) const url = `http://${SERVER_CONTROL_HOST}:${port}` const logs: string[] = [] const env = await this.resolveSidecarBaseEnv() @@ -92,6 +96,7 @@ export class ElectronServerRuntime { const child = spawnSidecar(plan) this.captureLogs(child, 'claude-server', logs) await waitForServer(SERVER_CONTROL_HOST, port) + writeLastServerPort(port) this.server = { url, child } this.startupError = null await this.startAdaptersSidecars(url) diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index fbb12d3f..a683e8a3 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import net from 'node:net' import path from 'node:path' -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { buildSidecarEnv, @@ -9,11 +10,18 @@ import { httpToWebSocketUrl, killSidecar, mergeProxyEnv, + parseH5FixedPort, + preferredServerPorts, proxyUrlFromElectronProxyRules, pushStartupLog, + readH5FixedPort, + readLastServerPort, + reserveLocalPort, + reserveServerPort, resolveHostTriple, spawnSidecar, windowsPowerShellOverride, + writeLastServerPort, type SidecarChild, } from './sidecarManager' @@ -191,4 +199,67 @@ describe('Electron sidecar manager', () => { expect(windowsPowerShellOverride('pwsh', 'darwin')).toBeNull() expect(windowsPowerShellOverride('powershell.exe', 'linux')).toBeNull() }) + + it('parses only in-range integer h5Access.fixedPort values', () => { + expect(parseH5FixedPort('{"h5Access":{"fixedPort":28670}}')).toBe(28670) + expect(parseH5FixedPort('{"h5Access":{"fixedPort":80}}')).toBeNull() + expect(parseH5FixedPort('{"h5Access":{"fixedPort":70000}}')).toBeNull() + expect(parseH5FixedPort('{"h5Access":{"fixedPort":"3456"}}')).toBeNull() + expect(parseH5FixedPort('{"h5Access":{"fixedPort":null}}')).toBeNull() + expect(parseH5FixedPort('{"h5Access":{}}')).toBeNull() + expect(parseH5FixedPort('{}')).toBeNull() + expect(parseH5FixedPort('not json')).toBeNull() + }) + + it('persists and prioritizes preferred server ports from the config dir', () => { + const configDir = mkdtempSync(path.join(tmpdir(), 'cchh-server-state-')) + const env = { CLAUDE_CONFIG_DIR: configDir } as NodeJS.ProcessEnv + try { + // Nothing stored yet: no preferred ports. + expect(preferredServerPorts(env)).toEqual([]) + + // Sticky port from the previous run. + writeLastServerPort(50123, env) + expect(readLastServerPort(env)).toBe(50123) + expect(preferredServerPorts(env)).toEqual([50123]) + + // An explicit fixed port wins over the sticky port. + mkdirSync(path.join(configDir, 'cc-haha'), { recursive: true }) + writeFileSync( + path.join(configDir, 'cc-haha', 'settings.json'), + JSON.stringify({ h5Access: { fixedPort: 28670 } }), + 'utf-8', + ) + expect(readH5FixedPort(env)).toBe(28670) + expect(preferredServerPorts(env)).toEqual([28670, 50123]) + + // Identical fixed and sticky ports are not duplicated. + writeLastServerPort(28670, env) + expect(preferredServerPorts(env)).toEqual([28670]) + } finally { + rmSync(configDir, { recursive: true, force: true }) + } + }) + + it('reserves a free preferred port and falls back when it is taken', async () => { + // Reserve a random free port, verify preference picks it while free. + const freePort = await reserveLocalPort('127.0.0.1') + await expect(reserveServerPort('127.0.0.1', [freePort])).resolves.toBe(freePort) + + // Occupy it and verify the fallback hands out a different port. + const blocker = net.createServer() + await new Promise((resolve, reject) => { + blocker.once('error', reject) + blocker.listen(freePort, '127.0.0.1', () => resolve()) + }) + try { + const fallback = await reserveServerPort('127.0.0.1', [freePort]) + expect(fallback).not.toBe(freePort) + } finally { + await new Promise(resolve => blocker.close(() => resolve())) + } + + // Invalid entries are skipped without throwing. + await expect(reserveServerPort('127.0.0.1', [0, -1, 1.5, 70000])).resolves.toBeGreaterThan(0) + }) }) diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 5f90c1da..f56da799 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -1,12 +1,19 @@ import { spawn, spawnSync, type ChildProcessByStdio } from 'node:child_process' -import { mkdirSync, existsSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, writeFileSync } from 'node:fs' import type { Readable } from 'node:stream' import net from 'node:net' +import os from 'node:os' import path from 'node:path' export const SERVER_BIND_HOST = '0.0.0.0' export const SERVER_CONTROL_HOST = '127.0.0.1' export const SERVER_STARTUP_LOG_LIMIT = 80 +// Shared with the Tauri shell (src-tauri/src/lib.rs) so both desktop builds +// reuse the same sticky port across restarts (issue #767). +export const SERVER_STATE_FILE = 'desktop-server-state.json' +// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT). +const MIN_FIXED_PORT = 1024 +const MAX_FIXED_PORT = 65535 export type SidecarChild = ChildProcessByStdio @@ -66,6 +73,95 @@ export async function reserveLocalPort(bindHost = SERVER_BIND_HOST): Promise { + return new Promise(resolve => { + const server = net.createServer() + server.once('error', () => resolve(false)) + server.listen(port, bindHost, () => { + server.close(() => resolve(true)) + }) + }) +} + +/** + * Try the preferred ports in order (h5Access.fixedPort first, then the port + * used by the previous run) and fall back to an OS-assigned random port when + * all of them are taken, so the app always starts. + */ +export async function reserveServerPort( + bindHost: string, + preferred: number[], +): Promise { + for (const port of preferred) { + if (!Number.isInteger(port) || port <= 0 || port > 65535) continue + if (await canBindPort(bindHost, port)) return port + console.error(`[desktop] preferred server port ${port} unavailable`) + } + return await reserveLocalPort(bindHost) +} + +export function claudeConfigDir(env: NodeJS.ProcessEnv = process.env): string { + return env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') +} + +/** Parse h5Access.fixedPort out of cc-haha/settings.json contents. */ +export function parseH5FixedPort(contents: string): number | null { + let value: unknown + try { + value = JSON.parse(contents) + } catch { + return null + } + if (!value || typeof value !== 'object') return null + const h5Access = (value as Record).h5Access + if (!h5Access || typeof h5Access !== 'object') return null + const port = (h5Access as Record).fixedPort + if (typeof port !== 'number' || !Number.isInteger(port)) return null + return port >= MIN_FIXED_PORT && port <= MAX_FIXED_PORT ? port : null +} + +export function readH5FixedPort(env: NodeJS.ProcessEnv = process.env): number | null { + try { + const settingsPath = path.join(claudeConfigDir(env), 'cc-haha', 'settings.json') + return parseH5FixedPort(readFileSync(settingsPath, 'utf-8')) + } catch { + return null + } +} + +export function readLastServerPort(env: NodeJS.ProcessEnv = process.env): number | null { + try { + const statePath = path.join(claudeConfigDir(env), SERVER_STATE_FILE) + const state: unknown = JSON.parse(readFileSync(statePath, 'utf-8')) + if (!state || typeof state !== 'object') return null + const port = (state as Record).lastPort + if (typeof port !== 'number' || !Number.isInteger(port)) return null + return port > 0 && port <= 65535 ? port : null + } catch { + return null + } +} + +export function writeLastServerPort(port: number, env: NodeJS.ProcessEnv = process.env): void { + try { + const dir = claudeConfigDir(env) + mkdirSync(dir, { recursive: true }) + writeFileSync(path.join(dir, SERVER_STATE_FILE), `${JSON.stringify({ lastPort: port }, null, 2)}\n`, 'utf-8') + } catch (error) { + console.error('[desktop] failed to persist server state', error) + } +} + +/** Preferred ports for the next server start: explicit fixed port first, then the sticky last-used port. */ +export function preferredServerPorts(env: NodeJS.ProcessEnv = process.env): number[] { + const ports: number[] = [] + const fixedPort = readH5FixedPort(env) + if (fixedPort !== null) ports.push(fixedPort) + const lastPort = readLastServerPort(env) + if (lastPort !== null && !ports.includes(lastPort)) ports.push(lastPort) + return ports +} + export async function waitForServer(host: string, port: number, timeoutMs = 10_000): Promise { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a4b4ba94..424a771b 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -229,6 +229,7 @@ const TRAY_QUIT_ID: &str = "tray_quit"; const WINDOW_STATE_FILE: &str = "window-state.json"; const TERMINAL_CONFIG_FILE: &str = "terminal-config.json"; const APP_MODE_FILE: &str = "app-mode.json"; +const SERVER_STATE_FILE: &str = "desktop-server-state.json"; const MIN_WINDOW_WIDTH: u32 = 960; const MIN_WINDOW_HEIGHT: u32 = 640; const MIN_VISIBLE_PIXELS: i64 = 64; @@ -406,6 +407,15 @@ struct StoredWindowState { maximized: bool, } +/// 上一次 server sidecar 实际监听的端口。重启时优先复用同一端口, +/// 这样手机书签 / 二维码 / 反向代理的 upstream 不会因为重启而失效 +/// (issue #767)。端口被占用时才回退到随机端口。 +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +struct StoredServerState { + #[serde(rename = "lastPort")] + last_port: u16, +} + /// 与 ServerState 平级的 adapter 子进程状态。 /// /// adapter sidecar(claude-sidecar adapters --telegram 等)的生命周期 @@ -741,6 +751,69 @@ fn resolve_portable_state_path() -> Option { .map(|dir| PathBuf::from(&dir).join(WINDOW_STATE_FILE)) } +fn server_state_path() -> Option { + // Lives next to cc-haha/settings.json (CLAUDE_CONFIG_DIR or ~/.claude) so + // the Tauri and Electron shells share the same sticky port across builds. + claude_config_dir().map(|dir| dir.join(SERVER_STATE_FILE)) +} + +fn read_stored_server_state() -> Option { + let path = server_state_path()?; + let data = match fs::read_to_string(&path) { + Ok(data) => data, + Err(err) if err.kind() == ErrorKind::NotFound => return None, + Err(err) => { + eprintln!( + "[desktop] failed to read server state {}: {err}", + path.display() + ); + return None; + } + }; + + match serde_json::from_str::(&data) { + Ok(state) => Some(state), + Err(err) => { + eprintln!( + "[desktop] failed to parse server state {}: {err}", + path.display() + ); + None + } + } +} + +fn write_stored_server_state(state: &StoredServerState) { + let Some(path) = server_state_path() else { + return; + }; + + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + eprintln!( + "[desktop] failed to create server state directory {}: {err}", + parent.display() + ); + return; + } + } + + let data = match serde_json::to_string_pretty(state) { + Ok(data) => data, + Err(err) => { + eprintln!("[desktop] failed to serialize server state: {err}"); + return; + } + }; + + if let Err(err) = fs::write(&path, data) { + eprintln!( + "[desktop] failed to write server state {}: {err}", + path.display() + ); + } +} + fn read_stored_window_state(app: &AppHandle) -> Option { let path = window_state_path(app)?; let data = match fs::read_to_string(&path) { @@ -1370,6 +1443,24 @@ fn desktop_terminal_settings_path() -> Option { claude_config_dir().map(|path| path.join("settings.json")) } +/// 解析 cc-haha/settings.json 里的 h5Access.fixedPort。范围必须与 +/// 服务端 h5AccessService 的 MIN/MAX_FIXED_PORT 一致(1024..=65535)。 +fn parse_h5_fixed_port(contents: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(contents).ok()?; + let port = value.get("h5Access")?.get("fixedPort")?.as_u64()?; + if (1024..=65535).contains(&port) { + u16::try_from(port).ok() + } else { + None + } +} + +fn read_h5_fixed_port() -> Option { + let path = claude_config_dir()?.join("cc-haha").join("settings.json"); + let contents = fs::read_to_string(path).ok()?; + parse_h5_fixed_port(&contents) +} + fn read_desktop_terminal_config() -> Option { let path = desktop_terminal_settings_path()?; let contents = fs::read_to_string(path).ok()?; @@ -1526,6 +1617,23 @@ fn reserve_local_port(bind_host: &str) -> Result { Ok(port) } +/// 按优先级尝试给定端口(h5Access.fixedPort > 上次使用的端口), +/// 全部被占用时回退到 OS 随机分配。保证 app 总能启动。 +fn reserve_local_port_with_preference(bind_host: &str, preferred: &[u16]) -> Result { + for &port in preferred { + match TcpListener::bind(format!("{bind_host}:{port}")) { + Ok(listener) => { + drop(listener); + return Ok(port); + } + Err(err) => { + eprintln!("[desktop] preferred server port {port} unavailable: {err}"); + } + } + } + reserve_local_port(bind_host) +} + fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> { let addr: SocketAddr = format!("{url_host}:{port}") .parse() @@ -1619,7 +1727,16 @@ fn resolve_h5_dist_dir(app: &AppHandle, app_root: &Path) -> PathBuf { fn start_server_sidecar(app: &AppHandle) -> Result { let bind_host = SERVER_BIND_HOST; let control_host = SERVER_CONTROL_HOST; - let port = reserve_local_port(bind_host)?; + let mut preferred_ports: Vec = Vec::new(); + if let Some(port) = read_h5_fixed_port() { + preferred_ports.push(port); + } + if let Some(state) = read_stored_server_state() { + if !preferred_ports.contains(&state.last_port) { + preferred_ports.push(state.last_port); + } + } + let port = reserve_local_port_with_preference(bind_host, &preferred_ports)?; let url = format!("http://{control_host}:{port}"); let app_root = resolve_app_root(app)?; let app_root_arg = app_root.to_string_lossy().to_string(); @@ -1707,6 +1824,8 @@ fn start_server_sidecar(app: &AppHandle) -> Result { return Err(format_server_startup_error(&err, &startup_logs)); } + write_stored_server_state(&StoredServerState { last_port: port }); + Ok(ServerRuntime { url, child }) } @@ -1968,11 +2087,13 @@ mod tests { use super::{ decode_terminal_output, default_utf8_locale, dir_has_portable_data, ensure_utf8_locale, has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path, - parse_env_block, resolve_agent_powershell_path_override, resolve_desktop_terminal_shell, + parse_env_block, parse_h5_fixed_port, reserve_local_port_with_preference, + resolve_agent_powershell_path_override, resolve_desktop_terminal_shell, resolve_terminal_cwd, run_notification_bridge, select_h5_dist_dir, DesktopTerminalConfig, - StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST, + StoredServerState, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, + SERVER_CONTROL_HOST, }; - use std::{collections::HashMap, fs}; + use std::{collections::HashMap, fs, net::TcpListener}; #[test] fn window_state_rejects_too_small_sizes() { @@ -2279,6 +2400,66 @@ mod tests { assert_eq!(SERVER_CONTROL_HOST, "127.0.0.1"); } + #[test] + fn h5_fixed_port_parses_only_valid_in_range_values() { + assert_eq!( + parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":28670}}"#), + Some(28670) + ); + // Out of range, wrong type, missing, or null all fall back to None. + assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":80}}"#), None); + assert_eq!( + parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":70000}}"#), + None + ); + assert_eq!( + parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":"3456"}}"#), + None + ); + assert_eq!( + parse_h5_fixed_port(r#"{"h5Access":{"fixedPort":null}}"#), + None + ); + assert_eq!(parse_h5_fixed_port(r#"{"h5Access":{}}"#), None); + assert_eq!(parse_h5_fixed_port("{}"), None); + assert_eq!(parse_h5_fixed_port("not json"), None); + } + + #[test] + fn preferred_port_is_used_when_free_and_skipped_when_taken() { + // Find a port that is currently free, then verify preference picks it. + let probe = TcpListener::bind("127.0.0.1:0").expect("probe bind"); + let free_port = probe.local_addr().expect("probe addr").port(); + drop(probe); + + let reserved = reserve_local_port_with_preference("127.0.0.1", &[free_port]) + .expect("reserve preferred"); + assert_eq!(reserved, free_port); + + // Occupy a port and verify preference falls back to a random one. + let occupied = TcpListener::bind("127.0.0.1:0").expect("occupy bind"); + let occupied_port = occupied.local_addr().expect("occupied addr").port(); + + let fallback = reserve_local_port_with_preference("127.0.0.1", &[occupied_port]) + .expect("reserve fallback"); + assert_ne!(fallback, occupied_port); + drop(occupied); + + // Empty preference list behaves like the plain random reservation. + assert!(reserve_local_port_with_preference("127.0.0.1", &[]).is_ok()); + } + + #[test] + fn stored_server_state_round_trips_camel_case_json() { + let state = StoredServerState { last_port: 28670 }; + let json = serde_json::to_string(&state).expect("serialize"); + assert_eq!(json, r#"{"lastPort":28670}"#); + assert_eq!( + serde_json::from_str::(&json).expect("parse"), + state + ); + } + #[test] fn h5_dist_dir_prefers_tauri_parent_resource_mapping() { let root = std::env::temp_dir().join(format!("cchh-h5-dist-test-{}", std::process::id())); diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 9414c42e..9f1c0cd8 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -221,9 +221,12 @@ describe('Settings > General tab', () => { }, h5Access: { enabled: false, + token: null, tokenPreview: null, allowedOrigins: [], publicBaseUrl: null, + fixedPort: null, + disconnectGraceSeconds: null, }, h5AccessDiagnostics: null, h5AccessError: null, @@ -305,18 +308,20 @@ describe('Settings > General tab', () => { h5Access: { ...current, enabled: true, + token: 'h5_default_generated_token', tokenPreview: 'h5_default_generated_token'.slice(0, 8), }, }) return 'h5_default_generated_token' }), disableH5Access: vi.fn().mockImplementation(async () => { + // Mirrors the server: disabling keeps the stored token so a later + // re-enable restores access for already-paired phones. const current = useSettingsStore.getState().h5Access useSettingsStore.setState({ h5Access: { ...current, enabled: false, - tokenPreview: null, }, }) }), @@ -326,6 +331,7 @@ describe('Settings > General tab', () => { h5Access: { ...current, enabled: true, + token: 'h5_default_regenerated_token', tokenPreview: 'h5_default_regenerated_token'.slice(0, 8), }, }) @@ -913,9 +919,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: false, + token: null, tokenPreview: null, allowedOrigins: [], publicBaseUrl: 'http://192.168.0.102:3456', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -940,9 +949,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: false, + token: null, tokenPreview: null, allowedOrigins: [], publicBaseUrl: 'http://192.168.0.102:3456', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -973,9 +985,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5oldtok', allowedOrigins: [], publicBaseUrl: 'http://192.168.0.102:3456', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -995,13 +1010,184 @@ describe('Settings > General tab', () => { expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument() }) + it('renders the QR code and token from persisted settings without any action (issue #767)', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:3456', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + + // No enable/regenerate click this session: everything comes from the + // persisted token, so a desktop restart no longer loses the QR code. + expect(await within(section).findByAltText('H5 access QR code')).toBeInTheDocument() + expect(within(section).getByText('http://192.168.0.102:3456/?serverUrl=http%3A%2F%2F192.168.0.102%3A3456&h5Token=h5_persisted_token')).toBeInTheDocument() + + fireEvent.click(within(section).getByRole('button', { name: 'Show token' })) + expect(within(section).getByText('h5_persisted_token')).toBeInTheDocument() + }) + + it('saves a fixed port together with the host', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Fixed port'), { + target: { value: '28670' }, + }) + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) + }) + + expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: 28670, + disconnectGraceSeconds: null, + }) + }) + + it('rejects an out-of-range fixed port before saving', () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Fixed port'), { + target: { value: '99' }, + }) + + expect(within(section).getByText('Port must be an integer between 1024 and 65535.')).toBeInTheDocument() + expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled() + expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled() + }) + + it('saves a custom disconnect grace period (issue #764)', async () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Disconnect grace (sec)'), { + target: { value: '600' }, + }) + + await act(async () => { + fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) + }) + + expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: 600, + }) + }) + + it('rejects an out-of-range disconnect grace period before saving', () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: null, + disconnectGraceSeconds: null, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + fireEvent.change(within(section).getByLabelText('Disconnect grace (sec)'), { + target: { value: '2' }, + }) + + expect(within(section).getByText('Must be an integer between 5 and 86400 seconds.')).toBeInTheDocument() + expect(within(section).getByRole('button', { name: 'Save H5 settings' })).toBeDisabled() + expect(useSettingsStore.getState().updateH5AccessSettings).not.toHaveBeenCalled() + }) + + it('shows a restart note while the saved fixed port is not active yet', () => { + useSettingsStore.setState({ + h5Access: { + enabled: true, + token: 'h5_persisted_token', + tokenPreview: 'h5_pers...oken', + allowedOrigins: [], + publicBaseUrl: 'http://192.168.0.102:54064', + fixedPort: 28670, + disconnectGraceSeconds: null, + }, + h5AccessDiagnostics: { + storedHostStaleness: 'ok', + storedPublicBaseUrl: 'http://192.168.0.102:54064', + effectivePublicBaseUrl: 'http://192.168.0.102:54064', + suggestedHost: null, + localInterfaceHosts: ['192.168.0.102'], + activePort: 54064, + }, + }) + render() + + fireEvent.click(screen.getByText('H5 Access')) + const section = screen.getByRole('region', { name: 'H5 Access' }) + + const note = within(section).getByTestId('h5-access-fixed-port-restart-note') + expect(note.textContent).toContain('28670') + expect(note.textContent).toContain('54064') + }) + it('shows the generated H5 token as a fallback when requested', async () => { useSettingsStore.setState({ h5Access: { enabled: false, + token: null, tokenPreview: null, allowedOrigins: [], publicBaseUrl: 'http://192.168.0.102:3456', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -1023,9 +1209,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5url123', allowedOrigins: ['https://phone.example'], publicBaseUrl: 'https://phone.example/app', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -1059,9 +1248,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5a1b2c3', allowedOrigins: [], publicBaseUrl: 'http://172.20.16.1:54064', + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -1080,6 +1272,8 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ publicBaseUrl: 'http://192.168.1.100:54064', + fixedPort: null, + disconnectGraceSeconds: null, }) }) @@ -1087,9 +1281,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: false, + token: null, tokenPreview: 'h5a1b2c3', allowedOrigins: ['https://old.example'], publicBaseUrl: null, + fixedPort: null, + disconnectGraceSeconds: null, }, }) render() @@ -1107,6 +1304,8 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ publicBaseUrl: 'https://phone.example/app', + fixedPort: null, + disconnectGraceSeconds: null, }) }) @@ -1114,9 +1313,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5a1b2c3', allowedOrigins: [], publicBaseUrl: 'http://192.168.1.207:55379', + fixedPort: null, + disconnectGraceSeconds: null, }, h5AccessDiagnostics: { storedHostStaleness: 'unreachable', @@ -1148,9 +1350,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5a1b2c3', allowedOrigins: [], publicBaseUrl: 'https://h5.mydomain.com', + fixedPort: null, + disconnectGraceSeconds: null, }, h5AccessDiagnostics: { storedHostStaleness: 'proxy', @@ -1172,9 +1377,12 @@ describe('Settings > General tab', () => { useSettingsStore.setState({ h5Access: { enabled: true, + token: null, tokenPreview: 'h5a1b2c3', allowedOrigins: [], publicBaseUrl: 'http://192.168.0.105:55379', + fixedPort: null, + disconnectGraceSeconds: null, }, h5AccessDiagnostics: { storedHostStaleness: 'ok', diff --git a/desktop/src/api/h5Access.ts b/desktop/src/api/h5Access.ts index 6e1a5422..024d1da2 100644 --- a/desktop/src/api/h5Access.ts +++ b/desktop/src/api/h5Access.ts @@ -33,6 +33,8 @@ export const h5AccessApi = { update(input: { allowedOrigins?: string[] publicBaseUrl?: string | null + fixedPort?: number | null + disconnectGraceSeconds?: number | null }) { return api.put('/api/h5-access', input) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 3f224c6a..34a820b6 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1025,11 +1025,20 @@ export const en = { 'settings.general.h5AccessUrlCopied': 'H5 URL copied.', 'settings.general.h5AccessLaunchUrlCopied': 'QR link copied.', 'settings.general.h5AccessHideToken': 'Hide token', - 'settings.general.h5AccessTokenNotAvailable': 'Regenerate to show a new token.', + 'settings.general.h5AccessTokenNotAvailable': 'Tokens from older versions cannot be recovered. Regenerate once and the token will be kept permanently and stay viewable.', 'settings.general.h5AccessPublicHost': 'Access host / IP', 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', 'settings.general.h5AccessCurrentPort': 'Current port', 'settings.general.h5AccessCurrentPortUnknown': 'Auto', + 'settings.general.h5AccessFixedPort': 'Fixed port', + 'settings.general.h5AccessFixedPortPlaceholder': 'Auto', + 'settings.general.h5AccessFixedPortInvalid': 'Port must be an integer between 1024 and 65535.', + 'settings.general.h5AccessFixedPortHint': 'Without a fixed port the previous port is reused automatically; pin one for reverse proxies and other setups that need a stable port. Port changes apply after restarting the app.', + 'settings.general.h5AccessFixedPortRestartNote': 'Fixed port {fixedPort} takes effect after restarting the app; currently still running on {activePort}.', + 'settings.general.h5AccessDisconnectGrace': 'Disconnect grace (sec)', + 'settings.general.h5AccessDisconnectGracePlaceholder': '30', + 'settings.general.h5AccessDisconnectGraceInvalid': 'Must be an integer between 5 and 86400 seconds.', + 'settings.general.h5AccessDisconnectGraceHint': 'When a phone locks its screen or backgrounds the app and disconnects, a running task is never interrupted — it finishes in the background and the result shows on reconnect. The CLI is only stopped after this idle period once the task is done and no client is attached (default 30s). Raise it for remote use on the go, e.g. 600.', 'settings.general.h5AccessPublicUrl': 'Public URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': 'Allowed origins', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 61ee9c35..ffa6906a 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1027,11 +1027,20 @@ export const jp: Record = { 'settings.general.h5AccessUrlCopied': 'H5 URL をコピーしました。', 'settings.general.h5AccessLaunchUrlCopied': 'QR リンクをコピーしました。', 'settings.general.h5AccessHideToken': 'トークンを非表示', - 'settings.general.h5AccessTokenNotAvailable': '新しいトークンを表示するには再生成してください。', + 'settings.general.h5AccessTokenNotAvailable': '旧バージョンのトークンは復元できません。一度再生成すると、トークンは永続化され、いつでも確認できます。', 'settings.general.h5AccessPublicHost': 'アクセスホスト / IP', 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', 'settings.general.h5AccessCurrentPort': '現在のポート', 'settings.general.h5AccessCurrentPortUnknown': '自動', + 'settings.general.h5AccessFixedPort': '固定ポート', + 'settings.general.h5AccessFixedPortPlaceholder': '自動', + 'settings.general.h5AccessFixedPortInvalid': 'ポートは 1024〜65535 の整数で指定してください。', + 'settings.general.h5AccessFixedPortHint': '固定ポート未設定の場合は前回のポートを自動的に再利用します。リバースプロキシなど安定したポートが必要な場合は固定を推奨します。変更はアプリ再起動後に反映されます。', + 'settings.general.h5AccessFixedPortRestartNote': '固定ポート {fixedPort} はアプリ再起動後に有効になります。現在は {activePort} で稼働中です。', + 'settings.general.h5AccessDisconnectGrace': '切断後の保持(秒)', + 'settings.general.h5AccessDisconnectGracePlaceholder': '30', + 'settings.general.h5AccessDisconnectGraceInvalid': '5〜86400 の整数(秒)で指定してください。', + 'settings.general.h5AccessDisconnectGraceHint': 'スマホの画面ロックやバックグラウンド移行で切断されても、実行中のタスクは中断されずバックグラウンドで完走し、再接続時に結果を確認できます。タスクが終了し誰も接続していない場合のみ、この時間が経過した後に CLI を停止します(デフォルト 30 秒)。外出先でのリモート操作では大きめに、例えば 600 に設定できます。', 'settings.general.h5AccessPublicUrl': '公開 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': '許可するオリジン', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 0633078d..5b914005 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1027,11 +1027,20 @@ export const kr: Record = { 'settings.general.h5AccessUrlCopied': 'H5 URL을 복사했습니다.', 'settings.general.h5AccessLaunchUrlCopied': 'QR 링크를 복사했습니다.', 'settings.general.h5AccessHideToken': '토큰 숨기기', - 'settings.general.h5AccessTokenNotAvailable': '새 토큰을 표시하려면 다시 생성하세요.', + 'settings.general.h5AccessTokenNotAvailable': '이전 버전의 토큰은 복구할 수 없습니다. 한 번 재생성하면 토큰이 영구 보존되어 언제든지 확인할 수 있습니다.', 'settings.general.h5AccessPublicHost': '액세스 호스트 / IP', 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', 'settings.general.h5AccessCurrentPort': '현재 포트', 'settings.general.h5AccessCurrentPortUnknown': '자동', + 'settings.general.h5AccessFixedPort': '고정 포트', + 'settings.general.h5AccessFixedPortPlaceholder': '자동', + 'settings.general.h5AccessFixedPortInvalid': '포트는 1024~65535 사이의 정수여야 합니다.', + 'settings.general.h5AccessFixedPortHint': '고정 포트를 설정하지 않으면 이전 포트를 자동으로 재사용합니다. 리버스 프록시 등 안정적인 포트가 필요한 경우 고정을 권장합니다. 변경 사항은 앱 재시작 후 적용됩니다.', + 'settings.general.h5AccessFixedPortRestartNote': '고정 포트 {fixedPort}은(는) 앱 재시작 후 적용됩니다. 현재는 {activePort}에서 실행 중입니다.', + 'settings.general.h5AccessDisconnectGrace': '연결 끊김 유지(초)', + 'settings.general.h5AccessDisconnectGracePlaceholder': '30', + 'settings.general.h5AccessDisconnectGraceInvalid': '5~86400 사이의 정수(초)여야 합니다.', + 'settings.general.h5AccessDisconnectGraceHint': '휴대폰 화면 잠금이나 백그라운드 전환으로 연결이 끊겨도 실행 중인 작업은 중단되지 않고 백그라운드에서 완료되며, 재연결 시 결과를 볼 수 있습니다. 작업이 끝나고 연결된 클라이언트가 없을 때만 이 시간이 지난 후 CLI를 중지합니다(기본 30초). 외출 중 원격 조작 시 600처럼 크게 설정할 수 있습니다.', 'settings.general.h5AccessPublicUrl': '공개 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': '허용된 출처', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index d30f243f..a208a343 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1027,11 +1027,20 @@ export const zh: Record = { 'settings.general.h5AccessUrlCopied': 'H5 連結已複製。', 'settings.general.h5AccessLaunchUrlCopied': '掃碼連結已複製。', 'settings.general.h5AccessHideToken': '隱藏令牌', - 'settings.general.h5AccessTokenNotAvailable': '重新生成後可顯示新的令牌。', + 'settings.general.h5AccessTokenNotAvailable': '舊版本的令牌無法找回。重新生成一次後,令牌將長期保存,可隨時查看。', 'settings.general.h5AccessPublicHost': '訪問主機 / IP', 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', 'settings.general.h5AccessCurrentPort': '當前埠', 'settings.general.h5AccessCurrentPortUnknown': '自動', + 'settings.general.h5AccessFixedPort': '固定埠', + 'settings.general.h5AccessFixedPortPlaceholder': '自動', + 'settings.general.h5AccessFixedPortInvalid': '埠需為 1024-65535 之間的整數。', + 'settings.general.h5AccessFixedPortHint': '不設固定埠時會自動沿用上次的埠;反向代理等需要穩定埠的場景建議固定。修改埠後重啟應用程式生效。', + 'settings.general.h5AccessFixedPortRestartNote': '固定埠 {fixedPort} 將在重啟應用程式後生效,目前仍執行在 {activePort}。', + 'settings.general.h5AccessDisconnectGrace': '斷線保活(秒)', + 'settings.general.h5AccessDisconnectGracePlaceholder': '30', + 'settings.general.h5AccessDisconnectGraceInvalid': '需為 5-86400 之間的整數秒。', + 'settings.general.h5AccessDisconnectGraceHint': '手機鎖屏或切後台導致斷線後,正在執行的任務不會被中斷,會在後台跑完、重連即可看到結果;只有任務空閒且無人連線時才在此時長後停止 CLI(預設 30 秒)。外出遠端操作可調大,例如 600。', 'settings.general.h5AccessPublicUrl': '公開訪問 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': '允許的來源', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 093182ce..a68a0a57 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1027,11 +1027,20 @@ export const zh: Record = { 'settings.general.h5AccessUrlCopied': 'H5 链接已复制。', 'settings.general.h5AccessLaunchUrlCopied': '扫码链接已复制。', 'settings.general.h5AccessHideToken': '隐藏令牌', - 'settings.general.h5AccessTokenNotAvailable': '重新生成后可显示新的令牌。', + 'settings.general.h5AccessTokenNotAvailable': '旧版本的令牌无法找回。重新生成一次后,令牌将长期保存,可随时查看。', 'settings.general.h5AccessPublicHost': '访问主机 / IP', 'settings.general.h5AccessPublicHostPlaceholder': '192.168.1.100', 'settings.general.h5AccessCurrentPort': '当前端口', 'settings.general.h5AccessCurrentPortUnknown': '自动', + 'settings.general.h5AccessFixedPort': '固定端口', + 'settings.general.h5AccessFixedPortPlaceholder': '自动', + 'settings.general.h5AccessFixedPortInvalid': '端口需为 1024-65535 之间的整数。', + 'settings.general.h5AccessFixedPortHint': '不设固定端口时会自动复用上次的端口;反向代理等需要稳定端口的场景建议固定。修改端口后重启应用生效。', + 'settings.general.h5AccessFixedPortRestartNote': '固定端口 {fixedPort} 将在重启应用后生效,当前仍运行在 {activePort}。', + 'settings.general.h5AccessDisconnectGrace': '断连保活(秒)', + 'settings.general.h5AccessDisconnectGracePlaceholder': '30', + 'settings.general.h5AccessDisconnectGraceInvalid': '需为 5-86400 之间的整数秒。', + 'settings.general.h5AccessDisconnectGraceHint': '手机锁屏或切后台导致断连后,正在执行的任务不会被打断,会在后台跑完、重连即可看到结果;只有任务空闲且无人连接时才在此时长后停止 CLI(默认 30 秒)。出门远程操作可调大,例如 600。', 'settings.general.h5AccessPublicUrl': '公开访问 URL', 'settings.general.h5AccessPublicUrlPlaceholder': 'https://chat.example.com', 'settings.general.h5AccessAllowedOrigins': '允许的来源', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 01a04f05..2e44355d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -123,6 +123,25 @@ function extractH5AccessPort(baseUrl: string | null): string | null { } } +// Mirrors the server-side fixedPort range (h5AccessService MIN/MAX_FIXED_PORT). +function parseH5FixedPortDraft(draft: string): number | null | 'invalid' { + const trimmed = draft.trim() + if (!trimmed) return null + if (!/^\d{1,5}$/.test(trimmed)) return 'invalid' + const port = Number(trimmed) + return port >= 1024 && port <= 65535 ? port : 'invalid' +} + +// Mirrors the server-side disconnect grace range (h5AccessService +// MIN/MAX_DISCONNECT_GRACE_SECONDS). Empty = use the built-in 30s default. +function parseH5GraceDraft(draft: string): number | null | 'invalid' { + const trimmed = draft.trim() + if (!trimmed) return null + if (!/^\d{1,5}$/.test(trimmed)) return 'invalid' + const seconds = Number(trimmed) + return seconds >= 5 && seconds <= 86400 ? seconds : 'invalid' +} + function buildH5PublicBaseUrlFromHostDraft(draft: string, currentBaseUrl: string | null): string | null { const trimmed = draft.trim() if (!trimmed) return null @@ -2777,27 +2796,44 @@ function H5AccessSettings() { const t = useTranslation() const addToast = useUIStore((s) => s.addToast) const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(extractH5AccessAddressDraft(h5Access.publicBaseUrl)) - const [h5GeneratedToken, setH5GeneratedToken] = useState(null) + const [h5FixedPortDraft, setH5FixedPortDraft] = useState(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '') + const [h5GraceDraft, setH5GraceDraft] = useState(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '') const [h5TokenVisible, setH5TokenVisible] = useState(false) const [h5EnableConfirmOpen, setH5EnableConfirmOpen] = useState(false) const [h5QrDataUrl, setH5QrDataUrl] = useState(null) const [h5ActionRunning, setH5ActionRunning] = useState(false) const h5AccessUrl = h5Access.publicBaseUrl + // The token is persisted server-side, so the QR code and copy actions stay + // available across desktop restarts (issue #767). + const h5Token = h5Access.token const h5LaunchUrl = useMemo( - () => buildH5LaunchUrl(h5AccessUrl, h5GeneratedToken), - [h5AccessUrl, h5GeneratedToken], + () => buildH5LaunchUrl(h5AccessUrl, h5Token), + [h5AccessUrl, h5Token], ) - const h5AccessPort = extractH5AccessPort(h5AccessUrl) + const h5ActivePort = h5AccessDiagnostics?.activePort != null + ? String(h5AccessDiagnostics.activePort) + : extractH5AccessPort(h5AccessUrl) const h5NextPublicBaseUrl = buildH5PublicBaseUrlFromHostDraft(h5PublicBaseUrlDraft, h5Access.publicBaseUrl) - const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null) + const h5NextFixedPort = parseH5FixedPortDraft(h5FixedPortDraft) + const h5FixedPortInvalid = h5NextFixedPort === 'invalid' + const h5NextGrace = parseH5GraceDraft(h5GraceDraft) + const h5GraceInvalid = h5NextGrace === 'invalid' + const h5AccessDirty = h5NextPublicBaseUrl !== (h5Access.publicBaseUrl ?? null) || + (!h5FixedPortInvalid && h5NextFixedPort !== h5Access.fixedPort) || + (!h5GraceInvalid && h5NextGrace !== h5Access.disconnectGraceSeconds) + const h5FixedPortPendingRestart = h5Access.fixedPort != null && + h5ActivePort != null && + String(h5Access.fixedPort) !== h5ActivePort useEffect(() => { setH5PublicBaseUrlDraft(extractH5AccessAddressDraft(h5Access.publicBaseUrl)) + setH5FixedPortDraft(h5Access.fixedPort != null ? String(h5Access.fixedPort) : '') + setH5GraceDraft(h5Access.disconnectGraceSeconds != null ? String(h5Access.disconnectGraceSeconds) : '') }, [h5Access]) useEffect(() => { let cancelled = false - if (!h5Access.enabled || !h5LaunchUrl || !h5GeneratedToken) { + if (!h5Access.enabled || !h5LaunchUrl || !h5Token) { setH5QrDataUrl(null) return () => { cancelled = true @@ -2815,7 +2851,7 @@ function H5AccessSettings() { return () => { cancelled = true } - }, [h5Access.enabled, h5LaunchUrl, h5GeneratedToken]) + }, [h5Access.enabled, h5LaunchUrl, h5Token]) const runH5Action = async (action: () => Promise) => { setH5ActionRunning(true) @@ -2829,9 +2865,12 @@ function H5AccessSettings() { } const handleH5SettingsSave = async () => { + if (h5FixedPortInvalid || h5GraceInvalid) return await runH5Action(async () => { await updateH5AccessSettings({ publicBaseUrl: h5NextPublicBaseUrl, + fixedPort: h5NextFixedPort, + disconnectGraceSeconds: h5NextGrace, }) }) } @@ -2867,8 +2906,7 @@ function H5AccessSettings() { const handleH5EnableConfirm = async () => { await runH5Action(async () => { - const token = await enableH5Access() - setH5GeneratedToken(token) + await enableH5Access() setH5TokenVisible(false) setH5EnableConfirmOpen(false) }) @@ -2877,15 +2915,13 @@ function H5AccessSettings() { const handleH5Disable = async () => { await runH5Action(async () => { await disableH5Access() - setH5GeneratedToken(null) setH5TokenVisible(false) }) } const handleH5Regenerate = async () => { await runH5Action(async () => { - const token = await regenerateH5AccessToken() - setH5GeneratedToken(token) + await regenerateH5AccessToken() setH5TokenVisible(false) }) } @@ -2992,7 +3028,7 @@ function H5AccessSettings() { ) : null}
-
+
setH5PublicBaseUrlDraft(event.target.value)} /> + setH5FixedPortDraft(event.target.value)} + />
+
+ setH5GraceDraft(event.target.value)} + /> +

+ {t('settings.general.h5AccessDisconnectGraceHint')} +

+

{t('settings.general.h5AccessOpenHint')} + {' '} + {t('settings.general.h5AccessFixedPortHint')}

+ {h5FixedPortPendingRestart && ( +
+ {t('settings.general.h5AccessFixedPortRestartNote', { + fixedPort: String(h5Access.fixedPort), + activePort: h5ActivePort ?? '', + })} +
+ )}
{h5AccessUrl && ( @@ -3073,7 +3145,7 @@ function H5AccessSettings() { {t('settings.general.h5AccessQrTitle')}

- {h5GeneratedToken + {h5Token ? t('settings.general.h5AccessQrHint') : t('settings.general.h5AccessQrRefreshHint')}

@@ -3087,19 +3159,19 @@ function H5AccessSettings() { size="sm" variant="secondary" icon={