/** * GatewayWizard (GW-10): the unified three-step remote-access & device-pairing * guide at the top of the plugin detail page (`plugins.bundle.config`, * `key: 'dsh-zen-remote'`). * * Covers both deployment roles in one entry point: * - Server (`host`): choose the ingress method (Caddy / Reverse Proxy / * Cloudflare Tunnel / LAN & Tailscale), configure parameters + server * display name & auto-share, verify connectivity, and pair either a * Phone/Browser (QR code) or another Desktop DSH Client (8-character * desktop pairing code + live paired-client list). * - Desktop Client (`client`): enter the server gateway URL + 8-character * desktop pairing code to claim a device token, and verify live * connection & session-sync status. */ 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 type { AdminStatusBody, ClaimRouteBody, ClientConnectionView, ClientStatusBody, SettingsDeviceView, ZenRemoteSettingsForm, } from '../../client-data/settings-form.ts' import { ADMIN_ACTION_ROUTE, ADMIN_PAIR_ROUTE, ADMIN_STATUS_ROUTE, CLIENT_CLAIM_ROUTE, CLIENT_CONFIG_ROUTE, CLIENT_RECONNECT_ROUTE, CLIENT_STATUS_ROUTE, claimDeviceNameOf, clientStatusLineOf, deriveClientStatusView, deriveSettingsView, normalizePairingCode, } from '../../client-data/settings-form.ts' import { qrMatrix } from '../../client-data/qr.ts' import { WIZARD_CADDY_START_ROUTE, WIZARD_CADDY_STATUS_ROUTE, WIZARD_CADDY_STOP_ROUTE, WIZARD_CF_TOKEN_ROUTE, WIZARD_STATUS_ROUTE, WIZARD_TUNNEL_CHECK_ROUTE, WIZARD_TUNNEL_SETUP_ROUTE, WIZARD_TUNNEL_STATUS_ROUTE, WIZARD_TUNNEL_STOP_ROUTE, WIZARD_VERIFY_ROUTE, caddyFailureCopy, configureReadiness, deriveClientWizardLights, deriveWizardLights, formSeedOf, initialStep, isValidPort, isWizardConfigured, pairingSecondsLeft, stepAfterPick, tunnelCheckCopy, tunnelFailureCopy, upstreamPortOf, type CaddyState, type TunnelCheckWire, type TunnelSetupWire, type LightState, type WizardPath, type WizardRole, type WizardStatusBody, type WizardStep, type WizardVerifyPoll, type WizardVerifyStart, } from '../../client-data/wizard.ts' import { NS } from '../locales.ts' import { MigrateCard } from './cards/MigrateCard.tsx' import { PortForwardCard } from './cards/PortForwardCard.tsx' import { ProxyCard } from './cards/ProxyCard.tsx' import { TokenCard } from './cards/TokenCard.tsx' export interface GatewayWizardProps extends PropsRuntime<'plugins.bundle.config'>, PropsLocale { /** The row's staged-form controller — saved-role source of truth. */ config: ZenRemoteSettingsForm /** One atomic row write (the wiring stages through configForms.mutate). */ saveRowFields: (fields: Record, options?: { unset?: readonly string[] }) => Promise } type StartFailureKey = 'wizard.startFailedProbe' | 'wizard.startFailedRuntime' | 'wizard.startFailedGateway' | 'wizard.startFailedPair' | 'wizard.startFailedUrl' /** The start failure's locale key + params, from the wire reason. */ function startFailureCopy(result: Extract): { key: StartFailureKey; params: Record } { switch (result.reason) { case 'bad-public-url': case 'no-public-url': return { key: 'wizard.startFailedUrl', params: {} } case 'runtime-not-ready': return { key: 'wizard.startFailedRuntime', params: {} } case 'gateway-unreachable': return { key: 'wizard.startFailedGateway', params: {} } case 'pair-refused': return { key: 'wizard.startFailedPair', params: { detail: String(result.detail ?? '') } } case 'probe-failed': { const detail = result.detail as { kind?: string; root?: string; admin?: string; reason?: string } | undefined const shape = detail === undefined || detail.kind !== 'reached' ? String(detail?.reason ?? 'network') : `root=${detail.root} admin=${detail.admin}` return { key: 'wizard.startFailedProbe', params: { detail: shape } } } } } /** The QR code's SVG path: one rect per dark module, pixel-crisp. */ function qrSvgPath(matrix: boolean[][], scale: number): string { let path = '' matrix.forEach((cells, row) => { cells.forEach((dark, col) => { if (dark) path += `M${col * scale} ${row * scale}h${scale}v${scale}h-${scale}z` }) }) return path } const LIGHT_ORDER: readonly { key: 'wizard.verify.gateway' | 'wizard.verify.public' | 'wizard.verify.pairing'; light: keyof ReturnType }[] = [ { key: 'wizard.verify.gateway', light: 'gateway' }, { key: 'wizard.verify.public', light: 'publicUrl' }, { key: 'wizard.verify.pairing', light: 'pairing' }, ] const CLIENT_LIGHT_ORDER: readonly { key: 'wizard.client.lightCredential' | 'wizard.client.lightConnection' | 'wizard.client.lightSync'; light: keyof ReturnType }[] = [ { key: 'wizard.client.lightCredential', light: 'credential' }, { key: 'wizard.client.lightConnection', light: 'connection' }, { key: 'wizard.client.lightSync', light: 'sync' }, ] const LIGHT_LABEL: Record = { pass: 'wizard.light.pass', pending: 'wizard.light.pending', fail: 'wizard.light.fail', } const STEP_ORDER: readonly WizardStep[] = ['pick', 'configure', 'verify'] const ROLE_OPTIONS = [ ['host', 'wizard.role.host', 'wizard.role.hostDesc'], ['client', 'wizard.role.client', 'wizard.role.clientDesc'], ] as const const PATH_OPTIONS = [ ['caddy', 'wizard.path.caddy', 'wizard.path.caddyDesc'], ['external', 'wizard.path.external', 'wizard.path.externalDesc'], ['cloudflared', 'wizard.path.cloudflared', 'wizard.path.cloudflaredDesc'], ['lan', 'wizard.path.lan', 'wizard.path.lanDesc'], ] as const function clientStatusCopy(view: ClientConnectionView, now: number, t: GatewayWizardProps['t']): 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') } } function claimFailCopy(code: string, retryAfterMs: number | undefined, t: GatewayWizardProps['t']): string { switch (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((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') } } export function GatewayWizard(props: GatewayWizardProps): ReactNode { const { t, config } = props const [modalOpen, setModalOpen] = useState(false) const [devicesModalOpen, setDevicesModalOpen] = useState(false) const [step, setStep] = useState('pick') const [wizardRole, setWizardRole] = useState('host') const [path, setPath] = useState('caddy') const [domain, setDomain] = useState('') const [httpsPort, setHttpsPort] = useState('443') const [cfToken, setCfToken] = useState('') const [serverUrl, setServerUrl] = useState('') const [hostName, setHostName] = useState('') const [autoShare, setAutoShare] = useState(true) const [formError, setFormError] = useState(null) const [savingConfig, setSavingConfig] = useState(false) const [hostSavedInSession, setHostSavedInSession] = useState(false) const [wizardStatus, setWizardStatus] = useState() const [gatewayReachable, setGatewayReachable] = useState(false) const [viaGateway, setViaGateway] = useState(false) // default local; server wall is the real gate const [statusLoaded, setStatusLoaded] = useState(false) const [allDevices, setAllDevices] = useState([]) const [deviceBusy, setDeviceBusy] = useState(false) const [deviceError, setDeviceError] = useState(false) const [renamingDevice, setRenamingDevice] = useState<{ id: string; draft: string } | null>(null) const [pairTarget, setPairTarget] = useState<'web' | 'desktop-client'>('web') const [desktopPairing, setDesktopPairing] = useState<{ code: string; expiresAt: number } | undefined>() const [desktopPairBusy, setDesktopPairBusy] = useState(false) const [desktopCopied, setDesktopCopied] = useState(false) const [startResult, setStartResult] = useState() const [probingUrl, setProbingUrl] = useState(false) const [webPairRequested, setWebPairRequested] = useState(false) const [poll, setPoll] = useState() const [notWired, setNotWired] = useState(false) const [now, setNow] = useState(() => Date.now()) const [card, setCard] = useState<'proxy' | 'token' | 'migrate' | 'portforward' | undefined>() const [tokenNotice, setTokenNotice] = useState<'saved' | 'failed' | undefined>() const [caddyState, setCaddyState] = useState() const [caddyBusy, setCaddyBusy] = useState(false) const [caddyStartError, setCaddyStartError] = useState | undefined>() const [tunnelState, setTunnelState] = useState() const [tunnelBusy, setTunnelBusy] = useState(false) const [tunnelResult, setTunnelResult] = useState() const [tunnelCheck, setTunnelCheck] = useState() const [adoptTunnelId, setAdoptTunnelId] = useState() const [probedClient, setProbedClient] = useState(null) const selectPath = useCallback((nextPath: WizardPath): void => { setFormError(null) setPath((prev) => { if (prev !== nextPath) { setCaddyStartError(undefined) setTunnelResult(undefined) setTunnelCheck(undefined) setAdoptTunnelId(undefined) setStartResult(undefined) setPoll(undefined) setWebPairRequested(false) } return nextPath }) }, []) const webDeviceCount = useMemo(() => allDevices.filter((d) => d.role === 'web').length, [allDevices]) const desktopDevices = useMemo(() => allDevices.filter((d) => d.role === 'desktop-client'), [allDevices]) // Client-mode states const [clientDeviceName, setClientDeviceName] = useState('') const [clientServerUrl, setClientServerUrl] = useState('') const [clientPairCode, setClientPairCode] = useState('') const [clientClaimBusy, setClientClaimBusy] = useState(false) const [clientClaimFail, setClientClaimFail] = useState<{ code: string; retryAfterMs?: number } | null>(null) const [clientView, setClientView] = useState() const [clientLoading, setClientLoading] = useState(false) const [clientReconnectBusy, setClientReconnectBusy] = useState(false) const [clientUnpairBusy, setClientUnpairBusy] = useState(false) // The saved role, live. const roleSnapshot = useSyncExternalStore( useCallback((cb: () => void) => config.subscribe(cb), [config]), () => config.getSnapshot(), ) const savedClient = config.savedRoleIsClient() const effectiveClient = savedClient || (probedClient === true && !config.rowRoleKnown()) // When the row document carries no role at all, probe the lightweight // client-config route (both roles register it). useEffect(() => { if (config.rowRoleKnown()) return let cancelled = false fetch(CLIENT_CONFIG_ROUTE) .then((response) => (response.ok ? response.json() : undefined)) .then((body: { role?: unknown } | undefined) => { if (!cancelled) setProbedClient(body?.role === 'client') }) .catch(() => { if (!cancelled) setProbedClient(false) }) return () => { cancelled = true } }, [config, roleSnapshot]) // Seed role & row-stored fields once when the form snapshot settles. const rowSeededRef = useRef(false) useEffect(() => { if (rowSeededRef.current || roleSnapshot.scopeStatus === 'loading') return if (!config.rowRoleKnown() && probedClient === null) return rowSeededRef.current = true const rowName = typeof config.rowValue('serverName') === 'string' ? (config.rowValue('serverName') as string) : '' const rowAutoShare = config.rowValue('autoShareNewSessions') const rowClientUrl = typeof config.rowValue('serverUrl') === 'string' ? (config.rowValue('serverUrl') as string) : '' if (rowName !== '') { setHostName(rowName) setClientDeviceName(rowName) } if (typeof rowAutoShare === 'boolean') setAutoShare(rowAutoShare) if (rowClientUrl !== '') setClientServerUrl(rowClientUrl) if (effectiveClient) { setWizardRole('client') setStep(roleSnapshot.deviceToken.configured || rowClientUrl !== '' ? 'verify' : 'configure') } else { setWizardRole('host') } }, [roleSnapshot, probedClient, effectiveClient, config]) const loadClientStatus = useCallback(async (): Promise => { setClientLoading(true) try { const response = await fetch(CLIENT_STATUS_ROUTE) if (response.ok) { const body = (await response.json()) as ClientStatusBody setClientView(deriveClientStatusView(body)) } } catch { // keep last client view } finally { setClientLoading(false) } }, []) const hostTicketRef = useRef(0) const loadHostStatus = useCallback(async (): Promise => { const mine = ++hostTicketRef.current try { const [wizardResponse, adminResponse] = await Promise.all([fetch(WIZARD_STATUS_ROUTE), fetch(ADMIN_STATUS_ROUTE)]) if (wizardResponse.ok && mine === hostTicketRef.current) { const body = (await wizardResponse.json()) as WizardStatusBody if (body?.ok === true) setWizardStatus(body) } if (adminResponse.ok && mine === hostTicketRef.current) { const body = (await adminResponse.json()) as AdminStatusBody if (body?.ok === true) { setGatewayReachable(body.gatewayReachable === true) setViaGateway(body.viaGateway === true) const derived = deriveSettingsView(body, { now: Date.now() }) setAllDevices(derived.devices) if (derived.pairing?.role === 'desktop-client' && typeof body.gateway?.pairing?.expiresAt === 'number') { setDesktopPairing({ code: derived.pairing.code, expiresAt: body.gateway.pairing.expiresAt }) } } } if (mine === hostTicketRef.current) setStatusLoaded(true) } catch { // keep the last ready data } }, []) // Poll host routes when on host role, or client status when on client role. useEffect(() => { if (effectiveClient) { void loadClientStatus() const handle = setInterval(() => { void loadClientStatus() }, 5000) return () => clearInterval(handle) } void loadHostStatus() const handle = setInterval(() => { void loadHostStatus() }, 5000) return () => clearInterval(handle) }, [effectiveClient, loadClientStatus, loadHostStatus]) const runDeviceAction = async (payload: Record): Promise => { setDeviceBusy(true) setDeviceError(false) try { const response = await fetch(ADMIN_ACTION_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const body = (await response.json().catch(() => ({}))) as { ok?: boolean } if (response.ok && body.ok === true) { await loadHostStatus() } else { setDeviceError(true) } } catch { setDeviceError(true) } finally { setDeviceBusy(false) } } // Close the wizard modal or devices modal on Escape when no sub-card is open and not mid-save. useEffect(() => { if ((!modalOpen && !devicesModalOpen) || card !== undefined) return const onKey = (event: KeyboardEvent): void => { if (event.key === 'Escape' && !savingConfig && !clientClaimBusy && !deviceBusy) { if (renamingDevice !== null) { setRenamingDevice(null) return } if (devicesModalOpen) { setDevicesModalOpen(false) return } setModalOpen(false) } } document.addEventListener('keydown', onKey) return () => document.removeEventListener('keydown', onKey) }, [modalOpen, devicesModalOpen, card, savingConfig, clientClaimBusy, deviceBusy, renamingDevice]) // Seed the entry step AND the configure forms from the FIRST ready // wizard status on a host deployment. const seededRef = useRef(false) useEffect(() => { if (seededRef.current || wizardStatus === undefined || effectiveClient) return seededRef.current = true setStep(initialStep(wizardStatus)) const seed = formSeedOf(wizardStatus) if (seed.path !== undefined) setPath(seed.path) else if (wizardStatus.publicUrl !== '') setPath('lan') if (seed.domain !== '') setDomain(seed.domain) if (seed.httpsPort !== '') setHttpsPort(seed.httpsPort) if (seed.serverUrl !== '') { setServerUrl(seed.serverUrl) } else if (wizardStatus.mode === 'off' && wizardStatus.lanAddress !== undefined && wizardStatus.lanAddress !== '') { setServerUrl(`http://${wizardStatus.lanAddress}:${upstreamPortOf(wizardStatus)}`) } }, [wizardStatus, effectiveClient]) // Verification polling: only while a web pairing session is active and could still move. const pollVerdict = poll?.result useEffect(() => { const active = webPairRequested && startResult?.ok === true && startResult.pairingCode !== '' && (pollVerdict === undefined || pollVerdict === 'not-started' || pollVerdict === 'pending') if (!active) return let cancelled = false const beat = async (): Promise => { try { const response = await fetch(WIZARD_VERIFY_ROUTE) if (!response.ok || cancelled) return const body = (await response.json()) as WizardVerifyPoll if (body?.result !== undefined && !cancelled) setPoll(body) } catch { // keep the last verdict } } const handle = setInterval(() => { void beat() }, 3000) void beat() return () => { cancelled = true clearInterval(handle) } }, [webPairRequested, startResult, pollVerdict]) // Countdown ticker while a web or desktop pairing code (or client retry) is active. const hasActiveCountdown = (webPairRequested && startResult?.ok === true && startResult.pairingCode !== '') || (desktopPairing !== undefined && desktopPairing.expiresAt > now) || clientView?.nextRetryAt !== undefined useEffect(() => { if (!hasActiveCountdown) return const handle = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(handle) }, [hasActiveCountdown]) const disabled = !effectiveClient && viaGateway const tokenSaved = wizardStatus?.cfTokenConfigured === true || tokenNotice === 'saved' const effectiveCfToken = cfToken.trim() !== '' ? cfToken : (tokenSaved ? '__SAVED__' : '') const readiness = useMemo( () => configureReadiness(path, { domain, httpsPort, cfToken: effectiveCfToken, serverUrl }), [path, domain, httpsPort, effectiveCfToken, serverUrl], ) const resolvedVerifyUrl = useMemo(() => { if (path === 'external' || path === 'lan') { const trimmed = serverUrl.trim() if (trimmed !== '') return trimmed } if (readiness.ok && readiness.publicUrl !== '') return readiness.publicUrl if (wizardStatus?.publicUrl !== undefined && wizardStatus.publicUrl !== '') return wizardStatus.publicUrl if (path === 'lan') { const lanHost = wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== '' ? wizardStatus.lanAddress : '127.0.0.1' return `http://${lanHost}:${upstreamPortOf(wizardStatus)}` } return '' }, [path, serverUrl, readiness, wizardStatus]) const probeAccessUrl = useCallback(async (urlToProbe: string): Promise => { if (urlToProbe === '') return setNotWired(false) setProbingUrl(true) try { const sendProbe = () => fetch(WIZARD_VERIFY_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publicUrl: urlToProbe, probeOnly: true }), }) let response = await sendProbe() // When switching from client to host role, the plugin row reloads and the // gateway child process takes a brief moment to bind its port. Retry once // or twice before surfacing a transient 404 or gateway-unreachable. for (let attempt = 0; attempt < 2; attempt++) { if (response.status === 404) { await new Promise((resolve) => setTimeout(resolve, 900)) response = await sendProbe() continue } const clone = (await response.clone().json().catch(() => undefined)) as WizardVerifyStart | undefined if (clone !== undefined && !clone.ok && clone.reason === 'gateway-unreachable') { await new Promise((resolve) => setTimeout(resolve, 900)) response = await sendProbe() continue } break } if (response.status === 501) { setNotWired(true) return } const body = (await response.json()) as WizardVerifyStart if (body.ok) setGatewayReachable(true) setStartResult((prev) => { // Keep an active web pairing code on screen if one was already requested and probe passed. if (body.ok && prev?.ok === true && prev.pairingCode !== '' && body.pairingCode === '') { return prev } return body }) } catch { setStartResult({ ok: false, reason: 'gateway-unreachable' }) } finally { setProbingUrl(false) } }, []) // Automatically test access URL connectivity when the verify step opens (or when // managed Caddy / Tunnel transitions to running while on the verify step). useEffect(() => { if (!modalOpen || step !== 'verify' || wizardRole !== 'host' || disabled) return if (resolvedVerifyUrl === '') return void probeAccessUrl(resolvedVerifyUrl) }, [modalOpen, step, wizardRole, disabled, resolvedVerifyUrl, caddyState, tunnelState, probeAccessUrl]) const saveTokenValue = async (rawToken: string): Promise => { try { const response = await fetch(WIZARD_CF_TOKEN_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: rawToken }), }) if (response.status === 404 && effectiveClient) { await props.saveRowFields({ role: 'host', autoShareNewSessions: autoShare, ...(hostName.trim() !== '' ? { serverName: hostName.trim() } : {}), }) setProbedClient(false) for (let i = 0; i < 6; i++) { await new Promise((resolve) => setTimeout(resolve, 650)) const retry = await fetch(WIZARD_CF_TOKEN_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: rawToken }), }) if (retry.status !== 404) { const ok = retry.ok setTokenNotice(ok ? 'saved' : 'failed') if (ok) setCfToken('') return ok } } } const ok = response.ok setTokenNotice(ok ? 'saved' : 'failed') if (ok) setCfToken('') return ok } catch { setTokenNotice('failed') return false } } const advanceFromConfigure = async (): Promise => { setFormError(null) if (!readiness.ok) { setFormError(readiness.error) return } setSavingConfig(true) const wasClient = effectiveClient const currentHostSetting = typeof config.rowValue('host') === 'string' ? (config.rowValue('host') as string).trim() : '' const needsLanHostBind = path === 'lan' && currentHostSetting !== '0.0.0.0' && currentHostSetting !== '::' try { try { const baseFields: Record = { role: 'host', autoShareNewSessions: autoShare, ...(hostName.trim() !== '' ? { serverName: hostName.trim() } : {}), } if (path === 'lan') { // LAN / Tailscale direct connections arrive on the machine's LAN or // Tailscale interface, so the gateway child binds to `0.0.0.0` (with // lan-gate's private-IP wall blocking any unproxied public IP). await props.saveRowFields( { ...baseFields, host: '0.0.0.0', ingressMode: 'off', ...(readiness.publicUrl !== '' ? { ingressPublicUrl: readiness.publicUrl } : {}), }, readiness.publicUrl === '' ? { unset: ['ingressPublicUrl'] } : undefined, ) } else if (path === 'external') { await props.saveRowFields({ ...baseFields, ingressMode: 'external', ingressPublicUrl: readiness.publicUrl, }) } else { // Managed Caddy and Cloudflare Tunnel both run on the same host and // connect to the gateway over loopback, so reset `host` back to // `127.0.0.1` in case the user previously enabled LAN mode. await props.saveRowFields({ ...baseFields, host: '127.0.0.1', ingressMode: path, ingressDomain: domain.trim(), ingressHttpsPort: readiness.httpsPort, }) } // If this machine was previously in client mode or just rebound // `host` to `0.0.0.0` for LAN access, wait for the restart-watcher to // reload the plugin row and respawn the local gateway child process // before saving the token or probing. if (wasClient || needsLanHostBind) { setProbedClient(false) if (needsLanHostBind && !wasClient) { // Give the restart-watcher time to observe the `host` change and // restart the gateway child on 0.0.0.0. await new Promise((resolve) => setTimeout(resolve, 900)) } for (let i = 0; i < 6; i++) { await new Promise((resolve) => setTimeout(resolve, 650)) try { const res = await fetch(ADMIN_STATUS_ROUTE) if (res.ok) { const body = (await res.json()) as AdminStatusBody if (body?.ok === true && body.gatewayReachable === true) break } } catch { // keep waiting for gateway startup } } await loadHostStatus() } } catch { // Even if the row mutation is pending mirror sync, proceed to token save / verify. } if ((path === 'caddy' || path === 'cloudflared') && cfToken.trim() !== '') { const saved = await saveTokenValue(cfToken.trim()) if (!saved) return } // Mode-switch process synchronization: stop any managed ingress process // that does not belong to the newly saved path so an old tunnel or // Caddy child never keeps running across a mode change. if (path !== 'caddy') { fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' }).catch(() => {}) setCaddyState('stopped') setCaddyStartError(undefined) } if (path !== 'cloudflared') { fetch(WIZARD_TUNNEL_STOP_ROUTE, { method: 'POST' }).catch(() => {}) setTunnelState('stopped') setTunnelResult(undefined) setTunnelCheck(undefined) setAdoptTunnelId(undefined) } await loadHostStatus() setHostSavedInSession(true) setStep('verify') } finally { setSavingConfig(false) } } const startVerification = async (): Promise => { const publicUrl = resolvedVerifyUrl setNotWired(false) setWebPairRequested(true) setProbingUrl(true) try { const response = await fetch(WIZARD_VERIFY_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publicUrl }), }) if (response.status === 501) { setNotWired(true) return } const body = (await response.json()) as WizardVerifyStart if (body.ok) setGatewayReachable(true) setStartResult(body) setPoll(body.ok ? { result: 'not-started' } : undefined) } catch { setStartResult({ ok: false, reason: 'gateway-unreachable' }) setPoll(undefined) } finally { setProbingUrl(false) } } const runDesktopPair = async (): Promise => { setDesktopPairBusy(true) setDesktopCopied(false) try { const response = await fetch(ADMIN_PAIR_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role: 'desktop-client' }), }) if (response.ok) { const body = (await response.json()) as { ok?: unknown; code?: unknown; expiresAt?: unknown } if (body?.ok === true && typeof body.code === 'string' && typeof body.expiresAt === 'number') { setDesktopPairing({ code: body.code, expiresAt: body.expiresAt }) } } } catch { // ignore network error; user can retry } finally { setDesktopPairBusy(false) } } const resolvedHostEndpoint = useMemo(() => { if (path === 'external' || path === 'lan') { const trimmed = serverUrl.trim() if (trimmed !== '') return trimmed } if (readiness.ok && readiness.publicUrl !== '') return readiness.publicUrl if (wizardStatus?.publicUrl !== undefined && wizardStatus.publicUrl !== '') return wizardStatus.publicUrl const port = upstreamPortOf(wizardStatus) if (wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== '') { return `http://${wizardStatus.lanAddress}:${port}` } return `http://<服务端局域网IP>:${port}` }, [path, serverUrl, readiness, wizardStatus]) const copyDesktopInfo = async (): Promise => { if (desktopPairing === undefined) return const text = `服务端地址:${resolvedHostEndpoint}\n桌面客户端配对码:${desktopPairing.code}` try { await navigator.clipboard?.writeText(text) setDesktopCopied(true) } catch { // ignore clipboard refusal } } const runClientClaim = async (): Promise => { setFormError(null) setClientClaimFail(null) const trimmedUrl = clientServerUrl.trim() const normalizedCode = normalizePairingCode(clientPairCode) if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) { setFormError('bad-client-url') return } if (normalizedCode === '') { setFormError('bad-client-code') return } setClientClaimBusy(true) try { // If the deployment is currently running as 'host', switch role to 'client' // first so the backend restart-watcher reloads the plugin into client mode // and registers CLIENT_CLAIM_ROUTE. if (!effectiveClient) { try { await props.saveRowFields({ role: 'client', ...(clientDeviceName.trim() !== '' ? { serverName: clientDeviceName.trim() } : {}), }) await new Promise((resolve) => setTimeout(resolve, 1600)) } catch { // proceed to claim attempt } } const deviceName = claimDeviceNameOf(clientDeviceName, t('settings.client.deviceNameDefault')) const response = await fetch(CLIENT_CLAIM_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ serverUrl: trimmedUrl, code: normalizedCode, deviceName, }), }) const body = (await response.json()) as ClaimRouteBody if (body?.ok === true && typeof body.token === 'string' && body.token !== '') { const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName await config.writeClientPairing(urlToWrite, body.token, echoed) setClientPairCode('') await loadClientStatus() setStep('verify') } else { const failReason = body && typeof body.code === 'string' ? body.code : 'unexpected' const retryAfterMs = body && typeof body.retryAfterMs === 'number' ? body.retryAfterMs : undefined setClientClaimFail(retryAfterMs === undefined ? { code: failReason } : { code: failReason, retryAfterMs }) } } catch { setClientClaimFail({ code: 'unreachable' }) } finally { setClientClaimBusy(false) } } const runClientReconnect = async (): Promise => { setClientReconnectBusy(true) try { await fetch(CLIENT_RECONNECT_ROUTE, { method: 'POST' }) await loadClientStatus() } catch { // ignore } finally { setClientReconnectBusy(false) } } const runClientUnpair = async (): Promise => { setClientUnpairBusy(true) try { await config.clearDeviceToken() await loadClientStatus() setStep('configure') } catch { // ignore } finally { setClientUnpairBusy(false) } } // Managed-Caddy & Tunnel controls (GW-13a / GW-13b): keyed strictly off the // currently selected `path` so switching away from Tunnel or Caddy never // leaves the previous mode's status bar or notices on screen. const caddyPath = wizardRole === 'host' && path === 'caddy' const tunnelPath = wizardRole === 'host' && path === 'cloudflared' const pollIngressState = async (route: string, apply: (state: CaddyState) => void): Promise => { try { const response = await fetch(route) if (!response.ok) return const body = (await response.json()) as { state?: unknown } if (body?.state === 'running' || body?.state === 'stopped' || body?.state === 'never-started') apply(body.state) } catch { // keep the last state } } useEffect(() => { if (step !== 'verify' || !caddyPath) return const beat = (): Promise => pollIngressState(WIZARD_CADDY_STATUS_ROUTE, setCaddyState) void beat() const handle = setInterval(() => { void beat() }, 3000) return () => clearInterval(handle) }, [step, caddyPath]) useEffect(() => { if (step !== 'verify' || !tunnelPath) return const beat = (): Promise => pollIngressState(WIZARD_TUNNEL_STATUS_ROUTE, setTunnelState) void beat() const handle = setInterval(() => { void beat() }, 3000) return () => clearInterval(handle) }, [step, tunnelPath]) const startCaddy = async (): Promise => { setCaddyStartError(undefined) setCaddyBusy(true) try { const parsedPort = Number(httpsPort.trim()) const response = await fetch(WIZARD_CADDY_START_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: domain.trim(), ...(Number.isSafeInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? { httpsPort: parsedPort } : {}), }), }) const body = (await response.json()) as { ok?: unknown; error?: unknown; detail?: unknown; installUrl?: unknown } if (body?.ok === true) { setCaddyState('running') setTunnelState('stopped') return } if (response.status === 501) { setNotWired(true) return } setCaddyStartError(caddyFailureCopy(String(body?.error ?? ''), typeof body?.detail === 'string' ? body.detail : undefined, typeof body?.installUrl === 'string' ? body.installUrl : undefined)) } catch { setCaddyStartError({ key: 'wizard.caddy.failSpawn', params: {} }) } finally { setCaddyBusy(false) } } const stopCaddy = async (): Promise => { try { await fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' }) setCaddyState('stopped') } catch { // the 3 s poll will correct the label } } // Tunnel path (GW-13b): the check-domain probe (the migration card's // re-check) and the full setup. A conflict carries the same-name list — // each entry offers adoption, which re-runs setup with its tunnelId. const checkDomain = async (): Promise => { const targetDomain = path === 'external' ? serverUrl.trim() : domain.trim() setTunnelCheck(undefined) try { const response = await fetch(WIZARD_TUNNEL_CHECK_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: targetDomain }), }) const body = (await response.json()) as TunnelCheckWire setTunnelCheck(body) } catch { setTunnelCheck({ ok: false, reason: 'network' }) } } const setupTunnelNow = async (tunnelId?: string): Promise => { setTunnelBusy(true) try { const response = await fetch(WIZARD_TUNNEL_SETUP_ROUTE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ domain: domain.trim(), ...(tunnelId !== undefined ? { tunnelId } : {}) }), }) const body = (await response.json()) as TunnelSetupWire setTunnelResult(body) if (body.ok) { setTunnelState('running') setCaddyState('stopped') } } catch { setTunnelResult({ ok: false, reason: 'network' }) } finally { setTunnelBusy(false) } } // Only show on the bundle detail page (never on a component row page). const subject = (props as { subject?: { kind?: string; pkg?: { name?: string } } }).subject if (subject !== undefined && (subject.kind === 'row' || (subject.pkg?.name !== undefined && subject.pkg.name !== 'dsh-zen-remote'))) { return null } const baseHostLights = deriveWizardLights({ gatewayReachable, poll, startResult }) const lights = pairTarget === 'desktop-client' && desktopDevices.length > 0 ? { ...baseHostLights, pairing: 'pass' as const } : baseHostLights const clientLights = deriveClientWizardLights({ tokenConfigured: roleSnapshot.deviceToken.configured || clientView?.state === 'connected', clientState: clientView?.state, interceptInstalled: clientView?.intercept?.installed, }) const verified = poll?.result === 'pass' const qr = startResult?.ok === true ? qrMatrix(startResult.publicUrl) : null const stepLabel = (name: WizardStep): string => t(name === 'pick' ? 'wizard.step.pick' : name === 'configure' ? 'wizard.step.configure' : 'wizard.step.verify') const currentStepIndex = STEP_ORDER.indexOf(step) const pathName = (value: WizardPath): string => t( value === 'caddy' ? 'wizard.path.caddy' : value === 'external' ? 'wizard.path.external' : value === 'cloudflared' ? 'wizard.path.cloudflared' : 'wizard.path.lan', ) const hasSavedHostConfig = hostSavedInSession || config.rowValue('role') === 'host' || (typeof config.rowValue('ingressPublicUrl') === 'string' && (config.rowValue('ingressPublicUrl') as string).trim() !== '') const configured = isWizardConfigured({ effectiveClient, wizardStatus, pairedDeviceCount: webDeviceCount + desktopDevices.length, hasSavedHostConfig, tokenConfigured: roleSnapshot.deviceToken.configured, clientServerUrl, clientState: clientView?.state, }) const statusSummary = (() => { if (effectiveClient) { if (clientView?.state === 'connected') { const label = clientView.serverName !== '' ? clientView.serverName : (clientView.serverUrl || t('wizard.role.client')) return { state: 'pass' as const, text: `${t('wizard.role.client')} · ${label}` } } if (roleSnapshot.deviceToken.configured) { return { state: 'pending' as const, text: t('wizard.role.client') } } return { state: 'idle' as const, text: t('settings.client.statusUnpaired') } } if (wizardStatus === undefined || wizardStatus.mode === 'off') { if (configured && gatewayReachable) { return { state: 'pass' as const, text: t('wizard.path.lan') } } return { state: 'idle' as const, text: t('wizard.off') } } if (wizardStatus.mode === 'caddy') { const running = caddyState === 'running' || (caddyState === undefined && wizardStatus.caddyRunning) return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.caddy.running' : 'wizard.caddy.stopped') } } if (wizardStatus.mode === 'cloudflared') { const running = tunnelState === 'running' || (tunnelState === undefined && wizardStatus.cloudflaredRunning) return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.tunnel.running' : 'wizard.tunnel.stopped') } } return { state: 'pass' as const, text: wizardStatus.publicUrl || t('wizard.path.external') } })() const openWizardAt = (targetStep: WizardStep, options?: { role?: WizardRole; target?: 'web' | 'desktop-client' }): void => { setFormError(null) if (options?.role !== undefined) setWizardRole(options.role) if (options?.target !== undefined) setPairTarget(options.target) setStep(targetStep) setModalOpen(true) } return (

{t('wizard.title')}

{t('wizard.subtitle')}

{statusSummary.text}
{statusLoaded && viaGateway && !effectiveClient ?

{t('wizard.remoteOnly')}

: null} {notWired ?

{t('wizard.notWired')}

: null} {configured ? ( effectiveClient ? ( <>

{t('wizard.overview.roleLabel')}

{t('wizard.role.client')}

{clientDeviceName.trim() !== '' ? clientDeviceName : t('settings.client.deviceNameDefault')}

{t('wizard.overview.clientCredLabel')}

{roleSnapshot.deviceToken.configured ? t('settings.client.tokenSet') : t('settings.client.statusUnpaired')}

{t(LIGHT_LABEL[clientLights.credential])}

{t('wizard.overview.clientConnLabel')}

{clientView === undefined ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired')) : clientStatusCopy(clientView, now, t)}

{t(LIGHT_LABEL[clientLights.connection])}

{t('wizard.overview.clientServerLabel')}

{clientView?.serverUrl || clientServerUrl || '—'}

{t('wizard.client.lightSync')}:{t(LIGHT_LABEL[clientLights.sync])}

{clientView?.nextRetryAt !== undefined ? ( ) : null}
) : ( <>

{t('wizard.overview.roleLabel')}

{t('wizard.role.host')}

{hostName.trim() !== '' ? hostName : t('settings.sourceDefault')}

{t('wizard.overview.gatewayLabel')}

{gatewayReachable ? t('wizard.overview.gatewayReady', { port: String(upstreamPortOf(wizardStatus)) }) : t('wizard.overview.gatewayDown')}

{autoShare ? t('wizard.autoShare') : t('settings.fieldAutoShare')}

{t('wizard.overview.ingressLabel')}

{pathName(path)}

{resolvedHostEndpoint}

{t('wizard.overview.devicesLabel')}

0 ? 'pass' : 'pending'} /> {allDevices.length > 0 ? t('wizard.overview.devicesSummary', { web: String(webDeviceCount), desktop: String(desktopDevices.length) }) : t('wizard.overview.devicesNone')}

{statusSummary.text}

) ) : (

{t('wizard.overview.emptyIntro')}

)} {modalOpen ? (
{ if (!savingConfig && !clientClaimBusy) setModalOpen(false) }} >
event.stopPropagation()} >

{t('wizard.modal.title')}

    {STEP_ORDER.map((name, index) => { const state = currentStepIndex > index ? 'done' : currentStepIndex === index ? 'current' : 'upcoming' return (
  1. {t('wizard.modal.stepTag', { num: String(index + 1) })} {stepLabel(name)}
    {index < STEP_ORDER.length - 1 ? (
    index} aria-hidden="true" /> ) : null}
  2. ) })}
{step === 'pick' ? ( <>

{t('wizard.modal.step1Desc')}

{t('wizard.role.sectionTitle')}

{ROLE_OPTIONS.map(([roleValue, nameKey, descKey]) => { const selected = wizardRole === roleValue return ( ) })}
{wizardRole === 'host' ? ( <>

{t('wizard.ingressSectionTitle')}

{PATH_OPTIONS.map(([value, nameKey, descKey]) => { const selected = path === value return ( ) })}
) : (

{t('wizard.role.clientPickNote')}

)}
{wizardRole === 'host' ? `${t('wizard.role.host')} · ${pathName(path)}` : t('wizard.role.client')}
) : null} {step === 'configure' ? ( <>

{t('wizard.modal.step2Desc')}

{t('wizard.modal.selectedPlan', { plan: wizardRole === 'host' ? `${t('wizard.role.host')} · ${pathName(path)}` : t('wizard.role.client'), })}
{wizardRole === 'host' ? ( <>
setHostName(event.target.value)} />

{t('wizard.hostNameHint')}

{path === 'lan' ? (
setServerUrl(event.target.value)} />

{t('wizard.lanUrlHint')}

) : path === 'external' ? (
setServerUrl(event.target.value)} />

{t('wizard.serverUrlHint')}

) : (
setDomain(event.target.value)} />

{t('wizard.domainHint')}

setHttpsPort(event.target.value)} />
{path === 'caddy' && isValidPort(httpsPort.trim()) && httpsPort.trim() !== '443' ? (
) : null}
{tokenSaved ? {t('wizard.token.saved')} : null}
setCfToken(event.target.value)} />

{t('wizard.cfTokenHint')}

{path === 'cloudflared' ? ( ) : null}
)} {tokenNotice === 'failed' ? (

{t('wizard.token.saveFailed')}

) : null} {formError !== null ? (

{t(formError === 'bad-domain' ? 'wizard.badDomain' : formError === 'bad-port' ? 'wizard.badPort' : formError === 'missing-token' ? 'wizard.missingToken' : 'wizard.badServerUrl')}

) : null} ) : ( <>
setClientDeviceName(event.target.value)} />

{t('wizard.client.deviceNameHint')}

setClientServerUrl(event.target.value)} />

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

setClientPairCode(normalizePairingCode(event.target.value))} />

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

{formError === 'bad-client-url' ? (

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

) : null} {formError === 'bad-client-code' ? (

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

) : null} {clientClaimFail !== null ? (

{claimFailCopy(clientClaimFail.code, clientClaimFail.retryAfterMs, t)}

) : null} )}
{wizardRole === 'host' ? ( ) : (
{roleSnapshot.deviceToken.configured ? ( ) : null}
)}
) : null} {step === 'verify' ? ( <>

{t('wizard.modal.step3Desc')}

{wizardRole === 'host' ? ( <> {caddyPath ? (
{t('wizard.ingress')} {caddyBusy ? t('wizard.caddy.starting') : t(caddyState === 'running' ? 'wizard.caddy.running' : caddyState === 'stopped' ? 'wizard.caddy.stopped' : 'wizard.caddy.neverStarted')}
) : null} {tunnelPath ? (
{t('wizard.ingress')} {t(tunnelState === 'running' ? 'wizard.tunnel.running' : tunnelState === 'stopped' ? 'wizard.tunnel.stopped' : 'wizard.tunnel.neverStarted')}
) : null} {tunnelPath && tunnelCheck !== undefined ? (() => { const copy = tunnelCheckCopy(tunnelCheck) return

{copy.key === 'wizard.check.failed' ? t('wizard.check.failed', { reason: tunnelCheck.ok ? '' : String(tunnelCheck.reason) }) : t(copy.key as Parameters[0])}

})() : null} {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason === 'tunnel-conflict' && tunnelResult.existing !== undefined ? (

{t('wizard.tunnel.conflict')}

{tunnelResult.existing.map((tunnel) => ( ))}
) : null} {tunnelPath && tunnelResult !== undefined && tunnelResult.ok ?

{t('wizard.tunnel.ok')}

: null} {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason !== 'tunnel-conflict' ? (() => { const copy = tunnelFailureCopy(tunnelResult.reason, tunnelResult.detail) return

{t(copy.key as Parameters[0], copy.params)}

})() : null} {caddyPath && caddyStartError !== undefined ? (

{t('wizard.caddy.startFailed', { reason: t(caddyStartError.key as Parameters[0], caddyStartError.params) })} {caddyStartError.installUrl !== undefined ? ( <> {caddyStartError.installUrl} ) : null}

) : null}
    {LIGHT_ORDER.map(({ key, light }) => { const state = lights[light] const labelText = light === 'publicUrl' && probingUrl ? t('wizard.light.probing') : t(LIGHT_LABEL[state]) return (
  • {t(key)}
    {labelText}
  • ) })}
{startResult !== undefined && !startResult.ok ? (() => { const copy = startFailureCopy(startResult) return

{t('wizard.startFailed', { reason: t(copy.key, copy.params) })}

})() : null}
{pairTarget === 'web' ? ( <> {webPairRequested && startResult?.ok === true && startResult.pairingCode !== '' ? (
{qr === null ? null : ( <> )}
{t('wizard.pairingCode')}

{startResult.pairingCode}

{t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(startResult.expiresAt, now)) })}

{t('wizard.qrHint')}

) : null} {poll?.result === 'pass' ?

{t('wizard.verifyPass', { deviceId: poll.deviceId })}

: null} {poll?.result === 'cookie-insecure' ?

{t('wizard.verifyCookieInsecure')}

: null} {poll?.result === 'code-expired' ?

{t('wizard.verifyExpired')}

: null} {poll?.result === 'stale' ?

{t('wizard.verifyStale')}

: null} {webPairRequested && (poll?.result === 'pending' || poll?.result === 'not-started') ?

{t('wizard.verifyPending')}

: null} {!webPairRequested && poll === undefined ?

{t('wizard.verifyNotStarted')}

: null} ) : (

{t('wizard.desktop.guide')}

{t('wizard.desktop.addressLabel')} {resolvedHostEndpoint}
{desktopPairing !== undefined && desktopPairing.expiresAt > now ? (
{t('wizard.pairingCode')}

{desktopPairing.code}

{t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(desktopPairing.expiresAt, now)) })}

) : null}
{desktopPairing !== undefined && desktopPairing.expiresAt > now ? ( ) : null}
)} ) : ( <>
    {CLIENT_LIGHT_ORDER.map(({ key, light }) => (
  • {t(key)}
    {t(LIGHT_LABEL[clientLights[light]])}
  • ))}

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

{clientView === undefined ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired')) : clientStatusCopy(clientView, now, t)}

{clientView?.nextRetryAt !== undefined ? ( ) : null} {roleSnapshot.deviceToken.configured ? ( ) : null}
)}
{wizardRole === 'host' && resolvedVerifyUrl !== '' ? ( ) : null} {wizardRole === 'host' && pairTarget === 'web' ? ( ) : null} {wizardRole === 'host' && pairTarget === 'desktop-client' ? ( ) : null}
) : null}
) : null} {devicesModalOpen ? (
{ if (!deviceBusy) setDevicesModalOpen(false) }} >
event.stopPropagation()} >

{t('wizard.devicesModal.title')}

{t('wizard.devicesModal.subtitle')}

{deviceError ? (

{t('settings.actionFail')}

) : null} {allDevices.length === 0 ? (

{t('settings.devicesEmpty')}

) : (
    {allDevices.map((device) => (
  • {device.name} {device.role === 'web' ? t('settings.deviceRoleWeb') : t('settings.deviceRoleDesktop')} {device.role === 'web' ? ( {device.hasPush ? t('settings.devicePushOn') : t('settings.devicePushOff')} ) : null}
    {t('settings.deviceLastSeen', { time: new Date(device.lastSeen).toLocaleString() })}
    {device.role === 'web' ? ( ) : null}
    {renamingDevice?.id === device.id ? (
    setRenamingDevice({ id: device.id, draft: event.currentTarget.value })} onKeyDown={(event) => { if (event.key === 'Enter') { const trimmed = renamingDevice.draft.trim() setRenamingDevice(null) if (trimmed !== '' && trimmed !== device.name) { void runDeviceAction({ action: 'rename', id: device.id, name: trimmed }) } } if (event.key === 'Escape') setRenamingDevice(null) }} />
    ) : null}
  • ))}
)}
{allDevices.length > 0 ? ( ) : null}
) : null} {card === 'proxy' ? ( { if (domain.trim() !== '') return domain.trim() try { const parsed = new URL(serverUrl.trim()) if (parsed.hostname !== '') return parsed.hostname } catch { /* fall back to placeholder */ } return 'dsh.example.com' })()} upstreamPort={upstreamPortOf(wizardStatus)} onCheck={() => { setStep('verify') void startVerification() }} onClose={() => setCard(undefined)} /> ) : null} {card === 'token' ? ( { void (async () => { await saveTokenValue(token) setCard(undefined) })() }} onClose={() => setCard(undefined)} /> ) : null} {card === 'migrate' ? ( { setCard(undefined) void checkDomain() }} onClose={() => setCard(undefined)} /> ) : null} {card === 'portforward' ? ( setCard(undefined)} /> ) : null}
) }