/** * The dsh-zen-remote plugin row's settings block on the Plugins manager * (T15 + T15-fix + T16 + T17): the staged row fields inside the official * settings-form frame ending in its one save control, and BELOW the form — * outside it, so they still render when the configuration namespace is not * served — the instant-operation areas. Which areas render follows the row's * SAVED role, read from the configForms snapshot's row document — never from * a status body (T16-fix 3); when that document carries no role at all (the * value lives only in `lan-gate.config.json`), the page probes the * lightweight client-config route (both roles register it) for the EFFECTIVE * role (T17). A host shows gateway status, pairing, device list and the push * probe (POSTing the same-origin admin routes and re-reading `admin/status`); * a client shows the server connection form, the connection status line and * unpairing (against `client/status`, T16) and NEVER polls `admin/*` — on a * client deployment those routes do not exist, and a stale kept body would * pin the page to the old role. The poll choice waits out the snapshot's * loading state — and the probe, when the row cannot answer — for the same * reason. Only field edits stage and save through `ZenRemoteSettingsForm`. * * Status refreshes never clear what is already on screen (T15-fix 1): a * failed refresh keeps the last ready data and says so in a banner — only a * failed FIRST load enters the error state, because saving a restart-required * field reloads the plugin row (T17) and the first post-save refresh can land * inside that reload window (further pulls follow at 1.5s, and for a * row-reloading save at 3s and 6s, T17b). Every status request carries a * latest-wins ticket (T15-fix 4), so an earlier request that answers late * cannot overwrite newer data. * * Opened through the gateway (`viaGateway`, i.e. on a phone or another * browser) the server-local buttons disable and a notice says so — and until * one status load answers from the server itself, they count as remote. The * summary view renders its one-liner alone and never fetches a thing. */ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import type { ReactNode } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import { Button, SettingsForm, SettingsValueField, Switch } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsFormLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { ADMIN_ACTION_ROUTE, ADMIN_PAIR_ROUTE, ADMIN_PLUGIN_BRIDGE_ROUTE, ADMIN_PUSH_TEST_ROUTE, ADMIN_STATUS_ROUTE, CLIENT_CLAIM_ROUTE, CLIENT_CONFIG_ROUTE, CLIENT_RECONNECT_ROUTE, CLIENT_STATUS_ROUTE, claimDeviceNameOf, createLatestGate, clientStatusLineOf, deriveClientStatusView, deriveSettingsView, localOpsAllowed, normalizePairingCode, shouldFollowServerDeviceName, } from '../../client-data/settings-form.ts' import { bridgeSectionState, togglePluginConsent } from '../../client-data/plugin-bridge.ts' import type { PluginBridgeStatusView } from '../../client-data/plugin-bridge.ts' import type { AdminStatusBody, ClaimRouteBody, ClientConfigBody, ClientConnectionView, ClientStatusBody, SettingsDeviceView, SettingsFieldState, SettingsFieldView, SettingsPairingView, ZenRemoteSettingsForm, } from '../../client-data/settings-form.ts' import { NS } from '../locales.ts' import { remoteDropDiagnostics } from '../effects/remote-drop.ts' import { describeShare } from '../../client-data/shares.ts' import type { ShareEntryView, SharesStore } from '../../client-data/shares.ts' export interface SettingsSectionProps extends PropsRuntime<'plugins.row.config'>, PropsLocale { /** The staged configuration form (injected share). */ config: ZenRemoteSettingsForm /** The shared shares store (injected share, T33b) — the same singleton the * title-row icon and the session menu read. */ shares: SharesStore } /** Device roles the pairing picker offers, in display order. */ const PAIR_ROLES = ['web', 'desktop-client'] as const type PairRole = (typeof PAIR_ROLES)[number] /** Display layouts a device can be pinned to (the gateway's `kind` field). */ const DEVICE_KINDS = ['auto', 'phone', 'desktop'] as const /** Longest device name, mirroring the gateway's rename cap. */ const DEVICE_NAME_MAX = 40 type PushTest = | { state: 'idle' } | { state: 'busy' } | { state: 'ok', sent: number, failed: number } | { state: 'fail' } /** One admin-status load's outcome; `error.status` is undefined for network / * non-JSON failures. `stale` keeps the last ready body on screen after a * failed refresh (T15-fix 1). */ type Load = | { state: 'loading' } | { state: 'ready', body: AdminStatusBody } | { state: 'stale', body: AdminStatusBody } | { state: 'error', status: number | undefined } /** One client-status load's outcome, same stale contract as {@link Load}. */ type ClientLoad = | { state: 'loading' } | { state: 'ready', view: ClientConnectionView } | { state: 'stale', view: ClientConnectionView } | { state: 'error' } /** One pairing attempt's classified failure, rendered by code. */ interface PairFail { code: string retryAfterMs?: number } async function postJson(url: string, payload: Record): Promise<{ ok: boolean, sent: number, failed: number }> { try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const body = await res.json().catch(() => ({})) as { ok?: boolean, sent?: unknown, failed?: unknown } return { ok: res.ok && body.ok === true, sent: typeof body.sent === 'number' ? body.sent : 0, failed: typeof body.failed === 'number' ? body.failed : 0, } } catch { return { ok: false, sent: 0, failed: 0 } } } export function SettingsSection(props: SettingsSectionProps) { // The summary view is a one-liner only — it must not even fetch // admin/status, so every hook (and the fetch) lives in the page component. if (props.view === 'summary') { return (

{props.t('settings.summary')}

) } return } /** Fetch error carrying the HTTP status (undefined for network / non-JSON failures). */ class StatusError extends Error { readonly status: number | undefined constructor(status: number | undefined) { super(status === undefined ? 'status unavailable' : `status ${status}`) this.name = 'StatusError' this.status = status } } type SectionT = SettingsSectionProps['t'] /** Copy of the client group's connection line (T43): the pure * {@link clientStatusLineOf} picks the case; this table only binds copy. */ function clientStatusText(view: ClientConnectionView, now: number, t: SectionT): string { const line = clientStatusLineOf(view, now) switch (line.kind) { case 'connectedName': return t('settings.client.statusConnectedName', { serverName: line.serverName }) case 'connected': return t('settings.client.statusConnected', { serverUrl: line.serverUrl }) case 'offlineRetry': return t('settings.client.statusOfflineRetry', { seconds: line.seconds }) case 'offlineRetrySoon': return t('settings.client.statusOfflineRetrySoon') case 'unreachable': return t('settings.client.statusUnreachable') case 'revoked': return t('settings.client.statusRevoked') case 'incompatible': return t('settings.client.statusIncompatible') case 'unexpected': return t('settings.client.statusUnexpected') case 'invalidUrl': return t('settings.client.statusInvalidUrl') default: return t('settings.client.statusUnpaired') } } /** Copy of the pairing failure line, one case per claim failure code. The * wording is always LOCAL (this build's dictionary), never the server's * message — a remote message arrives in the server's language and can leak * its internals. */ function pairFailText(fail: PairFail, t: SectionT): string { switch (fail.code) { case 'invalid': return t('settings.client.failInvalid') case 'insecure-http': return t('settings.client.failInsecureHttp') case 'bad-code': return t('settings.client.failBadCode') case 'locked': { const minutes = Math.max(1, Math.ceil((fail.retryAfterMs ?? 0) / 60000)) return t('settings.client.failLocked', { minutes }) } case 'unreachable': return t('settings.client.failUnreachable') case 'role-mismatch': return t('settings.client.failRoleMismatch') default: return t('settings.client.failUnexpected') } } function SettingsSectionPage({ config, shares, t }: SettingsSectionProps) { const form = useSyncExternalStore( useCallback((cb: () => void) => config.subscribe(cb), [config]), () => config.getSnapshot(), ) // Latest-wins gates (T15-fix 4): every status request takes a ticket and // only the newest may apply its result. const statusGate = useMemo(createLatestGate, []) const clientGate = useMemo(createLatestGate, []) const [load, setLoad] = useState({ state: 'loading' }) const [clientLoad, setClientLoad] = useState({ state: 'loading' }) const [now, setNow] = useState(() => Date.now()) const [pairRole, setPairRole] = useState('web') const [pairBusy, setPairBusy] = useState(false) const [pairFailed, setPairFailed] = useState(false) // The code the pair route just minted, shown before the status refresh // lands (and dropped once it does — the refreshed status carries it). const [freshPairing, setFreshPairing] = useState<{ code: string, expiresAt: number, role: 'web' | 'desktop-client' } | null>(null) const [deviceBusy, setDeviceBusy] = useState(false) const [deviceFailed, setDeviceFailed] = useState(false) // The device row currently renamed in place (Electron lacks the blocking // JS dialog methods, so renames edit inline). const [renaming, setRenaming] = useState<{ id: string, draft: string } | null>(null) const [pushTest, setPushTest] = useState({ state: 'idle' }) // The client group's pairing form (T16). The address prefills from the row // (once the shared form's document is served) until the user types into it; // the code normalizes as typed. The registered name is the shared 设备名称 // field in the role card above (T57) — no separate name box here. const [pairUrl, setPairUrl] = useState('') const [pairUrlTouched, setPairUrlTouched] = useState(false) const [pairCode, setPairCode] = useState('') const [claimBusy, setClaimBusy] = useState(false) const [claimFail, setClaimFail] = useState(null) const [claimWriteFailed, setClaimWriteFailed] = useState(false) const [unpairBusy, setUnpairBusy] = useState(false) const [unpairDone, setUnpairDone] = useState(false) // T43: the 立即重连 button's in-flight marker. const [reconnectBusy, setReconnectBusy] = useState(false) const loadStatus = useCallback(() => { const ticket = statusGate.next() fetch(ADMIN_STATUS_ROUTE) .then((res) => { if (!res.ok) return Promise.reject(new StatusError(res.status)) return res.json() as Promise }) .then((body) => { // A 200 whose body is not ok:true is a FAILED load (T16-fix2): it // throws into the catch, which keeps the last ready data instead of // letting a broken body overwrite it. if (body?.ok !== true) throw new StatusError(undefined) if (!statusGate.isLatest(ticket)) return setLoad({ state: 'ready', body }) setNow(Date.now()) setFreshPairing(null) }) .catch((error: unknown) => { if (!statusGate.isLatest(ticket)) return // T15-fix 1: a failed REFRESH keeps the last ready data on screen and // says so; only a failed FIRST load enters the error state. Saving a // restart-required field reloads the plugin row (T17), so the first // post-save refresh can land inside that reload window — the second // pull (1.5s later) then lands the fresh values. setLoad((prev) => prev.state === 'ready' || prev.state === 'stale' ? { state: 'stale', body: prev.body } : { state: 'error', status: error instanceof StatusError ? error.status : undefined }) }) }, [statusGate]) // T59: the SERVER's record of this device's name wins over the row — // unless the user is mid-edit (a staged draft in the field). One direct // write lands it in the row's serverName; that write's own volatile // update reaches the backend with both sides now EQUAL, so the push path // stays silent and the two ends cannot bounce the name back and forth. // T59-fix: the decision runs HERE, on each FRESH answer, judging the row // and the draft AS THEY ARE at that moment — never off a stale // `clientView` racing a row change (an effect keyed on the row value // would fire on every save with the PREVIOUS poll's name and write the // old one straight back over the user's fresh save). const followServerDeviceName = useCallback((view: ClientConnectionView): void => { if (!config.savedRoleIsClient()) return if (view.state !== 'connected') return const rowName = typeof config.rowValue('serverName') === 'string' ? config.rowValue('serverName') as string : '' if (!shouldFollowServerDeviceName(view.deviceName, rowName, config.hasDraft('serverName'))) return void config.writeDeviceName(view.deviceName) }, [config]) const loadClientStatus = useCallback(() => { const ticket = clientGate.next() fetch(CLIENT_STATUS_ROUTE) .then((res) => { if (!res.ok) return Promise.reject(new StatusError(res.status)) return res.json() as Promise }) .then((body) => { if (!clientGate.isLatest(ticket)) return const view = deriveClientStatusView(body) setClientLoad({ state: 'ready', view }) followServerDeviceName(view) }) .catch(() => { if (!clientGate.isLatest(ticket)) return setClientLoad((prev) => prev.state === 'ready' || prev.state === 'stale' ? { state: 'stale', view: prev.view } : { state: 'error' }) }) }, [clientGate, followServerDeviceName]) // The client group's rendered view, hoisted for the countdown ticker and // the retry-follow refresh below (T43). const clientView = clientLoad.state === 'ready' || clientLoad.state === 'stale' ? clientLoad.view : undefined // RD-3: the remote drop guard's browser-side counters, read at render // time — no snapshot state of its own: the initial mount, the 15s // connected-status poll and the countdown ticker all re-render this page, // and each render takes the values as they are right then. const dropDiag = remoteDropDiagnostics() const clientRetryAt = clientView?.nextRetryAt const clientCountingDown = clientRetryAt !== undefined // Offline with a pending retry: refresh the status shortly after the // retry comes due, so a server that came back flips the line to connected // without a manual refresh. A still-offline answer carries the NEXT // nextRetryAt and re-arms this effect. A HIDDEN page does not fire the // refresh: the timer marks the round missed, and becoming visible catches // up once (the same visibility-shaped cadence the stores keep). useEffect(() => { if (!clientCountingDown) return let missed = false const timer = window.setTimeout(() => { if (document.visibilityState === 'hidden') { missed = true return } loadClientStatus() }, Math.max(0, (clientRetryAt ?? 0) + 1500 - Date.now())) const onVisibility = (): void => { if (!missed || document.visibilityState !== 'visible') return missed = false loadClientStatus() } document.addEventListener('visibilitychange', onVisibility) return () => { window.clearTimeout(timer) document.removeEventListener('visibilitychange', onVisibility) } }, [clientCountingDown, clientRetryAt, loadClientStatus]) // T43 立即重连: one POST, then refresh now (the state is at least // "connecting") and once more after the attempt can have settled. const runReconnect = async (): Promise => { setReconnectBusy(true) try { const res = await fetch(CLIENT_RECONNECT_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}', }) if (res.ok) { loadClientStatus() refreshTimers.current.push(window.setTimeout(() => { loadClientStatus() }, 1500)) } } catch { // The refreshes tell the story; nothing to add here. } setReconnectBusy(false) } // The page's role is the SAVED row role from the configForms snapshot — // nothing else (T16-fix 3): a client deployment has no admin route at all, // so a role judged from admin/status either 404s into host, or — with a // stale kept body after a role switch — stays pinned to the old role. The // poll choice waits out the snapshot's loading state, so a client row never // fires a wasted admin/status; once settled, client polls ONLY // client/status. const data = load.state === 'ready' || load.state === 'stale' ? deriveSettingsView(load.body, { now, rowUser: config.rowUser() }) : undefined const clientRole = config.savedRoleIsClient() // The plugin bridge's registered-plugin listing (PB-5), host page only: // loaded once per page life and refreshed whenever a save lands (the // consent staged by the toggles below rides that very save). Starts in // the loading state — the section renders the loading copy until the // first answer lands (or the load fails, which is its own state, never // the empty one); a composition without the route stays loading. // PB-9-fix: the listing is a THREE-state read — loading, failed (an old // build, or the page opened through the gateway where the listing is // refused), or an answer that carries zero rows. Only the third means // "nothing registered"; the first two must not masquerade as it. const [bridgeLoad, setBridgeLoad] = useState<{ state: 'loading' | 'error' | 'ready', rows: { plugin: string, version: string, hasServerScope: boolean, consent: boolean }[] }>({ state: 'loading', rows: [] }) const loadBridge = useCallback(() => { setBridgeLoad((prev) => ({ state: 'loading', rows: prev.rows })) fetch(ADMIN_PLUGIN_BRIDGE_ROUTE) .then((res) => { if (!res.ok) return Promise.reject(new Error(String(res.status))) return res.json() as Promise<{ value?: { plugins?: unknown } }> }) .then((body) => { const plugins = body?.value?.plugins if (!Array.isArray(plugins)) return setBridgeLoad({ state: 'ready', rows: plugins.filter((row): row is { plugin: string, version: string, hasServerScope: boolean, consent: boolean } => row !== null && typeof row === 'object' && typeof (row as { plugin?: unknown }).plugin === 'string'), }) }) .catch(() => { setBridgeLoad((prev) => ({ state: 'error', rows: prev.rows })) }) }, []) useEffect(() => { if (!clientRole) loadBridge() }, [clientRole, loadBridge]) const bridgeState = bridgeSectionState(bridgeLoad.state, bridgeLoad.rows) const statusPoll = config.statusPoll() useEffect(() => { if (statusPoll === 'client') loadClientStatus() else if (statusPoll === 'admin') loadStatus() }, [statusPoll, loadClientStatus, loadStatus]) // T59: while CONNECTED the status refreshes on a steady 15s cadence (the // heartbeat pace) — that is the channel a server-side rename of this // device arrives through; offline the retry-follow effect above owns the // timing, and unpaired pages simply never arm this. const clientConnected = clientView?.state === 'connected' useEffect(() => { if (!clientConnected) return const timer = window.setInterval(() => { loadClientStatus() }, 15_000) return () => { window.clearInterval(timer) } }, [clientConnected, loadClientStatus]) // T17: when the row document cannot answer the saved role (the value lives // only in lan-gate.config.json), probe the client-config route — registered // wherever a webServer exists, on either role — for the EFFECTIVE role. A // stored row role always wins, so this only fills the gap; a failed probe // keeps the pre-T17 host default. Waits out the mirror's loading phase, // like the poll choice above. const scopeStatus = form.scopeStatus const roleKnown = config.rowRoleKnown() useEffect(() => { if (scopeStatus === 'loading' || roleKnown) return let cancelled = false fetch(CLIENT_CONFIG_ROUTE) .then((res) => (res.ok ? (res.json() as Promise) : undefined)) .then((body) => { if (!cancelled) config.setProbedRole(body?.role === 'client' ? 'client' : 'host') }) .catch(() => { if (!cancelled) config.setProbedRole('host') }) return () => { cancelled = true } }, [scopeStatus, roleKnown, config]) // Feed the effective values the fields display (and compare drafts against) // — but ONLY while the page actually polls the admin source (T55): a role // switch leaves the host page's kept status load (and its fed baseline) in // place forever, because a client page never refreshes admin/status again. // The form also refuses the baseline under a client poll, but the stale // values are dropped here as well, so the fields fall back to the row's // stored values the moment the row moves. useEffect(() => { if (statusPoll !== 'admin') { config.setBaseline(undefined) return } if (load.state !== 'ready') return config.setBaseline(load.body.config?.values) }, [load, config, statusPoll]) // Re-render every second while a pairing code is on screen so the countdown // follows; both display paths drop the code once `expiresAt` passes. T43: // the same ticker drives the offline "将于 N 秒后重试" countdown. const pairingShown = data?.pairing !== null && data?.pairing !== undefined || (freshPairing !== null && freshPairing.expiresAt > now) useEffect(() => { if (!pairingShown && !clientCountingDown) return const timer = window.setInterval(() => { setNow(Date.now()) }, 1000) return () => { window.clearInterval(timer) } }, [pairingShown, clientCountingDown]) // Environment-locked fields cannot be staged: a written value would be // shadowed by the variable anyway. The controller refuses them; this effect // keeps its lock set in step with admin/status. The same pass feeds the // saved-invalid set (T15-fix 3): those fields keep showing the RAW stored // value instead of the resolved one. const fieldEntries = data === undefined ? [] : Object.entries(data.fields) const lockedKey = fieldEntries .filter(([, fv]) => fv.locked) .map(([field]) => field) .join(',') const rowInvalidKey = fieldEntries .filter(([, fv]) => fv.savedRowInvalid) .map(([field]) => field) .join(',') useEffect(() => { config.setLockedFields(lockedKey === '' ? [] : lockedKey.split(',')) }, [lockedKey, config]) useEffect(() => { config.setRowInvalidFields(rowInvalidKey === '' ? [] : rowInvalidKey.split(',')) }, [rowInvalidKey, config]) // Post-save double refresh (T15-fix 1): once immediately, once after 1.5s — // saving a restart-required field reloads the plugin row (T17) and the // first refresh can land inside that window. Timers are released if the // page unmounts. const refreshTimers = useRef([]) useEffect(() => () => { for (const timer of refreshTimers.current) window.clearTimeout(timer) refreshTimers.current = [] }, []) // Pairing, device management and the push probe are server-local acts: they // stay disabled until a status load answers AND it answered as the local // machine (a remote device gets viaGateway: true) — localOpsAllowed. const localOps = localOpsAllowed(data) const localBusy = pairBusy || deviceBusy const runPair = async (): Promise => { setPairBusy(true) setPairFailed(false) try { const res = await fetch(ADMIN_PAIR_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: pairRole }), }) const body = await res.json().catch(() => ({})) as { ok?: boolean, code?: unknown, expiresAt?: unknown, role?: unknown } if (res.ok && body.ok === true && typeof body.code === 'string' && body.code !== '' && typeof body.expiresAt === 'number') { setFreshPairing({ code: body.code, expiresAt: body.expiresAt, role: body.role === 'desktop-client' ? 'desktop-client' : 'web', }) setNow(Date.now()) loadStatus() } else { setPairFailed(true) } } catch { setPairFailed(true) } setPairBusy(false) } const runDeviceAction = async (payload: Record): Promise => { setDeviceBusy(true) setDeviceFailed(false) const result = await postJson(ADMIN_ACTION_ROUTE, payload) if (result.ok) loadStatus() else setDeviceFailed(true) setDeviceBusy(false) } const commitRename = (device: SettingsDeviceView): void => { if (renaming === null || renaming.id !== device.id) return const name = renaming.draft.trim() setRenaming(null) if (name === '' || name === device.name) return void runDeviceAction({ action: 'rename', id: device.id, name }) } const runPushTest = async (): Promise => { setPushTest({ state: 'busy' }) const result = await postJson(ADMIN_PUSH_TEST_ROUTE, {}) setPushTest(result.ok ? { state: 'ok', sent: result.sent, failed: result.failed } : { state: 'fail' }) } // --- client group (T16) ---------------------------------------------------- // The address prefill reads the row's stored serverUrl while the user has // not typed anything of their own. const rowServerUrl = typeof config.rowValue('serverUrl') === 'string' ? config.rowValue('serverUrl') as string : '' useEffect(() => { if (pairUrlTouched || pairUrl !== '' || rowServerUrl === '') return setPairUrl(rowServerUrl) }, [pairUrlTouched, pairUrl, rowServerUrl]) const codeNormalized = normalizePairingCode(pairCode) // T60: an INVALID device-name draft (red box — blank-but-nonempty or over // the cap) blocks the pairing: the claim would silently substitute the // default copy and the server would register a name the user never asked // for. A VALID draft pairs fine — it lands in the row with the write. const claimAllowed = form.available && form.writable && !claimBusy && codeNormalized.length === 8 && !form.serverName.invalid const runClaim = async (): Promise => { setClaimBusy(true) setClaimFail(null) setClaimWriteFailed(false) // T60: one name for both halves of the round-trip — trimmed and legal // ({@link claimDeviceNameOf}), sent to the server AND written into the // row when the claim lands. const claimName = claimDeviceNameOf(form.serverName.text, t('settings.client.deviceNameDefault')) try { const res = await fetch(CLIENT_CLAIM_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // T57: the name registers under the shared 设备名称 field's current // displayed value (a staged draft counts), falling back to the // default copy when the field is empty — the client page reads no // admin baseline (T55), so an unstored name displays empty here. body: JSON.stringify({ serverUrl: pairUrl.trim(), code: codeNormalized, name: claimName, }), }) const body = await res.json().catch(() => ({})) as ClaimRouteBody if (res.ok && body.ok === true && typeof body.token === 'string' && body.token !== '') { // The backend echoes the address it validated; write the credentials // AND the registered name through ONE mutate, then clear the code // and re-read the connection state. The name is the SERVER's // confirmed echo when the claim carried one (the gateway may have // trimmed or uniquified it at registration), else the name we // actually sent (T60) — either way the row now matches the server. const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : pairUrl.trim() // T59-fix: the echo is judged by the SAME rules as our own draft // (claimDeviceNameOf trims and validates) — a server answer we // could not store falls back to the name we actually sent. const echoed = typeof body.deviceName === 'string' && body.deviceName !== '' ? claimDeviceNameOf(body.deviceName, claimName) : claimName const landed = await config.writeClientPairing(urlToWrite, body.token, echoed) if (landed) { // The draft did its job — it now equals the displayed value, and // keeping it would be a phantom edit (T60). config.discardField('serverName') setPairCode('') setPairUrl(urlToWrite) setUnpairDone(false) loadClientStatus() } else { setClaimWriteFailed(true) } } else { // Every other shape is a failure (T16-fix): a non-OK status, a body // whose `ok` is not true, a missing token — shown with the classified // code when the route sent one. The body's `message` is ignored: the // wording is this build's dictionary (pairFailText). const fail: PairFail = { code: typeof body.code === 'string' && body.code !== '' ? body.code : 'unexpected' } if (typeof body.retryAfterMs === 'number') fail.retryAfterMs = body.retryAfterMs setClaimFail(fail) } } catch { setClaimFail({ code: 'unexpected' }) } setClaimBusy(false) } const runUnpair = async (): Promise => { if (!window.confirm(t('settings.client.unpairConfirm'))) return setUnpairBusy(true) const landed = await config.clearDeviceToken() setUnpairBusy(false) if (landed) { setUnpairDone(true) loadClientStatus() } } const formDisabled = !form.available || !form.writable // T17: saving any restart-required field (the role among them) moves the // row, and the host half's watcher reloads it — the gateway child restarts // with it. T17b narrowed the flag to drafts that would actually change the // effective value (a re-typed identical value or an env-locked field no // longer promise a reload), so the note appears exactly when a save will // move the row, and vanishes on discard or save. const reloadPending = form.restartPending // The code shown under the pairing controls: the refreshed status's, or the // one the pair route just returned while the refresh is in flight. const shownPairing: SettingsPairingView | null = data?.pairing ?? (freshPairing !== null && freshPairing.expiresAt > now ? { code: freshPairing.code, role: freshPairing.role, remainingSeconds: Math.max(0, Math.ceil((freshPairing.expiresAt - now) / 1000)), } : null) const labels: SettingsFormLabels = { unavailable: t('settings.unavailable'), readOnly: t('settings.readOnly'), saveFailed: t('settings.saveFailed'), save: t('settings.save'), saving: t('settings.saving'), } /** The per-field source annotations (file badge, env lock, invalid saved value). */ const annotations = (field: string): ReactNode => { const fv: SettingsFieldView | undefined = data?.fields[field as keyof typeof data.fields] if (fv === undefined) return null return ( <> {fv.fromFile &&

{t('settings.sourceFileBadge')}

} {fv.locked &&

{t('settings.sourceEnvBadge')}

} {fv.savedRowInvalid && (

{t('settings.savedInvalid', { source: sourceName(fv.source, t) })}

)} ) } /** The "saving a staged clear reverts to the next layer" hint (T15-fix 2). */ const clearPreviewNote = (state: SettingsFieldState): ReactNode => state.cleared ?

{t('settings.resetPreview')}

: null const text = (state: SettingsFieldState, field: string, label: string, hint: string, invalidLabel: string, numeric = false, note?: string) => (
{ config.stage(field, value) }} onReset={() => { config.resetField(field) }} /> {note !== undefined &&

{note}

} {clearPreviewNote(state)} {annotations(field)}
) // One-of control (the shared fields have no select): the shared field chrome, a select body. const select = (state: SettingsFieldState, field: string, label: string, hint: string, options: readonly string[], optionLabel: (option: string) => string) => (
{state.overridden && {t('settings.overridden')}} {state.overridden && ( )}
{hint !== '' &&

{hint}

} {clearPreviewNote(state)} {annotations(field)}
) // Boolean control (the shared fields have no checkbox): label row + switch. const bool = (state: SettingsFieldState, field: string, label: string, hint: string) => (
{state.overridden && {t('settings.overridden')}} {state.overridden && ( )} { config.stage(field, e.currentTarget.checked ? 'true' : 'false') }} />

{hint}

{clearPreviewNote(state)} {annotations(field)}
) const deviceRow = (device: SettingsDeviceView) => (
{device.name} {device.role === 'web' ? t('settings.deviceRoleWeb') : t('settings.deviceRoleDesktop')} {device.hasPush ? t('settings.devicePushOn') : t('settings.devicePushOff')} {t('settings.deviceLastSeen', { time: new Date(device.lastSeen).toLocaleString() })}
{renaming?.id === device.id && (
{/* Electron lacks the blocking JS dialog methods: the rename edits in place. */} { setRenaming({ id: device.id, draft: e.currentTarget.value }) }} onKeyDown={(e) => { if (e.key === 'Enter') commitRename(device) if (e.key === 'Escape') setRenaming(null) }} />
)}
) // Whether a landed save moved a restart-required field — read BEFORE save() // clears the staged drafts (T17b): the projected snapshot's restartPending // is exactly "the plan writes a restart-field op" (value actually differs, // not env-locked), which is what decides the refresh cadence below. const saveReloadsRow = (): boolean => config.getSnapshot().restartPending const saveLanded = (reloadsRow: boolean): void => { // A landed save that switched the role drops the OTHER role's data right // away (T16-fix 3) — a stale device list or connection line must not // survive the switch, and the poll effect alone would leave the old // body on screen. The delayed pulls re-read the role at fire time: the // first pull can land inside a row-restart window. if (config.savedRoleIsClient()) setLoad({ state: 'loading' }) else setClientLoad({ state: 'loading' }) const refresh = config.savedRoleIsClient() ? loadClientStatus : loadStatus refresh() // The plugin bridge listing re-reads too (PB-5): the consent toggles // stage through the very save that just landed. if (!config.savedRoleIsClient()) loadBridge() // Immediate pull + 1.5s covers a hot-only save. A save that reloads the // plugin row (T17) restarts the row and the gateway child with it — the // reload and the gateway's rebinding of its port both take longer than // one slot, so two more pulls at 3s and 6s cover the whole window // (T17b); each pull re-reads the role in case the switch lands mid-way. const pulls: readonly number[] = reloadsRow ? [1500, 3000, 6000] : [1500] for (const delay of pulls) { refreshTimers.current.push(window.setTimeout(() => { if (config.savedRoleIsClient()) loadClientStatus() else loadStatus() }, delay)) } } return (
{!clientRole && load.state === 'error' && (

{load.status === undefined ? t('settings.statusUnknown') : t('settings.statusUnavailable', { code: load.status })}

)} {!clientRole && load.state === 'stale' && (

{t('settings.refreshFailed')}

)} {clientRole && clientLoad.state === 'error' && (

{t('settings.statusUnknown')}

)} {clientRole && clientLoad.state === 'stale' && (

{t('settings.refreshFailed')}

)} {data?.viaGateway === true &&

{t('settings.remoteNotice')}

} {/* The staged row fields — the only part the settings-form frame owns. */}
{ const reloadsRow = saveReloadsRow() void config.save().then((landed) => { if (landed) saveLanded(reloadsRow) }) }} onDiscard={() => { config.discard() }} >

{t('settings.roleTitle')}

{select(form.role, 'role', t('settings.role'), t('settings.roleHint'), ['host', 'client'], (option) => ( option === 'host' ? t('settings.roleHost') : t('settings.roleClient') ))} {/* T57: the device name — the row's serverName field — lives in the role card on BOTH roles: on a host it is the server display name other devices' sidebars show; on a client it is the name a pairing registers with. The hint is the one role fact that differs. */} {text( form.serverName, 'serverName', t('settings.fieldServerName'), t(clientRole ? 'settings.fieldServerNameHintClient' : 'settings.fieldServerNameHint'), t('settings.invalidName'), )} {reloadPending &&

{t('settings.reloadNote')}

} {!clientRole && ( <>

{t('settings.gatewayTitle')}

{text(form.host, 'host', t('settings.fieldHost'), t('settings.fieldHostHint'), '', false, t('settings.fieldHostNote'))} {text(form.port, 'port', t('settings.fieldPort'), t('settings.fieldPortHint'), t('settings.invalidPort'), true)} {text(form.trustedProxies, 'trustedProxies', t('settings.fieldTrustedProxies'), t('settings.fieldTrustedProxiesHint'), '')} {text(form.rateLimit, 'rateLimit', t('settings.fieldRateLimit'), t('settings.fieldRateLimitHint'), t('settings.invalidPositiveInt'), true)} {text(form.vapidSubject, 'vapidSubject', t('settings.fieldVapidSubject'), t('settings.fieldVapidSubjectHint'), '')}

{t('settings.pushTitle')}

{bool(form.pushSummary, 'pushSummary', t('settings.fieldPushSummary'), t('settings.fieldPushSummaryHint'))} {bool(form.pushTurnEnd, 'pushTurnEnd', t('settings.fieldPushTurnEnd'), t('settings.fieldPushTurnEndHint'))} {bool(form.pushTool, 'pushTool', t('settings.fieldPushTool'), t('settings.fieldPushToolHint'))} {text(form.pushDebounceMs, 'pushDebounceMs', t('settings.fieldPushDebounceMs'), t('settings.fieldPushDebounceMsHint'), t('settings.invalidNonNegativeInt'), true)} {select(form.lang, 'lang', t('settings.fieldLang'), t('settings.fieldLangHint'), ['auto', 'zh', 'en'], (option) => ( option === 'auto' ? t('settings.langAuto') : option === 'zh' ? t('settings.langZh') : t('settings.langEn') ))}

{t('settings.shareTitle')}

{text(form.idleHours, 'idleHours', t('settings.fieldIdleHours'), t('settings.fieldIdleHoursHint'), t('settings.invalidHours'), true)} {bool(form.autoShareNewSessions, 'autoShareNewSessions', t('settings.fieldAutoShare'), t('settings.fieldAutoShareHint'))} {/* 插件桥 (PB-5): the consent toggles stage the NEXT consent map over the SAME form — one save button, one revision fence — reading the CURRENT map off the staged (or displayed) draft so consecutive toggles compose. */}

{t('settings.bridge.title')}

{bridgeState === 'loading' &&

{t('settings.bridge.loading')}

} {bridgeState === 'error' &&

{t('settings.bridge.loadFailed')}

} {bridgeState === 'empty' &&

{t('settings.bridge.empty')}

} {bridgeLoad.rows.map((row) => { let current: Record = {} try { const parsed: unknown = JSON.parse(form.pluginBridgeConsent.text) if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { current = parsed as Record } } catch { /* a malformed draft reads as nothing consented */ } return (
{row.plugin} {row.version}
{row.hasServerScope ? ( <>
{ config.stage('pluginBridgeConsent', JSON.stringify(togglePluginConsent(current, row.plugin, checked))) }} label={t('settings.bridge.consentToggle')} />

{t('settings.bridge.consentHint')}

) : (

{t('settings.bridge.sessionOnly')}

)}
) })} )}
{/* Instant operations — outside the form frame, so pairing, device management and the gateway status stay visible even while the configuration namespace is unavailable or read-only. */} {!clientRole && (

{data === undefined ? '' : data.gatewayAbnormal ? t('settings.gatewayAbnormal', { code: data.gatewayStatus }) : data.gatewayReachable ? t('settings.gatewayRunning', { port: data.gatewayPort ?? '?', target: data.gatewayTarget ?? '?' }) : t('settings.gatewayDown')}

{t('settings.pairingTitle')}

{PAIR_ROLES.map((role) => ( ))}
{pairFailed && {t('settings.pairingFail')}}
{shownPairing !== null && (
{/* Not a form control anywhere near: a span, not a control-less label. */} {t('settings.pairingCodeLabel')} {shownPairing.role === 'web' ? t('settings.deviceRoleWeb') : t('settings.deviceRoleDesktop')} {t('settings.pairingRemaining', { count: shownPairing.remainingSeconds })}

{shownPairing.code}

)}

{t('settings.devicesTitle')}

{(data?.devices.length ?? 0) === 0 &&

{t('settings.devicesEmpty')}

} {data?.devices.map(deviceRow)} {deviceFailed &&

{t('settings.actionFail')}

} {(data?.devices.length ?? 0) > 0 && (
)}
{/* The row's control is a Button (not labelable): a span, not a control-less label. */} {t('settings.pushTestLabel')}
{pushTest.state === 'ok' &&

{t('settings.pushTestOk', { sent: pushTest.sent, failed: pushTest.failed })}

} {pushTest.state === 'fail' &&

{t('settings.pushTestFail')}

}
)} {/* The client group (T16): server connection, connection status and unpairing — same outside-the-frame placement as the host's instant operations, so they render even while the namespace is read-only. */} {clientRole && (

{t('settings.client.connectTitle')}

{ setPairUrl(e.currentTarget.value); setPairUrlTouched(true) }} />

{t('settings.client.serverUrlHint')}

{ setPairCode(normalizePairingCode(e.currentTarget.value)) }} />

{t('settings.client.pairingCodeHint')}

{/* T60: the pairing button is disabled while the device-name draft is invalid — this line says why. */} {form.serverName.invalid && {t('settings.client.fixDeviceName')}} {claimFail !== null && {pairFailText(claimFail, t)}} {claimWriteFailed && {t('settings.client.failWrite')}}

{t('settings.client.statusTitle')}

{clientView === undefined ? '' : clientStatusText(clientView, now, t)}

{/* T43 立即重连: the response carrying a nextRetryAt is the one honest "the relay is offline with a retry armed" signal — the probe word underneath may be unreachable or unexpected; the route 409s anything else anyway (T43-fix). */}
{form.deviceToken.configured && (
{t('settings.client.tokenSet')}
)} {unpairDone &&

{t('settings.client.unpairDone')}

} {/* 诊断 (T43): everything on this section comes from the same client/status body. The interceptor (T23b) and compat (T42) groups render only when the response carried their fields — earlier servers answer neither, and the section itself only exists when there is at least one line to show. PB-5 adds the bridge rows and the rerouting note on the same presence gate. */} {clientView !== undefined && (clientView.lastError !== '' || clientView.intercept !== undefined || clientView.compat !== undefined || (clientView.pluginBridge?.length ?? 0) > 0 || clientView.pluginRouteIntercept !== undefined || dropDiag.installed || dropDiag.notInstalledReason !== undefined) && ( <>

{t('settings.client.diagTitle')}

{clientView.lastError !== '' && (

{t('settings.client.diagLastError', { code: clientView.lastError })}

)} {clientView.intercept !== undefined && ( <>

{clientView.intercept.installed ? t('settings.client.diagInterceptInstalled') : t('settings.client.diagInterceptMissing')}

{clientView.intercept.reasons.length > 0 && (

{t('settings.client.diagInterceptDisabled')} {clientView.intercept.reasons.map((reason, index) => ( {reason} ))}

)} {clientView.intercept.recentFailures.length > 0 && (
{clientView.intercept.recentFailures.map((failure, index) => (

{new Date(failure.time).toLocaleString()} · {failure.method !== '' ? failure.method : '—'} · {failure.code}

))}
)} )} {/* RD-3: the remote drop guard's own line — installed state plus the five counters. Browser-side values, so unlike the rows above it never rides the client/status body. */} {(dropDiag.installed || dropDiag.notInstalledReason !== undefined) && (

{dropDiag.installed ? t('settings.client.diagRemoteDropOk', { intercepted: dropDiag.intercepted, redispatched: dropDiag.redispatched, blocked: dropDiag.blockedDirectories, passthrough: dropDiag.passthrough, failed: dropDiag.rewrapFailed, }) : t('settings.client.diagRemoteDropOff', { reason: dropDiag.notInstalledReason ?? '—' })}

)} {/* RD-6: the upload-redirect (fetch-route intercept) line — rides the client/status body's serviceProbe. One row answers the question this diagnosis was built for: whether the redirect is installed, refused (with the shape reason), or its service inject never fired — and which of the two services is missing when so. */} {clientView?.uploadProbe !== undefined && (() => { const probe = clientView.uploadProbe as { uploadInjectSeen?: unknown connectionService?: unknown fileUploadsService?: unknown fetchRoute?: { installed?: unknown; uploadWrapped?: unknown; shape?: { reasons?: unknown } } } const seen = probe.uploadInjectSeen === true const refused = seen && probe.fetchRoute?.shape?.reasons instanceof Array && (probe.fetchRoute.shape.reasons as string[]).length > 0 const wrapped = probe.fetchRoute?.uploadWrapped === true const text = wrapped ? t('settings.client.diagUploadRedirectOk') : refused ? t('settings.client.diagUploadRedirectRefused', { reason: (probe.fetchRoute?.shape?.reasons as string[]).join('; ') }) : t('settings.client.diagUploadRedirectPending', { connection: typeof probe.connectionService === 'string' ? probe.connectionService : '—', fileUploads: typeof probe.fileUploadsService === 'string' ? probe.fileUploadsService : '—', }) return (

{text}

) })()} {clientView.compat !== undefined && ( <> {clientView.compat.mismatchedGroups.length > 0 && (

{t('settings.client.diagCompatGroups', { groups: clientView.compat.mismatchedGroups.join(', ') })}

)} {clientView.compat.recentCalls.length > 0 && (
{clientView.compat.recentCalls.map((failure, index) => (

{new Date(failure.time).toLocaleString()} · {failure.method !== '' ? failure.method : '—'} · {failure.code}

))}
)} )} {(clientView.pluginBridge?.length ?? 0) > 0 && clientView.pluginBridge !== undefined && (
{clientView.pluginBridge.map((row: PluginBridgeStatusView) => (

{row.plugin} · {row.localVersion !== '' ? `v${row.localVersion}` : '—'} ·{' '} {row.stateKey === 'settings.bridge.stateVersionMismatch' ? t(row.stateKey, { version: row.serverVersion ?? '?' }) : t(row.stateKey)}

))}
)} {clientView.pluginRouteIntercept !== undefined && !clientView.pluginRouteIntercept.installed && (

{t('settings.bridge.interceptOff', { reason: clientView.pluginRouteIntercept.reasons.join('; ') || '—' })}

)} )}
)}
) } /** Display name of a value's source layer for the invalid-saved-value note. */ function sourceName(source: SettingsFieldView['source'], t: SectionT): string { if (source === 'env') return t('settings.sourceEnv') if (source === 'file') return t('settings.sourceFile') return t('settings.sourceDefault') } /** * The shared-session list (T33b entry 3), inside the host instant-operations * card — outside the settings-form frame like the pairing and device areas, * so it renders while the configuration namespace is unavailable or * read-only. It reads the SAME shares store singleton as the title-row icon * and the session menu (one poll loop for all three, started by this page's * subscription while mounted). Renders nothing until the first GET answers * and nothing while the store's wired role is not host (T33b-fix: the role * comes from the settings page's own two-level decision — this component * additionally sits inside the page's `!clientRole` branch, judged the T16 * way off the saved row role). A failed GET keeps the last table on screen. * * Unlike pairing and device management these actions are NOT gated by * `viaGateway`: toggling shares through the gateway is the T33a-sanctioned * exception, so a phone may close a session's remote access. */ function SharesList({ shares, t }: { shares: SharesStore, t: SectionT }) { const snap = useSyncExternalStore( useCallback((onStoreChange: () => void) => shares.subscribe(onStoreChange), [shares]), () => shares.getSnapshot(), ) const [shareBusy, setShareBusy] = useState(false) const [shareFailed, setShareFailed] = useState(false) if (!snap.ready || snap.role !== 'host') return null const entries = snap.entries // Fresh clock per render — the 30 s poll replaces the snapshot, which is // the re-render that keeps every countdown text within one poll of truth. const now = Date.now() const runUnshare = async (sessionId: string): Promise => { setShareBusy(true) setShareFailed(false) const outcome = await shares.unshare(sessionId) if (!outcome.ok) setShareFailed(true) setShareBusy(false) } const runUnshareAll = async (): Promise => { if (!window.confirm(t('settings.shareCloseAllConfirm'))) return setShareBusy(true) setShareFailed(false) const outcome = await shares.unshareAll() if (!outcome.ok) setShareFailed(true) setShareBusy(false) } const shareRow = (entry: ShareEntryView) => (
{entry.title ?? entry.sessionId} {entry.viewers > 0 && ( {t('settings.shareViewers', { count: entry.viewers })} )} {describeShare(entry, now, t).remainingText}
) return ( <>

{t('settings.shareListTitle')}

{entries.length === 0 &&

{t('settings.shareListEmpty')}

} {entries.map(shareRow)} {shareFailed &&

{t('settings.shareActionFail')}

} {entries.length > 0 && (
)} ) }