dsh-zen-remote 2.2.9 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/docs/interface.md +1 -1
  4. package/docs/remote-access.md +20 -0
  5. package/lan-gate.mjs +11 -1
  6. package/lib/adapters/dsh-better-sidebar/index.js +6 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
  9. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  10. package/lib/admin-routes.js +33 -2
  11. package/lib/admin-routes.js.map +1 -1
  12. package/lib/client.js +3293 -6
  13. package/lib/config.js +70 -0
  14. package/lib/config.js.map +1 -1
  15. package/lib/index.js +232 -2
  16. package/lib/index.js.map +1 -1
  17. package/lib/intercept.js +43 -3
  18. package/lib/intercept.js.map +1 -1
  19. package/lib/lan-gate-server.cjs +58 -3
  20. package/lib/merge-streams.js +42 -12
  21. package/lib/merge-streams.js.map +1 -1
  22. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  23. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  24. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  25. package/lib/types/admin-routes.d.ts +13 -0
  26. package/lib/types/admin-routes.d.ts.map +1 -1
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/locales.d.ts +204 -1
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  31. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  32. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  33. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  34. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  35. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  37. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  39. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  41. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  43. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  45. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  46. package/lib/types/client-data/qr.d.ts +20 -0
  47. package/lib/types/client-data/qr.d.ts.map +1 -0
  48. package/lib/types/client-data/settings-form.d.ts +7 -2
  49. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  50. package/lib/types/client-data/wizard.d.ts +247 -0
  51. package/lib/types/client-data/wizard.d.ts.map +1 -0
  52. package/lib/types/config.d.ts +27 -0
  53. package/lib/types/config.d.ts.map +1 -1
  54. package/lib/types/index.d.ts.map +1 -1
  55. package/lib/types/intercept.d.ts.map +1 -1
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/wizard/binary-io.d.ts +111 -0
  58. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  59. package/lib/types/wizard/binary.d.ts +118 -0
  60. package/lib/types/wizard/binary.d.ts.map +1 -0
  61. package/lib/types/wizard/caddy-process.d.ts +101 -0
  62. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  63. package/lib/types/wizard/caddy.d.ts +53 -0
  64. package/lib/types/wizard/caddy.d.ts.map +1 -0
  65. package/lib/types/wizard/cloudflare.d.ts +112 -0
  66. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  67. package/lib/types/wizard/cloudflared.d.ts +71 -0
  68. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  69. package/lib/types/wizard/probe.d.ts +66 -0
  70. package/lib/types/wizard/probe.d.ts.map +1 -0
  71. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  72. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  73. package/lib/types/wizard/routes.d.ts +152 -0
  74. package/lib/types/wizard/routes.d.ts.map +1 -0
  75. package/lib/types/wizard/source-detect.d.ts +53 -0
  76. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  77. package/lib/types/wizard/status.d.ts +115 -0
  78. package/lib/types/wizard/status.d.ts.map +1 -0
  79. package/lib/types/wizard/token-store.d.ts +27 -0
  80. package/lib/types/wizard/token-store.d.ts.map +1 -0
  81. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  82. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  83. package/lib/types/wizard/verify.d.ts +97 -0
  84. package/lib/types/wizard/verify.d.ts.map +1 -0
  85. package/lib/wizard/binary-io.js +386 -0
  86. package/lib/wizard/binary-io.js.map +1 -0
  87. package/lib/wizard/binary.js +138 -0
  88. package/lib/wizard/binary.js.map +1 -0
  89. package/lib/wizard/caddy-process.js +162 -0
  90. package/lib/wizard/caddy-process.js.map +1 -0
  91. package/lib/wizard/caddy.js +80 -0
  92. package/lib/wizard/caddy.js.map +1 -0
  93. package/lib/wizard/cloudflare.js +334 -0
  94. package/lib/wizard/cloudflare.js.map +1 -0
  95. package/lib/wizard/cloudflared.js +158 -0
  96. package/lib/wizard/cloudflared.js.map +1 -0
  97. package/lib/wizard/probe.js +118 -0
  98. package/lib/wizard/probe.js.map +1 -0
  99. package/lib/wizard/proxy-snippets.js +112 -0
  100. package/lib/wizard/proxy-snippets.js.map +1 -0
  101. package/lib/wizard/routes.js +462 -0
  102. package/lib/wizard/routes.js.map +1 -0
  103. package/lib/wizard/source-detect.js +77 -0
  104. package/lib/wizard/source-detect.js.map +1 -0
  105. package/lib/wizard/status.js +94 -0
  106. package/lib/wizard/status.js.map +1 -0
  107. package/lib/wizard/token-store.js +71 -0
  108. package/lib/wizard/token-store.js.map +1 -0
  109. package/lib/wizard/tunnel-orchestrator.js +155 -0
  110. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  111. package/lib/wizard/verify.js +171 -0
  112. package/lib/wizard/verify.js.map +1 -0
  113. package/package.json +37 -37
  114. package/scripts/check-settings-form.mjs +5 -4
  115. package/scripts/check-wizard-ui.mjs +690 -0
  116. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  117. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  118. package/src/admin-routes.ts +44 -2
  119. package/src/client/index.tsx +9 -0
  120. package/src/client/locales.ts +410 -2
  121. package/src/client/wizard/GatewayWizard.tsx +2224 -0
  122. package/src/client/wizard/WizardCard.tsx +81 -0
  123. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  124. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  125. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  126. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  127. package/src/client/wizard/register-wizard.ts +99 -0
  128. package/src/client/wizard/wizard-css.ts +948 -0
  129. package/src/client-data/qr.ts +284 -0
  130. package/src/client-data/settings-form.ts +12 -3
  131. package/src/client-data/wizard.ts +432 -0
  132. package/src/config.ts +87 -0
  133. package/src/index.ts +227 -2
  134. package/src/intercept.ts +43 -3
  135. package/src/merge-streams.ts +41 -12
  136. package/src/wizard/binary-io.ts +449 -0
  137. package/src/wizard/binary.ts +177 -0
  138. package/src/wizard/caddy-process.ts +228 -0
  139. package/src/wizard/caddy.ts +97 -0
  140. package/src/wizard/cloudflare.ts +413 -0
  141. package/src/wizard/cloudflared.ts +196 -0
  142. package/src/wizard/probe.ts +161 -0
  143. package/src/wizard/proxy-snippets.ts +128 -0
  144. package/src/wizard/routes.ts +537 -0
  145. package/src/wizard/source-detect.ts +87 -0
  146. package/src/wizard/status.ts +180 -0
  147. package/src/wizard/token-store.ts +73 -0
  148. package/src/wizard/tunnel-orchestrator.ts +197 -0
  149. package/src/wizard/verify.ts +236 -0
@@ -0,0 +1,2224 @@
1
+ /**
2
+ * GatewayWizard (GW-10): the unified three-step remote-access & device-pairing
3
+ * guide at the top of the plugin detail page (`plugins.bundle.config`,
4
+ * `key: 'dsh-zen-remote'`).
5
+ *
6
+ * Covers both deployment roles in one entry point:
7
+ * - Server (`host`): choose the ingress method (Caddy / Reverse Proxy /
8
+ * Cloudflare Tunnel / LAN & Tailscale), configure parameters + server
9
+ * display name & auto-share, verify connectivity, and pair either a
10
+ * Phone/Browser (QR code) or another Desktop DSH Client (8-character
11
+ * desktop pairing code + live paired-client list).
12
+ * - Desktop Client (`client`): enter the server gateway URL + 8-character
13
+ * desktop pairing code to claim a device token, and verify live
14
+ * connection & session-sync status.
15
+ */
16
+
17
+ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
18
+ import type { ReactNode } from 'react'
19
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
20
+ import type {
21
+ AdminStatusBody,
22
+ ClaimRouteBody,
23
+ ClientConnectionView,
24
+ ClientStatusBody,
25
+ SettingsDeviceView,
26
+ ZenRemoteSettingsForm,
27
+ } from '../../client-data/settings-form.ts'
28
+ import {
29
+ ADMIN_ACTION_ROUTE,
30
+ ADMIN_PAIR_ROUTE,
31
+ ADMIN_STATUS_ROUTE,
32
+ CLIENT_CLAIM_ROUTE,
33
+ CLIENT_CONFIG_ROUTE,
34
+ CLIENT_RECONNECT_ROUTE,
35
+ CLIENT_STATUS_ROUTE,
36
+ claimDeviceNameOf,
37
+ clientStatusLineOf,
38
+ deriveClientStatusView,
39
+ deriveSettingsView,
40
+ normalizePairingCode,
41
+ } from '../../client-data/settings-form.ts'
42
+ import { qrMatrix } from '../../client-data/qr.ts'
43
+ import {
44
+ WIZARD_CADDY_START_ROUTE,
45
+ WIZARD_CADDY_STATUS_ROUTE,
46
+ WIZARD_CADDY_STOP_ROUTE,
47
+ WIZARD_CF_TOKEN_ROUTE,
48
+ WIZARD_STATUS_ROUTE,
49
+ WIZARD_TUNNEL_CHECK_ROUTE,
50
+ WIZARD_TUNNEL_SETUP_ROUTE,
51
+ WIZARD_TUNNEL_STATUS_ROUTE,
52
+ WIZARD_TUNNEL_STOP_ROUTE,
53
+ WIZARD_VERIFY_ROUTE,
54
+ caddyFailureCopy,
55
+ configureReadiness,
56
+ deriveClientWizardLights,
57
+ deriveWizardLights,
58
+ formSeedOf,
59
+ initialStep,
60
+ isValidPort,
61
+ isWizardConfigured,
62
+ pairingSecondsLeft,
63
+ stepAfterPick,
64
+ tunnelCheckCopy,
65
+ tunnelFailureCopy,
66
+ upstreamPortOf,
67
+ type CaddyState,
68
+ type TunnelCheckWire,
69
+ type TunnelSetupWire,
70
+ type LightState,
71
+ type WizardPath,
72
+ type WizardRole,
73
+ type WizardStatusBody,
74
+ type WizardStep,
75
+ type WizardVerifyPoll,
76
+ type WizardVerifyStart,
77
+ } from '../../client-data/wizard.ts'
78
+ import { NS } from '../locales.ts'
79
+ import { MigrateCard } from './cards/MigrateCard.tsx'
80
+ import { PortForwardCard } from './cards/PortForwardCard.tsx'
81
+ import { ProxyCard } from './cards/ProxyCard.tsx'
82
+ import { TokenCard } from './cards/TokenCard.tsx'
83
+
84
+ export interface GatewayWizardProps extends PropsRuntime<'plugins.bundle.config'>, PropsLocale<typeof NS> {
85
+ /** The row's staged-form controller — saved-role source of truth. */
86
+ config: ZenRemoteSettingsForm
87
+ /** One atomic row write (the wiring stages through configForms.mutate). */
88
+ saveRowFields: (fields: Record<string, unknown>, options?: { unset?: readonly string[] }) => Promise<void>
89
+ }
90
+
91
+ type StartFailureKey = 'wizard.startFailedProbe' | 'wizard.startFailedRuntime' | 'wizard.startFailedGateway' | 'wizard.startFailedPair' | 'wizard.startFailedUrl'
92
+
93
+ /** The start failure's locale key + params, from the wire reason. */
94
+ function startFailureCopy(result: Extract<WizardVerifyStart, { ok: false }>): { key: StartFailureKey; params: Record<string, string> } {
95
+ switch (result.reason) {
96
+ case 'bad-public-url':
97
+ case 'no-public-url':
98
+ return { key: 'wizard.startFailedUrl', params: {} }
99
+ case 'runtime-not-ready':
100
+ return { key: 'wizard.startFailedRuntime', params: {} }
101
+ case 'gateway-unreachable':
102
+ return { key: 'wizard.startFailedGateway', params: {} }
103
+ case 'pair-refused':
104
+ return { key: 'wizard.startFailedPair', params: { detail: String(result.detail ?? '') } }
105
+ case 'probe-failed': {
106
+ const detail = result.detail as { kind?: string; root?: string; admin?: string; reason?: string } | undefined
107
+ const shape = detail === undefined || detail.kind !== 'reached'
108
+ ? String(detail?.reason ?? 'network')
109
+ : `root=${detail.root} admin=${detail.admin}`
110
+ return { key: 'wizard.startFailedProbe', params: { detail: shape } }
111
+ }
112
+ }
113
+ }
114
+
115
+ /** The QR code's SVG path: one rect per dark module, pixel-crisp. */
116
+ function qrSvgPath(matrix: boolean[][], scale: number): string {
117
+ let path = ''
118
+ matrix.forEach((cells, row) => {
119
+ cells.forEach((dark, col) => {
120
+ if (dark) path += `M${col * scale} ${row * scale}h${scale}v${scale}h-${scale}z`
121
+ })
122
+ })
123
+ return path
124
+ }
125
+
126
+ const LIGHT_ORDER: readonly { key: 'wizard.verify.gateway' | 'wizard.verify.public' | 'wizard.verify.pairing'; light: keyof ReturnType<typeof deriveWizardLights> }[] = [
127
+ { key: 'wizard.verify.gateway', light: 'gateway' },
128
+ { key: 'wizard.verify.public', light: 'publicUrl' },
129
+ { key: 'wizard.verify.pairing', light: 'pairing' },
130
+ ]
131
+
132
+ const CLIENT_LIGHT_ORDER: readonly { key: 'wizard.client.lightCredential' | 'wizard.client.lightConnection' | 'wizard.client.lightSync'; light: keyof ReturnType<typeof deriveClientWizardLights> }[] = [
133
+ { key: 'wizard.client.lightCredential', light: 'credential' },
134
+ { key: 'wizard.client.lightConnection', light: 'connection' },
135
+ { key: 'wizard.client.lightSync', light: 'sync' },
136
+ ]
137
+
138
+ const LIGHT_LABEL: Record<LightState, 'wizard.light.pass' | 'wizard.light.pending' | 'wizard.light.fail'> = {
139
+ pass: 'wizard.light.pass',
140
+ pending: 'wizard.light.pending',
141
+ fail: 'wizard.light.fail',
142
+ }
143
+
144
+ const STEP_ORDER: readonly WizardStep[] = ['pick', 'configure', 'verify']
145
+
146
+ const ROLE_OPTIONS = [
147
+ ['host', 'wizard.role.host', 'wizard.role.hostDesc'],
148
+ ['client', 'wizard.role.client', 'wizard.role.clientDesc'],
149
+ ] as const
150
+
151
+ const PATH_OPTIONS = [
152
+ ['caddy', 'wizard.path.caddy', 'wizard.path.caddyDesc'],
153
+ ['external', 'wizard.path.external', 'wizard.path.externalDesc'],
154
+ ['cloudflared', 'wizard.path.cloudflared', 'wizard.path.cloudflaredDesc'],
155
+ ['lan', 'wizard.path.lan', 'wizard.path.lanDesc'],
156
+ ] as const
157
+
158
+ function clientStatusCopy(view: ClientConnectionView, now: number, t: GatewayWizardProps['t']): string {
159
+ const line = clientStatusLineOf(view, now)
160
+ switch (line.kind) {
161
+ case 'connectedName': return t('settings.client.statusConnectedName', { serverName: line.serverName })
162
+ case 'connected': return t('settings.client.statusConnected', { serverUrl: line.serverUrl })
163
+ case 'offlineRetry': return t('settings.client.statusOfflineRetry', { seconds: line.seconds })
164
+ case 'offlineRetrySoon': return t('settings.client.statusOfflineRetrySoon')
165
+ case 'unreachable': return t('settings.client.statusUnreachable')
166
+ case 'revoked': return t('settings.client.statusRevoked')
167
+ case 'incompatible': return t('settings.client.statusIncompatible')
168
+ case 'unexpected': return t('settings.client.statusUnexpected')
169
+ case 'invalidUrl': return t('settings.client.statusInvalidUrl')
170
+ default: return t('settings.client.statusUnpaired')
171
+ }
172
+ }
173
+
174
+ function claimFailCopy(code: string, retryAfterMs: number | undefined, t: GatewayWizardProps['t']): string {
175
+ switch (code) {
176
+ case 'invalid': return t('settings.client.failInvalid')
177
+ case 'insecure-http': return t('settings.client.failInsecureHttp')
178
+ case 'bad-code': return t('settings.client.failBadCode')
179
+ case 'locked': {
180
+ const minutes = Math.max(1, Math.ceil((retryAfterMs ?? 0) / 60000))
181
+ return t('settings.client.failLocked', { minutes })
182
+ }
183
+ case 'unreachable': return t('settings.client.failUnreachable')
184
+ case 'role-mismatch': return t('settings.client.failRoleMismatch')
185
+ default: return t('settings.client.failUnexpected')
186
+ }
187
+ }
188
+
189
+ export function GatewayWizard(props: GatewayWizardProps): ReactNode {
190
+ const { t, config } = props
191
+
192
+ const [modalOpen, setModalOpen] = useState(false)
193
+ const [devicesModalOpen, setDevicesModalOpen] = useState(false)
194
+ const [step, setStep] = useState<WizardStep>('pick')
195
+ const [wizardRole, setWizardRole] = useState<WizardRole>('host')
196
+ const [path, setPath] = useState<WizardPath>('caddy')
197
+ const [domain, setDomain] = useState('')
198
+ const [httpsPort, setHttpsPort] = useState('443')
199
+ const [cfToken, setCfToken] = useState('')
200
+ const [serverUrl, setServerUrl] = useState('')
201
+ const [hostName, setHostName] = useState('')
202
+ const [autoShare, setAutoShare] = useState(true)
203
+ const [formError, setFormError] = useState<string | null>(null)
204
+ const [savingConfig, setSavingConfig] = useState(false)
205
+ const [hostSavedInSession, setHostSavedInSession] = useState(false)
206
+ const [wizardStatus, setWizardStatus] = useState<WizardStatusBody | undefined>()
207
+ const [gatewayReachable, setGatewayReachable] = useState(false)
208
+ const [viaGateway, setViaGateway] = useState(false) // default local; server wall is the real gate
209
+ const [statusLoaded, setStatusLoaded] = useState(false)
210
+ const [allDevices, setAllDevices] = useState<readonly SettingsDeviceView[]>([])
211
+ const [deviceBusy, setDeviceBusy] = useState(false)
212
+ const [deviceError, setDeviceError] = useState(false)
213
+ const [renamingDevice, setRenamingDevice] = useState<{ id: string; draft: string } | null>(null)
214
+ const [pairTarget, setPairTarget] = useState<'web' | 'desktop-client'>('web')
215
+ const [desktopPairing, setDesktopPairing] = useState<{ code: string; expiresAt: number } | undefined>()
216
+ const [desktopPairBusy, setDesktopPairBusy] = useState(false)
217
+ const [desktopCopied, setDesktopCopied] = useState(false)
218
+ const [startResult, setStartResult] = useState<WizardVerifyStart | undefined>()
219
+ const [probingUrl, setProbingUrl] = useState(false)
220
+ const [webPairRequested, setWebPairRequested] = useState(false)
221
+ const [poll, setPoll] = useState<WizardVerifyPoll | undefined>()
222
+ const [notWired, setNotWired] = useState(false)
223
+ const [now, setNow] = useState(() => Date.now())
224
+ const [card, setCard] = useState<'proxy' | 'token' | 'migrate' | 'portforward' | undefined>()
225
+ const [tokenNotice, setTokenNotice] = useState<'saved' | 'failed' | undefined>()
226
+ const [caddyState, setCaddyState] = useState<CaddyState | undefined>()
227
+ const [caddyBusy, setCaddyBusy] = useState(false)
228
+ const [caddyStartError, setCaddyStartError] = useState<ReturnType<typeof caddyFailureCopy> | undefined>()
229
+ const [tunnelState, setTunnelState] = useState<CaddyState | undefined>()
230
+ const [tunnelBusy, setTunnelBusy] = useState(false)
231
+ const [tunnelResult, setTunnelResult] = useState<TunnelSetupWire | undefined>()
232
+ const [tunnelCheck, setTunnelCheck] = useState<TunnelCheckWire | undefined>()
233
+ const [adoptTunnelId, setAdoptTunnelId] = useState<string | undefined>()
234
+ const [probedClient, setProbedClient] = useState<boolean | null>(null)
235
+
236
+ const selectPath = useCallback((nextPath: WizardPath): void => {
237
+ setFormError(null)
238
+ setPath((prev) => {
239
+ if (prev !== nextPath) {
240
+ setCaddyStartError(undefined)
241
+ setTunnelResult(undefined)
242
+ setTunnelCheck(undefined)
243
+ setAdoptTunnelId(undefined)
244
+ setStartResult(undefined)
245
+ setPoll(undefined)
246
+ setWebPairRequested(false)
247
+ }
248
+ return nextPath
249
+ })
250
+ }, [])
251
+
252
+ const webDeviceCount = useMemo(() => allDevices.filter((d) => d.role === 'web').length, [allDevices])
253
+ const desktopDevices = useMemo(() => allDevices.filter((d) => d.role === 'desktop-client'), [allDevices])
254
+
255
+ // Client-mode states
256
+ const [clientDeviceName, setClientDeviceName] = useState('')
257
+ const [clientServerUrl, setClientServerUrl] = useState('')
258
+ const [clientPairCode, setClientPairCode] = useState('')
259
+ const [clientClaimBusy, setClientClaimBusy] = useState(false)
260
+ const [clientClaimFail, setClientClaimFail] = useState<{ code: string; retryAfterMs?: number } | null>(null)
261
+ const [clientView, setClientView] = useState<ClientConnectionView | undefined>()
262
+ const [clientLoading, setClientLoading] = useState(false)
263
+ const [clientReconnectBusy, setClientReconnectBusy] = useState(false)
264
+ const [clientUnpairBusy, setClientUnpairBusy] = useState(false)
265
+
266
+ // The saved role, live.
267
+ const roleSnapshot = useSyncExternalStore(
268
+ useCallback((cb: () => void) => config.subscribe(cb), [config]),
269
+ () => config.getSnapshot(),
270
+ )
271
+ const savedClient = config.savedRoleIsClient()
272
+ const effectiveClient = savedClient || (probedClient === true && !config.rowRoleKnown())
273
+
274
+ // When the row document carries no role at all, probe the lightweight
275
+ // client-config route (both roles register it).
276
+ useEffect(() => {
277
+ if (config.rowRoleKnown()) return
278
+ let cancelled = false
279
+ fetch(CLIENT_CONFIG_ROUTE)
280
+ .then((response) => (response.ok ? response.json() : undefined))
281
+ .then((body: { role?: unknown } | undefined) => {
282
+ if (!cancelled) setProbedClient(body?.role === 'client')
283
+ })
284
+ .catch(() => {
285
+ if (!cancelled) setProbedClient(false)
286
+ })
287
+ return () => { cancelled = true }
288
+ }, [config, roleSnapshot])
289
+
290
+ // Seed role & row-stored fields once when the form snapshot settles.
291
+ const rowSeededRef = useRef(false)
292
+ useEffect(() => {
293
+ if (rowSeededRef.current || roleSnapshot.scopeStatus === 'loading') return
294
+ if (!config.rowRoleKnown() && probedClient === null) return
295
+ rowSeededRef.current = true
296
+ const rowName = typeof config.rowValue('serverName') === 'string' ? (config.rowValue('serverName') as string) : ''
297
+ const rowAutoShare = config.rowValue('autoShareNewSessions')
298
+ const rowClientUrl = typeof config.rowValue('serverUrl') === 'string' ? (config.rowValue('serverUrl') as string) : ''
299
+ if (rowName !== '') {
300
+ setHostName(rowName)
301
+ setClientDeviceName(rowName)
302
+ }
303
+ if (typeof rowAutoShare === 'boolean') setAutoShare(rowAutoShare)
304
+ if (rowClientUrl !== '') setClientServerUrl(rowClientUrl)
305
+ if (effectiveClient) {
306
+ setWizardRole('client')
307
+ setStep(roleSnapshot.deviceToken.configured || rowClientUrl !== '' ? 'verify' : 'configure')
308
+ } else {
309
+ setWizardRole('host')
310
+ }
311
+ }, [roleSnapshot, probedClient, effectiveClient, config])
312
+
313
+ const loadClientStatus = useCallback(async (): Promise<void> => {
314
+ setClientLoading(true)
315
+ try {
316
+ const response = await fetch(CLIENT_STATUS_ROUTE)
317
+ if (response.ok) {
318
+ const body = (await response.json()) as ClientStatusBody
319
+ setClientView(deriveClientStatusView(body))
320
+ }
321
+ } catch {
322
+ // keep last client view
323
+ } finally {
324
+ setClientLoading(false)
325
+ }
326
+ }, [])
327
+
328
+ const hostTicketRef = useRef(0)
329
+ const loadHostStatus = useCallback(async (): Promise<void> => {
330
+ const mine = ++hostTicketRef.current
331
+ try {
332
+ const [wizardResponse, adminResponse] = await Promise.all([fetch(WIZARD_STATUS_ROUTE), fetch(ADMIN_STATUS_ROUTE)])
333
+ if (wizardResponse.ok && mine === hostTicketRef.current) {
334
+ const body = (await wizardResponse.json()) as WizardStatusBody
335
+ if (body?.ok === true) setWizardStatus(body)
336
+ }
337
+ if (adminResponse.ok && mine === hostTicketRef.current) {
338
+ const body = (await adminResponse.json()) as AdminStatusBody
339
+ if (body?.ok === true) {
340
+ setGatewayReachable(body.gatewayReachable === true)
341
+ setViaGateway(body.viaGateway === true)
342
+ const derived = deriveSettingsView(body, { now: Date.now() })
343
+ setAllDevices(derived.devices)
344
+ if (derived.pairing?.role === 'desktop-client' && typeof body.gateway?.pairing?.expiresAt === 'number') {
345
+ setDesktopPairing({ code: derived.pairing.code, expiresAt: body.gateway.pairing.expiresAt })
346
+ }
347
+ }
348
+ }
349
+ if (mine === hostTicketRef.current) setStatusLoaded(true)
350
+ } catch {
351
+ // keep the last ready data
352
+ }
353
+ }, [])
354
+
355
+ // Poll host routes when on host role, or client status when on client role.
356
+ useEffect(() => {
357
+ if (effectiveClient) {
358
+ void loadClientStatus()
359
+ const handle = setInterval(() => { void loadClientStatus() }, 5000)
360
+ return () => clearInterval(handle)
361
+ }
362
+ void loadHostStatus()
363
+ const handle = setInterval(() => { void loadHostStatus() }, 5000)
364
+ return () => clearInterval(handle)
365
+ }, [effectiveClient, loadClientStatus, loadHostStatus])
366
+
367
+ const runDeviceAction = async (payload: Record<string, unknown>): Promise<void> => {
368
+ setDeviceBusy(true)
369
+ setDeviceError(false)
370
+ try {
371
+ const response = await fetch(ADMIN_ACTION_ROUTE, {
372
+ method: 'POST',
373
+ headers: { 'Content-Type': 'application/json' },
374
+ body: JSON.stringify(payload),
375
+ })
376
+ const body = (await response.json().catch(() => ({}))) as { ok?: boolean }
377
+ if (response.ok && body.ok === true) {
378
+ await loadHostStatus()
379
+ } else {
380
+ setDeviceError(true)
381
+ }
382
+ } catch {
383
+ setDeviceError(true)
384
+ } finally {
385
+ setDeviceBusy(false)
386
+ }
387
+ }
388
+
389
+ // Close the wizard modal or devices modal on Escape when no sub-card is open and not mid-save.
390
+ useEffect(() => {
391
+ if ((!modalOpen && !devicesModalOpen) || card !== undefined) return
392
+ const onKey = (event: KeyboardEvent): void => {
393
+ if (event.key === 'Escape' && !savingConfig && !clientClaimBusy && !deviceBusy) {
394
+ if (renamingDevice !== null) {
395
+ setRenamingDevice(null)
396
+ return
397
+ }
398
+ if (devicesModalOpen) {
399
+ setDevicesModalOpen(false)
400
+ return
401
+ }
402
+ setModalOpen(false)
403
+ }
404
+ }
405
+ document.addEventListener('keydown', onKey)
406
+ return () => document.removeEventListener('keydown', onKey)
407
+ }, [modalOpen, devicesModalOpen, card, savingConfig, clientClaimBusy, deviceBusy, renamingDevice])
408
+
409
+ // Seed the entry step AND the configure forms from the FIRST ready
410
+ // wizard status on a host deployment.
411
+ const seededRef = useRef(false)
412
+ useEffect(() => {
413
+ if (seededRef.current || wizardStatus === undefined || effectiveClient) return
414
+ seededRef.current = true
415
+ setStep(initialStep(wizardStatus))
416
+ const seed = formSeedOf(wizardStatus)
417
+ if (seed.path !== undefined) setPath(seed.path)
418
+ else if (wizardStatus.publicUrl !== '') setPath('lan')
419
+ if (seed.domain !== '') setDomain(seed.domain)
420
+ if (seed.httpsPort !== '') setHttpsPort(seed.httpsPort)
421
+ if (seed.serverUrl !== '') {
422
+ setServerUrl(seed.serverUrl)
423
+ } else if (wizardStatus.mode === 'off' && wizardStatus.lanAddress !== undefined && wizardStatus.lanAddress !== '') {
424
+ setServerUrl(`http://${wizardStatus.lanAddress}:${upstreamPortOf(wizardStatus)}`)
425
+ }
426
+ }, [wizardStatus, effectiveClient])
427
+
428
+ // Verification polling: only while a web pairing session is active and could still move.
429
+ const pollVerdict = poll?.result
430
+ useEffect(() => {
431
+ const active = webPairRequested
432
+ && startResult?.ok === true
433
+ && startResult.pairingCode !== ''
434
+ && (pollVerdict === undefined || pollVerdict === 'not-started' || pollVerdict === 'pending')
435
+ if (!active) return
436
+ let cancelled = false
437
+ const beat = async (): Promise<void> => {
438
+ try {
439
+ const response = await fetch(WIZARD_VERIFY_ROUTE)
440
+ if (!response.ok || cancelled) return
441
+ const body = (await response.json()) as WizardVerifyPoll
442
+ if (body?.result !== undefined && !cancelled) setPoll(body)
443
+ } catch {
444
+ // keep the last verdict
445
+ }
446
+ }
447
+ const handle = setInterval(() => { void beat() }, 3000)
448
+ void beat()
449
+ return () => {
450
+ cancelled = true
451
+ clearInterval(handle)
452
+ }
453
+ }, [webPairRequested, startResult, pollVerdict])
454
+
455
+ // Countdown ticker while a web or desktop pairing code (or client retry) is active.
456
+ const hasActiveCountdown = (webPairRequested && startResult?.ok === true && startResult.pairingCode !== '')
457
+ || (desktopPairing !== undefined && desktopPairing.expiresAt > now)
458
+ || clientView?.nextRetryAt !== undefined
459
+ useEffect(() => {
460
+ if (!hasActiveCountdown) return
461
+ const handle = setInterval(() => setNow(Date.now()), 1000)
462
+ return () => clearInterval(handle)
463
+ }, [hasActiveCountdown])
464
+
465
+ const disabled = !effectiveClient && viaGateway
466
+ const tokenSaved = wizardStatus?.cfTokenConfigured === true || tokenNotice === 'saved'
467
+ const effectiveCfToken = cfToken.trim() !== '' ? cfToken : (tokenSaved ? '__SAVED__' : '')
468
+ const readiness = useMemo(
469
+ () => configureReadiness(path, { domain, httpsPort, cfToken: effectiveCfToken, serverUrl }),
470
+ [path, domain, httpsPort, effectiveCfToken, serverUrl],
471
+ )
472
+
473
+ const resolvedVerifyUrl = useMemo(() => {
474
+ if (path === 'external' || path === 'lan') {
475
+ const trimmed = serverUrl.trim()
476
+ if (trimmed !== '') return trimmed
477
+ }
478
+ if (readiness.ok && readiness.publicUrl !== '') return readiness.publicUrl
479
+ if (wizardStatus?.publicUrl !== undefined && wizardStatus.publicUrl !== '') return wizardStatus.publicUrl
480
+ if (path === 'lan') {
481
+ const lanHost = wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== ''
482
+ ? wizardStatus.lanAddress
483
+ : '127.0.0.1'
484
+ return `http://${lanHost}:${upstreamPortOf(wizardStatus)}`
485
+ }
486
+ return ''
487
+ }, [path, serverUrl, readiness, wizardStatus])
488
+
489
+ const probeAccessUrl = useCallback(async (urlToProbe: string): Promise<void> => {
490
+ if (urlToProbe === '') return
491
+ setNotWired(false)
492
+ setProbingUrl(true)
493
+ try {
494
+ const sendProbe = () => fetch(WIZARD_VERIFY_ROUTE, {
495
+ method: 'POST',
496
+ headers: { 'Content-Type': 'application/json' },
497
+ body: JSON.stringify({ publicUrl: urlToProbe, probeOnly: true }),
498
+ })
499
+ let response = await sendProbe()
500
+ // When switching from client to host role, the plugin row reloads and the
501
+ // gateway child process takes a brief moment to bind its port. Retry once
502
+ // or twice before surfacing a transient 404 or gateway-unreachable.
503
+ for (let attempt = 0; attempt < 2; attempt++) {
504
+ if (response.status === 404) {
505
+ await new Promise((resolve) => setTimeout(resolve, 900))
506
+ response = await sendProbe()
507
+ continue
508
+ }
509
+ const clone = (await response.clone().json().catch(() => undefined)) as WizardVerifyStart | undefined
510
+ if (clone !== undefined && !clone.ok && clone.reason === 'gateway-unreachable') {
511
+ await new Promise((resolve) => setTimeout(resolve, 900))
512
+ response = await sendProbe()
513
+ continue
514
+ }
515
+ break
516
+ }
517
+ if (response.status === 501) {
518
+ setNotWired(true)
519
+ return
520
+ }
521
+ const body = (await response.json()) as WizardVerifyStart
522
+ if (body.ok) setGatewayReachable(true)
523
+ setStartResult((prev) => {
524
+ // Keep an active web pairing code on screen if one was already requested and probe passed.
525
+ if (body.ok && prev?.ok === true && prev.pairingCode !== '' && body.pairingCode === '') {
526
+ return prev
527
+ }
528
+ return body
529
+ })
530
+ } catch {
531
+ setStartResult({ ok: false, reason: 'gateway-unreachable' })
532
+ } finally {
533
+ setProbingUrl(false)
534
+ }
535
+ }, [])
536
+
537
+ // Automatically test access URL connectivity when the verify step opens (or when
538
+ // managed Caddy / Tunnel transitions to running while on the verify step).
539
+ useEffect(() => {
540
+ if (!modalOpen || step !== 'verify' || wizardRole !== 'host' || disabled) return
541
+ if (resolvedVerifyUrl === '') return
542
+ void probeAccessUrl(resolvedVerifyUrl)
543
+ }, [modalOpen, step, wizardRole, disabled, resolvedVerifyUrl, caddyState, tunnelState, probeAccessUrl])
544
+
545
+ const saveTokenValue = async (rawToken: string): Promise<boolean> => {
546
+ try {
547
+ const response = await fetch(WIZARD_CF_TOKEN_ROUTE, {
548
+ method: 'POST',
549
+ headers: { 'Content-Type': 'application/json' },
550
+ body: JSON.stringify({ token: rawToken }),
551
+ })
552
+ if (response.status === 404 && effectiveClient) {
553
+ await props.saveRowFields({
554
+ role: 'host',
555
+ autoShareNewSessions: autoShare,
556
+ ...(hostName.trim() !== '' ? { serverName: hostName.trim() } : {}),
557
+ })
558
+ setProbedClient(false)
559
+ for (let i = 0; i < 6; i++) {
560
+ await new Promise((resolve) => setTimeout(resolve, 650))
561
+ const retry = await fetch(WIZARD_CF_TOKEN_ROUTE, {
562
+ method: 'POST',
563
+ headers: { 'Content-Type': 'application/json' },
564
+ body: JSON.stringify({ token: rawToken }),
565
+ })
566
+ if (retry.status !== 404) {
567
+ const ok = retry.ok
568
+ setTokenNotice(ok ? 'saved' : 'failed')
569
+ if (ok) setCfToken('')
570
+ return ok
571
+ }
572
+ }
573
+ }
574
+ const ok = response.ok
575
+ setTokenNotice(ok ? 'saved' : 'failed')
576
+ if (ok) setCfToken('')
577
+ return ok
578
+ } catch {
579
+ setTokenNotice('failed')
580
+ return false
581
+ }
582
+ }
583
+
584
+ const advanceFromConfigure = async (): Promise<void> => {
585
+ setFormError(null)
586
+ if (!readiness.ok) {
587
+ setFormError(readiness.error)
588
+ return
589
+ }
590
+ setSavingConfig(true)
591
+ const wasClient = effectiveClient
592
+ const currentHostSetting = typeof config.rowValue('host') === 'string' ? (config.rowValue('host') as string).trim() : ''
593
+ const needsLanHostBind = path === 'lan' && currentHostSetting !== '0.0.0.0' && currentHostSetting !== '::'
594
+ try {
595
+ try {
596
+ const baseFields: Record<string, unknown> = {
597
+ role: 'host',
598
+ autoShareNewSessions: autoShare,
599
+ ...(hostName.trim() !== '' ? { serverName: hostName.trim() } : {}),
600
+ }
601
+ if (path === 'lan') {
602
+ // LAN / Tailscale direct connections arrive on the machine's LAN or
603
+ // Tailscale interface, so the gateway child binds to `0.0.0.0` (with
604
+ // lan-gate's private-IP wall blocking any unproxied public IP).
605
+ await props.saveRowFields(
606
+ {
607
+ ...baseFields,
608
+ host: '0.0.0.0',
609
+ ingressMode: 'off',
610
+ ...(readiness.publicUrl !== '' ? { ingressPublicUrl: readiness.publicUrl } : {}),
611
+ },
612
+ readiness.publicUrl === '' ? { unset: ['ingressPublicUrl'] } : undefined,
613
+ )
614
+ } else if (path === 'external') {
615
+ await props.saveRowFields({
616
+ ...baseFields,
617
+ ingressMode: 'external',
618
+ ingressPublicUrl: readiness.publicUrl,
619
+ })
620
+ } else {
621
+ // Managed Caddy and Cloudflare Tunnel both run on the same host and
622
+ // connect to the gateway over loopback, so reset `host` back to
623
+ // `127.0.0.1` in case the user previously enabled LAN mode.
624
+ await props.saveRowFields({
625
+ ...baseFields,
626
+ host: '127.0.0.1',
627
+ ingressMode: path,
628
+ ingressDomain: domain.trim(),
629
+ ingressHttpsPort: readiness.httpsPort,
630
+ })
631
+ }
632
+ // If this machine was previously in client mode or just rebound
633
+ // `host` to `0.0.0.0` for LAN access, wait for the restart-watcher to
634
+ // reload the plugin row and respawn the local gateway child process
635
+ // before saving the token or probing.
636
+ if (wasClient || needsLanHostBind) {
637
+ setProbedClient(false)
638
+ if (needsLanHostBind && !wasClient) {
639
+ // Give the restart-watcher time to observe the `host` change and
640
+ // restart the gateway child on 0.0.0.0.
641
+ await new Promise((resolve) => setTimeout(resolve, 900))
642
+ }
643
+ for (let i = 0; i < 6; i++) {
644
+ await new Promise((resolve) => setTimeout(resolve, 650))
645
+ try {
646
+ const res = await fetch(ADMIN_STATUS_ROUTE)
647
+ if (res.ok) {
648
+ const body = (await res.json()) as AdminStatusBody
649
+ if (body?.ok === true && body.gatewayReachable === true) break
650
+ }
651
+ } catch {
652
+ // keep waiting for gateway startup
653
+ }
654
+ }
655
+ await loadHostStatus()
656
+ }
657
+ } catch {
658
+ // Even if the row mutation is pending mirror sync, proceed to token save / verify.
659
+ }
660
+ if ((path === 'caddy' || path === 'cloudflared') && cfToken.trim() !== '') {
661
+ const saved = await saveTokenValue(cfToken.trim())
662
+ if (!saved) return
663
+ }
664
+ // Mode-switch process synchronization: stop any managed ingress process
665
+ // that does not belong to the newly saved path so an old tunnel or
666
+ // Caddy child never keeps running across a mode change.
667
+ if (path !== 'caddy') {
668
+ fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' }).catch(() => {})
669
+ setCaddyState('stopped')
670
+ setCaddyStartError(undefined)
671
+ }
672
+ if (path !== 'cloudflared') {
673
+ fetch(WIZARD_TUNNEL_STOP_ROUTE, { method: 'POST' }).catch(() => {})
674
+ setTunnelState('stopped')
675
+ setTunnelResult(undefined)
676
+ setTunnelCheck(undefined)
677
+ setAdoptTunnelId(undefined)
678
+ }
679
+ await loadHostStatus()
680
+ setHostSavedInSession(true)
681
+ setStep('verify')
682
+ } finally {
683
+ setSavingConfig(false)
684
+ }
685
+ }
686
+
687
+ const startVerification = async (): Promise<void> => {
688
+ const publicUrl = resolvedVerifyUrl
689
+ setNotWired(false)
690
+ setWebPairRequested(true)
691
+ setProbingUrl(true)
692
+ try {
693
+ const response = await fetch(WIZARD_VERIFY_ROUTE, {
694
+ method: 'POST',
695
+ headers: { 'Content-Type': 'application/json' },
696
+ body: JSON.stringify({ publicUrl }),
697
+ })
698
+ if (response.status === 501) {
699
+ setNotWired(true)
700
+ return
701
+ }
702
+ const body = (await response.json()) as WizardVerifyStart
703
+ if (body.ok) setGatewayReachable(true)
704
+ setStartResult(body)
705
+ setPoll(body.ok ? { result: 'not-started' } : undefined)
706
+ } catch {
707
+ setStartResult({ ok: false, reason: 'gateway-unreachable' })
708
+ setPoll(undefined)
709
+ } finally {
710
+ setProbingUrl(false)
711
+ }
712
+ }
713
+
714
+ const runDesktopPair = async (): Promise<void> => {
715
+ setDesktopPairBusy(true)
716
+ setDesktopCopied(false)
717
+ try {
718
+ const response = await fetch(ADMIN_PAIR_ROUTE, {
719
+ method: 'POST',
720
+ headers: { 'Content-Type': 'application/json' },
721
+ body: JSON.stringify({ role: 'desktop-client' }),
722
+ })
723
+ if (response.ok) {
724
+ const body = (await response.json()) as { ok?: unknown; code?: unknown; expiresAt?: unknown }
725
+ if (body?.ok === true && typeof body.code === 'string' && typeof body.expiresAt === 'number') {
726
+ setDesktopPairing({ code: body.code, expiresAt: body.expiresAt })
727
+ }
728
+ }
729
+ } catch {
730
+ // ignore network error; user can retry
731
+ } finally {
732
+ setDesktopPairBusy(false)
733
+ }
734
+ }
735
+
736
+ const resolvedHostEndpoint = useMemo(() => {
737
+ if (path === 'external' || path === 'lan') {
738
+ const trimmed = serverUrl.trim()
739
+ if (trimmed !== '') return trimmed
740
+ }
741
+ if (readiness.ok && readiness.publicUrl !== '') return readiness.publicUrl
742
+ if (wizardStatus?.publicUrl !== undefined && wizardStatus.publicUrl !== '') return wizardStatus.publicUrl
743
+ const port = upstreamPortOf(wizardStatus)
744
+ if (wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== '') {
745
+ return `http://${wizardStatus.lanAddress}:${port}`
746
+ }
747
+ return `http://<服务端局域网IP>:${port}`
748
+ }, [path, serverUrl, readiness, wizardStatus])
749
+
750
+ const copyDesktopInfo = async (): Promise<void> => {
751
+ if (desktopPairing === undefined) return
752
+ const text = `服务端地址:${resolvedHostEndpoint}\n桌面客户端配对码:${desktopPairing.code}`
753
+ try {
754
+ await navigator.clipboard?.writeText(text)
755
+ setDesktopCopied(true)
756
+ } catch {
757
+ // ignore clipboard refusal
758
+ }
759
+ }
760
+
761
+ const runClientClaim = async (): Promise<void> => {
762
+ setFormError(null)
763
+ setClientClaimFail(null)
764
+ const trimmedUrl = clientServerUrl.trim()
765
+ const normalizedCode = normalizePairingCode(clientPairCode)
766
+ if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
767
+ setFormError('bad-client-url')
768
+ return
769
+ }
770
+ if (normalizedCode === '') {
771
+ setFormError('bad-client-code')
772
+ return
773
+ }
774
+ setClientClaimBusy(true)
775
+ try {
776
+ // If the deployment is currently running as 'host', switch role to 'client'
777
+ // first so the backend restart-watcher reloads the plugin into client mode
778
+ // and registers CLIENT_CLAIM_ROUTE.
779
+ if (!effectiveClient) {
780
+ try {
781
+ await props.saveRowFields({
782
+ role: 'client',
783
+ ...(clientDeviceName.trim() !== '' ? { serverName: clientDeviceName.trim() } : {}),
784
+ })
785
+ await new Promise((resolve) => setTimeout(resolve, 1600))
786
+ } catch {
787
+ // proceed to claim attempt
788
+ }
789
+ }
790
+ const deviceName = claimDeviceNameOf(clientDeviceName, t('settings.client.deviceNameDefault'))
791
+ const response = await fetch(CLIENT_CLAIM_ROUTE, {
792
+ method: 'POST',
793
+ headers: { 'Content-Type': 'application/json' },
794
+ body: JSON.stringify({
795
+ serverUrl: trimmedUrl,
796
+ code: normalizedCode,
797
+ deviceName,
798
+ }),
799
+ })
800
+ const body = (await response.json()) as ClaimRouteBody
801
+ if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
802
+ const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
803
+ const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
804
+ await config.writeClientPairing(urlToWrite, body.token, echoed)
805
+ setClientPairCode('')
806
+ await loadClientStatus()
807
+ setStep('verify')
808
+ } else {
809
+ const failReason = body && typeof body.code === 'string' ? body.code : 'unexpected'
810
+ const retryAfterMs = body && typeof body.retryAfterMs === 'number' ? body.retryAfterMs : undefined
811
+ setClientClaimFail(retryAfterMs === undefined ? { code: failReason } : { code: failReason, retryAfterMs })
812
+ }
813
+ } catch {
814
+ setClientClaimFail({ code: 'unreachable' })
815
+ } finally {
816
+ setClientClaimBusy(false)
817
+ }
818
+ }
819
+
820
+ const runClientReconnect = async (): Promise<void> => {
821
+ setClientReconnectBusy(true)
822
+ try {
823
+ await fetch(CLIENT_RECONNECT_ROUTE, { method: 'POST' })
824
+ await loadClientStatus()
825
+ } catch {
826
+ // ignore
827
+ } finally {
828
+ setClientReconnectBusy(false)
829
+ }
830
+ }
831
+
832
+ const runClientUnpair = async (): Promise<void> => {
833
+ setClientUnpairBusy(true)
834
+ try {
835
+ await config.clearDeviceToken()
836
+ await loadClientStatus()
837
+ setStep('configure')
838
+ } catch {
839
+ // ignore
840
+ } finally {
841
+ setClientUnpairBusy(false)
842
+ }
843
+ }
844
+
845
+ // Managed-Caddy & Tunnel controls (GW-13a / GW-13b): keyed strictly off the
846
+ // currently selected `path` so switching away from Tunnel or Caddy never
847
+ // leaves the previous mode's status bar or notices on screen.
848
+ const caddyPath = wizardRole === 'host' && path === 'caddy'
849
+ const tunnelPath = wizardRole === 'host' && path === 'cloudflared'
850
+ const pollIngressState = async (route: string, apply: (state: CaddyState) => void): Promise<void> => {
851
+ try {
852
+ const response = await fetch(route)
853
+ if (!response.ok) return
854
+ const body = (await response.json()) as { state?: unknown }
855
+ if (body?.state === 'running' || body?.state === 'stopped' || body?.state === 'never-started') apply(body.state)
856
+ } catch {
857
+ // keep the last state
858
+ }
859
+ }
860
+ useEffect(() => {
861
+ if (step !== 'verify' || !caddyPath) return
862
+ const beat = (): Promise<void> => pollIngressState(WIZARD_CADDY_STATUS_ROUTE, setCaddyState)
863
+ void beat()
864
+ const handle = setInterval(() => { void beat() }, 3000)
865
+ return () => clearInterval(handle)
866
+ }, [step, caddyPath])
867
+ useEffect(() => {
868
+ if (step !== 'verify' || !tunnelPath) return
869
+ const beat = (): Promise<void> => pollIngressState(WIZARD_TUNNEL_STATUS_ROUTE, setTunnelState)
870
+ void beat()
871
+ const handle = setInterval(() => { void beat() }, 3000)
872
+ return () => clearInterval(handle)
873
+ }, [step, tunnelPath])
874
+
875
+ const startCaddy = async (): Promise<void> => {
876
+ setCaddyStartError(undefined)
877
+ setCaddyBusy(true)
878
+ try {
879
+ const parsedPort = Number(httpsPort.trim())
880
+ const response = await fetch(WIZARD_CADDY_START_ROUTE, {
881
+ method: 'POST',
882
+ headers: { 'Content-Type': 'application/json' },
883
+ body: JSON.stringify({
884
+ domain: domain.trim(),
885
+ ...(Number.isSafeInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? { httpsPort: parsedPort } : {}),
886
+ }),
887
+ })
888
+ const body = (await response.json()) as { ok?: unknown; error?: unknown; detail?: unknown; installUrl?: unknown }
889
+ if (body?.ok === true) {
890
+ setCaddyState('running')
891
+ setTunnelState('stopped')
892
+ return
893
+ }
894
+ if (response.status === 501) {
895
+ setNotWired(true)
896
+ return
897
+ }
898
+ setCaddyStartError(caddyFailureCopy(String(body?.error ?? ''), typeof body?.detail === 'string' ? body.detail : undefined, typeof body?.installUrl === 'string' ? body.installUrl : undefined))
899
+ } catch {
900
+ setCaddyStartError({ key: 'wizard.caddy.failSpawn', params: {} })
901
+ } finally {
902
+ setCaddyBusy(false)
903
+ }
904
+ }
905
+
906
+ const stopCaddy = async (): Promise<void> => {
907
+ try {
908
+ await fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' })
909
+ setCaddyState('stopped')
910
+ } catch {
911
+ // the 3 s poll will correct the label
912
+ }
913
+ }
914
+
915
+ // Tunnel path (GW-13b): the check-domain probe (the migration card's
916
+ // re-check) and the full setup. A conflict carries the same-name list —
917
+ // each entry offers adoption, which re-runs setup with its tunnelId.
918
+ const checkDomain = async (): Promise<void> => {
919
+ const targetDomain = path === 'external' ? serverUrl.trim() : domain.trim()
920
+ setTunnelCheck(undefined)
921
+ try {
922
+ const response = await fetch(WIZARD_TUNNEL_CHECK_ROUTE, {
923
+ method: 'POST',
924
+ headers: { 'Content-Type': 'application/json' },
925
+ body: JSON.stringify({ domain: targetDomain }),
926
+ })
927
+ const body = (await response.json()) as TunnelCheckWire
928
+ setTunnelCheck(body)
929
+ } catch {
930
+ setTunnelCheck({ ok: false, reason: 'network' })
931
+ }
932
+ }
933
+
934
+ const setupTunnelNow = async (tunnelId?: string): Promise<void> => {
935
+ setTunnelBusy(true)
936
+ try {
937
+ const response = await fetch(WIZARD_TUNNEL_SETUP_ROUTE, {
938
+ method: 'POST',
939
+ headers: { 'Content-Type': 'application/json' },
940
+ body: JSON.stringify({ domain: domain.trim(), ...(tunnelId !== undefined ? { tunnelId } : {}) }),
941
+ })
942
+ const body = (await response.json()) as TunnelSetupWire
943
+ setTunnelResult(body)
944
+ if (body.ok) {
945
+ setTunnelState('running')
946
+ setCaddyState('stopped')
947
+ }
948
+ } catch {
949
+ setTunnelResult({ ok: false, reason: 'network' })
950
+ } finally {
951
+ setTunnelBusy(false)
952
+ }
953
+ }
954
+
955
+ // Only show on the bundle detail page (never on a component row page).
956
+ const subject = (props as { subject?: { kind?: string; pkg?: { name?: string } } }).subject
957
+ if (subject !== undefined && (subject.kind === 'row' || (subject.pkg?.name !== undefined && subject.pkg.name !== 'dsh-zen-remote'))) {
958
+ return null
959
+ }
960
+
961
+ const baseHostLights = deriveWizardLights({ gatewayReachable, poll, startResult })
962
+ const lights = pairTarget === 'desktop-client' && desktopDevices.length > 0
963
+ ? { ...baseHostLights, pairing: 'pass' as const }
964
+ : baseHostLights
965
+ const clientLights = deriveClientWizardLights({
966
+ tokenConfigured: roleSnapshot.deviceToken.configured || clientView?.state === 'connected',
967
+ clientState: clientView?.state,
968
+ interceptInstalled: clientView?.intercept?.installed,
969
+ })
970
+ const verified = poll?.result === 'pass'
971
+ const qr = startResult?.ok === true ? qrMatrix(startResult.publicUrl) : null
972
+
973
+ const stepLabel = (name: WizardStep): string => t(name === 'pick' ? 'wizard.step.pick' : name === 'configure' ? 'wizard.step.configure' : 'wizard.step.verify')
974
+ const currentStepIndex = STEP_ORDER.indexOf(step)
975
+ const pathName = (value: WizardPath): string => t(
976
+ value === 'caddy'
977
+ ? 'wizard.path.caddy'
978
+ : value === 'external'
979
+ ? 'wizard.path.external'
980
+ : value === 'cloudflared'
981
+ ? 'wizard.path.cloudflared'
982
+ : 'wizard.path.lan',
983
+ )
984
+
985
+ const hasSavedHostConfig = hostSavedInSession
986
+ || config.rowValue('role') === 'host'
987
+ || (typeof config.rowValue('ingressPublicUrl') === 'string' && (config.rowValue('ingressPublicUrl') as string).trim() !== '')
988
+ const configured = isWizardConfigured({
989
+ effectiveClient,
990
+ wizardStatus,
991
+ pairedDeviceCount: webDeviceCount + desktopDevices.length,
992
+ hasSavedHostConfig,
993
+ tokenConfigured: roleSnapshot.deviceToken.configured,
994
+ clientServerUrl,
995
+ clientState: clientView?.state,
996
+ })
997
+
998
+ const statusSummary = (() => {
999
+ if (effectiveClient) {
1000
+ if (clientView?.state === 'connected') {
1001
+ const label = clientView.serverName !== '' ? clientView.serverName : (clientView.serverUrl || t('wizard.role.client'))
1002
+ return { state: 'pass' as const, text: `${t('wizard.role.client')} · ${label}` }
1003
+ }
1004
+ if (roleSnapshot.deviceToken.configured) {
1005
+ return { state: 'pending' as const, text: t('wizard.role.client') }
1006
+ }
1007
+ return { state: 'idle' as const, text: t('settings.client.statusUnpaired') }
1008
+ }
1009
+ if (wizardStatus === undefined || wizardStatus.mode === 'off') {
1010
+ if (configured && gatewayReachable) {
1011
+ return { state: 'pass' as const, text: t('wizard.path.lan') }
1012
+ }
1013
+ return { state: 'idle' as const, text: t('wizard.off') }
1014
+ }
1015
+ if (wizardStatus.mode === 'caddy') {
1016
+ const running = caddyState === 'running' || (caddyState === undefined && wizardStatus.caddyRunning)
1017
+ return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.caddy.running' : 'wizard.caddy.stopped') }
1018
+ }
1019
+ if (wizardStatus.mode === 'cloudflared') {
1020
+ const running = tunnelState === 'running' || (tunnelState === undefined && wizardStatus.cloudflaredRunning)
1021
+ return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.tunnel.running' : 'wizard.tunnel.stopped') }
1022
+ }
1023
+ return { state: 'pass' as const, text: wizardStatus.publicUrl || t('wizard.path.external') }
1024
+ })()
1025
+
1026
+ const openWizardAt = (targetStep: WizardStep, options?: { role?: WizardRole; target?: 'web' | 'desktop-client' }): void => {
1027
+ setFormError(null)
1028
+ if (options?.role !== undefined) setWizardRole(options.role)
1029
+ if (options?.target !== undefined) setPairTarget(options.target)
1030
+ setStep(targetStep)
1031
+ setModalOpen(true)
1032
+ }
1033
+
1034
+ return (
1035
+ <div data-zen-remote="wizard">
1036
+ <div className="zr-wizard-header">
1037
+ <div className="zr-wizard-headerText">
1038
+ <p className="zr-wizard-title">{t('wizard.title')}</p>
1039
+ <p className="zr-wizard-subtitle">{t('wizard.subtitle')}</p>
1040
+ </div>
1041
+ <div className="zr-wizard-statusBadge" data-state={statusSummary.state}>
1042
+ <span className="zr-wizard-dot" data-state={statusSummary.state === 'pass' ? 'pass' : 'pending'} />
1043
+ <span>{statusSummary.text}</span>
1044
+ </div>
1045
+ </div>
1046
+
1047
+ {statusLoaded && viaGateway && !effectiveClient ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.remoteOnly')}</p> : null}
1048
+ {notWired ? <p className="zr-wizard-notice">{t('wizard.notWired')}</p> : null}
1049
+
1050
+ {configured ? (
1051
+ effectiveClient ? (
1052
+ <>
1053
+ <div className="zr-wizard-overviewGrid">
1054
+ <div className="zr-wizard-overviewItem">
1055
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.roleLabel')}</p>
1056
+ <p className="zr-wizard-overviewValue">{t('wizard.role.client')}</p>
1057
+ <p className="zr-wizard-overviewSub">{clientDeviceName.trim() !== '' ? clientDeviceName : t('settings.client.deviceNameDefault')}</p>
1058
+ </div>
1059
+ <div className="zr-wizard-overviewItem">
1060
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientCredLabel')}</p>
1061
+ <p className="zr-wizard-overviewValue">
1062
+ <span className="zr-wizard-dot" data-state={clientLights.credential} />
1063
+ <span>{roleSnapshot.deviceToken.configured ? t('settings.client.tokenSet') : t('settings.client.statusUnpaired')}</span>
1064
+ </p>
1065
+ <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.credential])}</p>
1066
+ </div>
1067
+ <div className="zr-wizard-overviewItem">
1068
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientConnLabel')}</p>
1069
+ <p className="zr-wizard-overviewValue">
1070
+ <span className="zr-wizard-dot" data-state={clientLights.connection} />
1071
+ <span>
1072
+ {clientView === undefined
1073
+ ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1074
+ : clientStatusCopy(clientView, now, t)}
1075
+ </span>
1076
+ </p>
1077
+ <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.connection])}</p>
1078
+ </div>
1079
+ <div className="zr-wizard-overviewItem">
1080
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientServerLabel')}</p>
1081
+ <p className="zr-wizard-overviewValue">
1082
+ <span className="zr-wizard-dot" data-state={clientLights.sync} />
1083
+ <span>{clientView?.serverUrl || clientServerUrl || '—'}</span>
1084
+ </p>
1085
+ <p className="zr-wizard-overviewSub">{t('wizard.client.lightSync')}:{t(LIGHT_LABEL[clientLights.sync])}</p>
1086
+ </div>
1087
+ </div>
1088
+
1089
+ <div className="zr-wizard-footer">
1090
+ <div className="zr-wizard-row">
1091
+ <button
1092
+ type="button"
1093
+ className="zr-wizard-btn"
1094
+ disabled={clientLoading}
1095
+ onClick={() => { void loadClientStatus() }}
1096
+ >
1097
+ {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1098
+ </button>
1099
+ {clientView?.nextRetryAt !== undefined ? (
1100
+ <button
1101
+ type="button"
1102
+ className="zr-wizard-btn"
1103
+ disabled={clientReconnectBusy}
1104
+ onClick={() => { void runClientReconnect() }}
1105
+ >
1106
+ {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1107
+ </button>
1108
+ ) : null}
1109
+ </div>
1110
+ <div className="zr-wizard-row">
1111
+ <button
1112
+ type="button"
1113
+ className="zr-wizard-btn"
1114
+ onClick={() => openWizardAt('pick', { role: 'client' })}
1115
+ >
1116
+ {t('wizard.overview.reconfigure')}
1117
+ </button>
1118
+ <button
1119
+ type="button"
1120
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1121
+ onClick={() => openWizardAt(roleSnapshot.deviceToken.configured ? 'verify' : 'configure', { role: 'client' })}
1122
+ >
1123
+ {t('wizard.overview.clientDetail')}
1124
+ </button>
1125
+ </div>
1126
+ </div>
1127
+ </>
1128
+ ) : (
1129
+ <>
1130
+ <div className="zr-wizard-overviewGrid">
1131
+ <div className="zr-wizard-overviewItem">
1132
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.roleLabel')}</p>
1133
+ <p className="zr-wizard-overviewValue">{t('wizard.role.host')}</p>
1134
+ <p className="zr-wizard-overviewSub">{hostName.trim() !== '' ? hostName : t('settings.sourceDefault')}</p>
1135
+ </div>
1136
+ <div className="zr-wizard-overviewItem">
1137
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.gatewayLabel')}</p>
1138
+ <p className="zr-wizard-overviewValue">
1139
+ <span className="zr-wizard-dot" data-state={gatewayReachable ? 'pass' : 'fail'} />
1140
+ <span>
1141
+ {gatewayReachable
1142
+ ? t('wizard.overview.gatewayReady', { port: String(upstreamPortOf(wizardStatus)) })
1143
+ : t('wizard.overview.gatewayDown')}
1144
+ </span>
1145
+ </p>
1146
+ <p className="zr-wizard-overviewSub">{autoShare ? t('wizard.autoShare') : t('settings.fieldAutoShare')}</p>
1147
+ </div>
1148
+ <div className="zr-wizard-overviewItem">
1149
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.ingressLabel')}</p>
1150
+ <p className="zr-wizard-overviewValue">
1151
+ <span className="zr-wizard-dot" data-state={statusSummary.state === 'pass' ? 'pass' : 'pending'} />
1152
+ <span>{pathName(path)}</span>
1153
+ </p>
1154
+ <p className="zr-wizard-overviewSub">{resolvedHostEndpoint}</p>
1155
+ </div>
1156
+ <div className="zr-wizard-overviewItem">
1157
+ <div className="zr-wizard-overviewHead">
1158
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.devicesLabel')}</p>
1159
+ <button
1160
+ type="button"
1161
+ className="zr-wizard-linkBtn"
1162
+ disabled={disabled}
1163
+ onClick={() => {
1164
+ setDeviceError(false)
1165
+ setRenamingDevice(null)
1166
+ setDevicesModalOpen(true)
1167
+ }}
1168
+ >
1169
+ {t('wizard.overview.manageDevices')}
1170
+ </button>
1171
+ </div>
1172
+ <p className="zr-wizard-overviewValue">
1173
+ <span className="zr-wizard-dot" data-state={allDevices.length > 0 ? 'pass' : 'pending'} />
1174
+ <span>
1175
+ {allDevices.length > 0
1176
+ ? t('wizard.overview.devicesSummary', { web: String(webDeviceCount), desktop: String(desktopDevices.length) })
1177
+ : t('wizard.overview.devicesNone')}
1178
+ </span>
1179
+ </p>
1180
+ <p className="zr-wizard-overviewSub">{statusSummary.text}</p>
1181
+ </div>
1182
+ </div>
1183
+
1184
+ <div className="zr-wizard-footer">
1185
+ <div className="zr-wizard-row">
1186
+ <button
1187
+ type="button"
1188
+ className="zr-wizard-btn"
1189
+ disabled={disabled}
1190
+ onClick={() => openWizardAt('pick', { role: 'host' })}
1191
+ >
1192
+ {t('wizard.overview.reconfigure')}
1193
+ </button>
1194
+ <button
1195
+ type="button"
1196
+ className="zr-wizard-btn"
1197
+ disabled={disabled}
1198
+ onClick={() => {
1199
+ setDeviceError(false)
1200
+ setRenamingDevice(null)
1201
+ setDevicesModalOpen(true)
1202
+ }}
1203
+ >
1204
+ {t('wizard.overview.manageDevices')}
1205
+ </button>
1206
+ </div>
1207
+ <div className="zr-wizard-row">
1208
+ <button
1209
+ type="button"
1210
+ className="zr-wizard-btn"
1211
+ disabled={disabled}
1212
+ onClick={() => openWizardAt('verify', { role: 'host', target: 'desktop-client' })}
1213
+ >
1214
+ {t('wizard.overview.pairDesktop')}
1215
+ </button>
1216
+ <button
1217
+ type="button"
1218
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1219
+ disabled={disabled}
1220
+ onClick={() => openWizardAt('verify', { role: 'host', target: 'web' })}
1221
+ >
1222
+ {t('wizard.overview.pairPhone')}
1223
+ </button>
1224
+ </div>
1225
+ </div>
1226
+ </>
1227
+ )
1228
+ ) : (
1229
+ <div className="zr-wizard-emptyBanner">
1230
+ <p className="zr-wizard-emptyText">{t('wizard.overview.emptyIntro')}</p>
1231
+ <button
1232
+ type="button"
1233
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1234
+ disabled={disabled}
1235
+ onClick={() => openWizardAt('pick')}
1236
+ >
1237
+ {t('wizard.overview.startSetup')}
1238
+ </button>
1239
+ </div>
1240
+ )}
1241
+
1242
+ {modalOpen ? (
1243
+ <div
1244
+ data-zen-remote="wizard-modal"
1245
+ onClick={() => {
1246
+ if (!savingConfig && !clientClaimBusy) setModalOpen(false)
1247
+ }}
1248
+ >
1249
+ <div
1250
+ className="zr-wizard-modalDialog"
1251
+ role="dialog"
1252
+ aria-modal="true"
1253
+ aria-label={t('wizard.modal.title')}
1254
+ onClick={(event) => event.stopPropagation()}
1255
+ >
1256
+ <div className="zr-wizard-modalHeader">
1257
+ <p className="zr-wizard-modalTitle">{t('wizard.modal.title')}</p>
1258
+ <button
1259
+ type="button"
1260
+ className="zr-wizard-cardCloseIcon"
1261
+ aria-label={t('wizard.card.close')}
1262
+ disabled={savingConfig || clientClaimBusy}
1263
+ onClick={() => setModalOpen(false)}
1264
+ >
1265
+ ×
1266
+ </button>
1267
+ </div>
1268
+
1269
+ <ol className="zr-wizard-stepper" aria-label={t('wizard.modal.title')}>
1270
+ {STEP_ORDER.map((name, index) => {
1271
+ const state = currentStepIndex > index ? 'done' : currentStepIndex === index ? 'current' : 'upcoming'
1272
+ return (
1273
+ <li key={name} style={{ display: 'contents' }}>
1274
+ <div className="zr-wizard-stepItem" data-state={state} aria-current={state === 'current' ? 'step' : undefined}>
1275
+ <span className="zr-wizard-stepCircle" aria-hidden="true">
1276
+ {state === 'done' ? (
1277
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
1278
+ <path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
1279
+ </svg>
1280
+ ) : (
1281
+ index + 1
1282
+ )}
1283
+ </span>
1284
+ <div className="zr-wizard-stepText">
1285
+ <span className="zr-wizard-stepTag">{t('wizard.modal.stepTag', { num: String(index + 1) })}</span>
1286
+ <span className="zr-wizard-stepName">{stepLabel(name)}</span>
1287
+ </div>
1288
+ </div>
1289
+ {index < STEP_ORDER.length - 1 ? (
1290
+ <div className="zr-wizard-stepConnector" data-done={currentStepIndex > index} aria-hidden="true" />
1291
+ ) : null}
1292
+ </li>
1293
+ )
1294
+ })}
1295
+ </ol>
1296
+
1297
+ {step === 'pick' ? (
1298
+ <>
1299
+ <div className="zr-wizard-modalBody">
1300
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step1Desc')}</p>
1301
+ <p className="zr-wizard-sectionTitle">{t('wizard.role.sectionTitle')}</p>
1302
+ <div className="zr-wizard-roleGrid" role="radiogroup" aria-label={t('wizard.role.sectionTitle')}>
1303
+ {ROLE_OPTIONS.map(([roleValue, nameKey, descKey]) => {
1304
+ const selected = wizardRole === roleValue
1305
+ return (
1306
+ <button
1307
+ key={roleValue}
1308
+ type="button"
1309
+ role="radio"
1310
+ aria-checked={selected}
1311
+ className="zr-wizard-path"
1312
+ data-selected={selected}
1313
+ disabled={disabled}
1314
+ onClick={() => {
1315
+ setFormError(null)
1316
+ setWizardRole(roleValue)
1317
+ }}
1318
+ >
1319
+ <div className="zr-wizard-pathHead">
1320
+ <span className="zr-wizard-pathRadio" data-selected={selected} aria-hidden="true" />
1321
+ <span className="zr-wizard-pathName">{t(nameKey)}</span>
1322
+ </div>
1323
+ <p className="zr-wizard-pathDesc">{t(descKey)}</p>
1324
+ </button>
1325
+ )
1326
+ })}
1327
+ </div>
1328
+
1329
+ {wizardRole === 'host' ? (
1330
+ <>
1331
+ <p className="zr-wizard-sectionTitle">{t('wizard.ingressSectionTitle')}</p>
1332
+ <div className="zr-wizard-paths" role="radiogroup" aria-label={t('wizard.ingressSectionTitle')}>
1333
+ {PATH_OPTIONS.map(([value, nameKey, descKey]) => {
1334
+ const selected = path === value
1335
+ return (
1336
+ <button
1337
+ key={value}
1338
+ type="button"
1339
+ role="radio"
1340
+ aria-checked={selected}
1341
+ className="zr-wizard-path"
1342
+ data-selected={selected}
1343
+ disabled={disabled}
1344
+ onClick={() => {
1345
+ selectPath(value)
1346
+ }}
1347
+ onDoubleClick={() => {
1348
+ if (disabled) return
1349
+ selectPath(value)
1350
+ setStep(stepAfterPick())
1351
+ }}
1352
+ >
1353
+ <div className="zr-wizard-pathHead">
1354
+ <span className="zr-wizard-pathRadio" data-selected={selected} aria-hidden="true" />
1355
+ <span className="zr-wizard-pathName">{t(nameKey)}</span>
1356
+ </div>
1357
+ <p className="zr-wizard-pathDesc">{t(descKey)}</p>
1358
+ </button>
1359
+ )
1360
+ })}
1361
+ </div>
1362
+ </>
1363
+ ) : (
1364
+ <p className="zr-wizard-notice">{t('wizard.role.clientPickNote')}</p>
1365
+ )}
1366
+ </div>
1367
+
1368
+ <div className="zr-wizard-modalFooter">
1369
+ <button
1370
+ type="button"
1371
+ className="zr-wizard-btn"
1372
+ onClick={() => setModalOpen(false)}
1373
+ >
1374
+ {t('wizard.modal.cancel')}
1375
+ </button>
1376
+ <div className="zr-wizard-row">
1377
+ <span className="zr-wizard-footerHint">
1378
+ {wizardRole === 'host'
1379
+ ? `${t('wizard.role.host')} · ${pathName(path)}`
1380
+ : t('wizard.role.client')}
1381
+ </span>
1382
+ <button
1383
+ type="button"
1384
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1385
+ disabled={disabled}
1386
+ onClick={() => {
1387
+ setFormError(null)
1388
+ setStep(stepAfterPick())
1389
+ }}
1390
+ >
1391
+ {t('wizard.next')}
1392
+ </button>
1393
+ </div>
1394
+ </div>
1395
+ </>
1396
+ ) : null}
1397
+
1398
+ {step === 'configure' ? (
1399
+ <>
1400
+ <div className="zr-wizard-modalBody">
1401
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step2Desc')}</p>
1402
+ <div className="zr-wizard-selectedBar">
1403
+ <span className="zr-wizard-selectedLabel">
1404
+ {t('wizard.modal.selectedPlan', {
1405
+ plan: wizardRole === 'host'
1406
+ ? `${t('wizard.role.host')} · ${pathName(path)}`
1407
+ : t('wizard.role.client'),
1408
+ })}
1409
+ </span>
1410
+ </div>
1411
+
1412
+ {wizardRole === 'host' ? (
1413
+ <>
1414
+ <div className="zr-wizard-formStack">
1415
+ <div className="zr-wizard-field">
1416
+ <label className="zr-wizard-label" htmlFor="zr-wizard-host-name">{t('wizard.hostName')}</label>
1417
+ <input
1418
+ id="zr-wizard-host-name"
1419
+ className="zr-wizard-input"
1420
+ type="text"
1421
+ placeholder={t('wizard.hostNamePlaceholder')}
1422
+ value={hostName}
1423
+ disabled={disabled}
1424
+ onChange={(event) => setHostName(event.target.value)}
1425
+ />
1426
+ <p className="zr-wizard-hint">{t('wizard.hostNameHint')}</p>
1427
+ </div>
1428
+
1429
+ <label className="zr-wizard-checkRow">
1430
+ <input
1431
+ type="checkbox"
1432
+ checked={autoShare}
1433
+ disabled={disabled}
1434
+ onChange={(event) => setAutoShare(event.target.checked)}
1435
+ />
1436
+ <span>{t('wizard.autoShare')}</span>
1437
+ </label>
1438
+ </div>
1439
+
1440
+ {path === 'lan' ? (
1441
+ <div className="zr-wizard-field">
1442
+ <label className="zr-wizard-label" htmlFor="zr-wizard-lan-url">{t('wizard.lanUrl')}</label>
1443
+ <input
1444
+ id="zr-wizard-lan-url"
1445
+ className="zr-wizard-input"
1446
+ type="url"
1447
+ placeholder={t('wizard.lanUrlPlaceholder')}
1448
+ value={serverUrl}
1449
+ disabled={disabled}
1450
+ onChange={(event) => setServerUrl(event.target.value)}
1451
+ />
1452
+ <p className="zr-wizard-hint">{t('wizard.lanUrlHint')}</p>
1453
+ </div>
1454
+ ) : path === 'external' ? (
1455
+ <div className="zr-wizard-field">
1456
+ <label className="zr-wizard-label" htmlFor="zr-wizard-server-url">{t('wizard.serverUrl')}</label>
1457
+ <input
1458
+ id="zr-wizard-server-url"
1459
+ className="zr-wizard-input"
1460
+ type="url"
1461
+ placeholder="https://dsh.example.com"
1462
+ value={serverUrl}
1463
+ disabled={disabled}
1464
+ onChange={(event) => setServerUrl(event.target.value)}
1465
+ />
1466
+ <p className="zr-wizard-hint">{t('wizard.serverUrlHint')}</p>
1467
+ <div className="zr-wizard-row">
1468
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('proxy')}>
1469
+ {t('wizard.proxySnippet')}
1470
+ </button>
1471
+ </div>
1472
+ </div>
1473
+ ) : (
1474
+ <div className="zr-wizard-formStack">
1475
+ <div className="zr-wizard-formGrid">
1476
+ <div className="zr-wizard-field">
1477
+ <label className="zr-wizard-label" htmlFor="zr-wizard-domain">{t('wizard.domain')}</label>
1478
+ <input
1479
+ id="zr-wizard-domain"
1480
+ className="zr-wizard-input"
1481
+ type="text"
1482
+ placeholder="dsh.example.com"
1483
+ value={domain}
1484
+ disabled={disabled}
1485
+ onChange={(event) => setDomain(event.target.value)}
1486
+ />
1487
+ <p className="zr-wizard-hint">{t('wizard.domainHint')}</p>
1488
+ </div>
1489
+ <div className="zr-wizard-field zr-wizard-fieldPort">
1490
+ <label className="zr-wizard-label" htmlFor="zr-wizard-port">{t('wizard.httpsPort')}</label>
1491
+ <input
1492
+ id="zr-wizard-port"
1493
+ className="zr-wizard-input"
1494
+ type="text"
1495
+ inputMode="numeric"
1496
+ value={httpsPort}
1497
+ disabled={disabled}
1498
+ onChange={(event) => setHttpsPort(event.target.value)}
1499
+ />
1500
+ </div>
1501
+ </div>
1502
+
1503
+ {path === 'caddy' && isValidPort(httpsPort.trim()) && httpsPort.trim() !== '443' ? (
1504
+ <div className="zr-wizard-row">
1505
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('portforward')}>
1506
+ {t('wizard.portForwardEntry')}
1507
+ </button>
1508
+ </div>
1509
+ ) : null}
1510
+
1511
+ <div className="zr-wizard-field">
1512
+ <div className="zr-wizard-labelRow">
1513
+ <label className="zr-wizard-label" htmlFor="zr-wizard-token">{t('wizard.cfToken')}</label>
1514
+ {tokenSaved ? <span className="zr-wizard-savedTag">{t('wizard.token.saved')}</span> : null}
1515
+ </div>
1516
+ <input
1517
+ id="zr-wizard-token"
1518
+ className="zr-wizard-input"
1519
+ type="password"
1520
+ placeholder={tokenSaved ? '••••••••••••••••' : ''}
1521
+ value={cfToken}
1522
+ disabled={disabled}
1523
+ onChange={(event) => setCfToken(event.target.value)}
1524
+ />
1525
+ <p className="zr-wizard-hint">{t('wizard.cfTokenHint')}</p>
1526
+ <div className="zr-wizard-row">
1527
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('token')}>
1528
+ {t('wizard.cfTokenHowTo')}
1529
+ </button>
1530
+ {path === 'cloudflared' ? (
1531
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('migrate')}>
1532
+ {t('wizard.migrateEntry')}
1533
+ </button>
1534
+ ) : null}
1535
+ </div>
1536
+ </div>
1537
+ </div>
1538
+ )}
1539
+
1540
+ {tokenNotice === 'failed' ? (
1541
+ <p className="zr-wizard-notice" data-tone="error">
1542
+ {t('wizard.token.saveFailed')}
1543
+ </p>
1544
+ ) : null}
1545
+ {formError !== null ? (
1546
+ <p className="zr-wizard-notice" data-tone="error">
1547
+ {t(formError === 'bad-domain' ? 'wizard.badDomain' : formError === 'bad-port' ? 'wizard.badPort' : formError === 'missing-token' ? 'wizard.missingToken' : 'wizard.badServerUrl')}
1548
+ </p>
1549
+ ) : null}
1550
+ </>
1551
+ ) : (
1552
+ <>
1553
+ <div className="zr-wizard-formStack">
1554
+ <div className="zr-wizard-field">
1555
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-device">{t('wizard.client.deviceName')}</label>
1556
+ <input
1557
+ id="zr-wizard-client-device"
1558
+ className="zr-wizard-input"
1559
+ type="text"
1560
+ placeholder={t('wizard.client.deviceNamePlaceholder')}
1561
+ value={clientDeviceName}
1562
+ disabled={clientClaimBusy}
1563
+ onChange={(event) => setClientDeviceName(event.target.value)}
1564
+ />
1565
+ <p className="zr-wizard-hint">{t('wizard.client.deviceNameHint')}</p>
1566
+ </div>
1567
+
1568
+ <div className="zr-wizard-field">
1569
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-url">{t('wizard.client.serverUrl')}</label>
1570
+ <input
1571
+ id="zr-wizard-client-url"
1572
+ className="zr-wizard-input"
1573
+ type="url"
1574
+ placeholder={t('wizard.client.serverUrlPlaceholder')}
1575
+ value={clientServerUrl}
1576
+ disabled={clientClaimBusy}
1577
+ onChange={(event) => setClientServerUrl(event.target.value)}
1578
+ />
1579
+ <p className="zr-wizard-hint">{t('wizard.client.serverUrlHint')}</p>
1580
+ </div>
1581
+
1582
+ <div className="zr-wizard-field">
1583
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-code">{t('wizard.client.pairingCode')}</label>
1584
+ <input
1585
+ id="zr-wizard-client-code"
1586
+ className="zr-wizard-input"
1587
+ type="text"
1588
+ autoComplete="off"
1589
+ spellCheck={false}
1590
+ placeholder={t('wizard.client.pairingCodePlaceholder')}
1591
+ value={clientPairCode}
1592
+ disabled={clientClaimBusy}
1593
+ onChange={(event) => setClientPairCode(normalizePairingCode(event.target.value))}
1594
+ />
1595
+ <p className="zr-wizard-hint">{t('wizard.client.pairingCodeHint')}</p>
1596
+ </div>
1597
+ </div>
1598
+
1599
+ {formError === 'bad-client-url' ? (
1600
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.client.failInvalid')}</p>
1601
+ ) : null}
1602
+ {formError === 'bad-client-code' ? (
1603
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.client.failBadCode')}</p>
1604
+ ) : null}
1605
+ {clientClaimFail !== null ? (
1606
+ <p className="zr-wizard-notice" data-tone="error">{claimFailCopy(clientClaimFail.code, clientClaimFail.retryAfterMs, t)}</p>
1607
+ ) : null}
1608
+ </>
1609
+ )}
1610
+ </div>
1611
+
1612
+ <div className="zr-wizard-modalFooter">
1613
+ <button
1614
+ type="button"
1615
+ className="zr-wizard-btn"
1616
+ disabled={disabled || savingConfig || clientClaimBusy}
1617
+ onClick={() => {
1618
+ setFormError(null)
1619
+ setStep('pick')
1620
+ }}
1621
+ >
1622
+ {t('wizard.back')}
1623
+ </button>
1624
+ {wizardRole === 'host' ? (
1625
+ <button
1626
+ type="button"
1627
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1628
+ disabled={disabled || savingConfig}
1629
+ onClick={() => { void advanceFromConfigure() }}
1630
+ >
1631
+ {t('wizard.modal.saveAndNext')}
1632
+ </button>
1633
+ ) : (
1634
+ <div className="zr-wizard-row">
1635
+ {roleSnapshot.deviceToken.configured ? (
1636
+ <button type="button" className="zr-wizard-btn" disabled={clientClaimBusy} onClick={() => setStep('verify')}>
1637
+ {t('wizard.client.skipToStatus')}
1638
+ </button>
1639
+ ) : null}
1640
+ <button
1641
+ type="button"
1642
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1643
+ disabled={clientClaimBusy}
1644
+ onClick={() => { void runClientClaim() }}
1645
+ >
1646
+ {clientClaimBusy ? t('wizard.client.pairingBusy') : t('wizard.client.pairAndConnect')}
1647
+ </button>
1648
+ </div>
1649
+ )}
1650
+ </div>
1651
+ </>
1652
+ ) : null}
1653
+
1654
+ {step === 'verify' ? (
1655
+ <>
1656
+ <div className="zr-wizard-modalBody">
1657
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step3Desc')}</p>
1658
+ {wizardRole === 'host' ? (
1659
+ <>
1660
+ {caddyPath ? (
1661
+ <div className="zr-wizard-serviceBar" data-zen-remote="wizard-caddy-controls">
1662
+ <div className="zr-wizard-serviceInfo">
1663
+ <span className="zr-wizard-dot" data-state={caddyState === 'running' ? 'pass' : 'pending'} />
1664
+ <span className="zr-wizard-label">{t('wizard.ingress')}</span>
1665
+ <span className="zr-wizard-hint">
1666
+ {caddyBusy
1667
+ ? t('wizard.caddy.starting')
1668
+ : t(caddyState === 'running' ? 'wizard.caddy.running' : caddyState === 'stopped' ? 'wizard.caddy.stopped' : 'wizard.caddy.neverStarted')}
1669
+ </span>
1670
+ </div>
1671
+ <div className="zr-wizard-row">
1672
+ <button type="button" className="zr-wizard-btn zr-wizard-btnPrimary" disabled={disabled || caddyBusy || caddyState === 'running'} onClick={() => { void startCaddy() }}>
1673
+ {caddyBusy ? t('wizard.caddy.starting') : t('wizard.caddy.start')}
1674
+ </button>
1675
+ <button type="button" className="zr-wizard-btn" disabled={disabled || caddyBusy || caddyState !== 'running'} onClick={() => { void stopCaddy() }}>
1676
+ {t('wizard.caddy.stop')}
1677
+ </button>
1678
+ </div>
1679
+ </div>
1680
+ ) : null}
1681
+ {tunnelPath ? (
1682
+ <div className="zr-wizard-serviceBar" data-zen-remote="wizard-tunnel-controls">
1683
+ <div className="zr-wizard-serviceInfo">
1684
+ <span className="zr-wizard-dot" data-state={tunnelState === 'running' ? 'pass' : 'pending'} />
1685
+ <span className="zr-wizard-label">{t('wizard.ingress')}</span>
1686
+ <span className="zr-wizard-hint">
1687
+ {t(tunnelState === 'running' ? 'wizard.tunnel.running' : tunnelState === 'stopped' ? 'wizard.tunnel.stopped' : 'wizard.tunnel.neverStarted')}
1688
+ </span>
1689
+ </div>
1690
+ <div className="zr-wizard-row">
1691
+ <button type="button" className="zr-wizard-btn zr-wizard-btnPrimary" disabled={disabled || tunnelBusy} onClick={() => { void setupTunnelNow(adoptTunnelId) }}>
1692
+ {tunnelBusy ? t('wizard.tunnel.checking') : tunnelState === 'running' ? t('wizard.reverify') : t('wizard.tunnel.setup')}
1693
+ </button>
1694
+ <button type="button" className="zr-wizard-btn" disabled={disabled || tunnelState !== 'running'} onClick={() => { void (async () => {
1695
+ try {
1696
+ await fetch(WIZARD_TUNNEL_STOP_ROUTE, { method: 'POST' })
1697
+ setTunnelState('stopped')
1698
+ } catch { /* the poll corrects */ }
1699
+ })() }}>{t('wizard.tunnel.stop')}</button>
1700
+ </div>
1701
+ </div>
1702
+ ) : null}
1703
+ {tunnelPath && tunnelCheck !== undefined ? (() => {
1704
+ const copy = tunnelCheckCopy(tunnelCheck)
1705
+ return <p className="zr-wizard-notice" data-tone={copy.tone === 'error' ? 'error' : undefined}>
1706
+ {copy.key === 'wizard.check.failed'
1707
+ ? t('wizard.check.failed', { reason: tunnelCheck.ok ? '' : String(tunnelCheck.reason) })
1708
+ : t(copy.key as Parameters<typeof t>[0])}
1709
+ </p>
1710
+ })() : null}
1711
+ {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason === 'tunnel-conflict' && tunnelResult.existing !== undefined ? (
1712
+ <div className="zr-wizard-field">
1713
+ <p className="zr-wizard-label">{t('wizard.tunnel.conflict')}</p>
1714
+ <div className="zr-wizard-row">
1715
+ {tunnelResult.existing.map((tunnel) => (
1716
+ <button key={tunnel.id} type="button" className="zr-wizard-linkBtn" disabled={disabled || tunnelBusy}
1717
+ onClick={() => { setAdoptTunnelId(tunnel.id); void setupTunnelNow(tunnel.id) }}>
1718
+ {t('wizard.tunnel.useExisting')}:{tunnel.name}({tunnel.status})
1719
+ </button>
1720
+ ))}
1721
+ </div>
1722
+ </div>
1723
+ ) : null}
1724
+ {tunnelPath && tunnelResult !== undefined && tunnelResult.ok ? <p className="zr-wizard-notice">{t('wizard.tunnel.ok')}</p> : null}
1725
+ {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason !== 'tunnel-conflict' ? (() => {
1726
+ const copy = tunnelFailureCopy(tunnelResult.reason, tunnelResult.detail)
1727
+ return <p className="zr-wizard-notice" data-tone="error">{t(copy.key as Parameters<typeof t>[0], copy.params)}</p>
1728
+ })() : null}
1729
+ {caddyPath && caddyStartError !== undefined ? (
1730
+ <p className="zr-wizard-notice" data-tone="error">
1731
+ {t('wizard.caddy.startFailed', { reason: t(caddyStartError.key as Parameters<typeof t>[0], caddyStartError.params) })}
1732
+ {caddyStartError.installUrl !== undefined ? (
1733
+ <> <a className="zr-wizard-link" href={caddyStartError.installUrl} target="_blank" rel="noreferrer">{caddyStartError.installUrl}</a></>
1734
+ ) : null}
1735
+ </p>
1736
+ ) : null}
1737
+
1738
+ <ul className="zr-wizard-lights">
1739
+ {LIGHT_ORDER.map(({ key, light }) => {
1740
+ const state = lights[light]
1741
+ const labelText = light === 'publicUrl' && probingUrl
1742
+ ? t('wizard.light.probing')
1743
+ : t(LIGHT_LABEL[state])
1744
+ return (
1745
+ <li key={key} className="zr-wizard-light" data-state={state}>
1746
+ <div className="zr-wizard-lightMain">
1747
+ <span className="zr-wizard-dot" data-state={state} />
1748
+ <span className="zr-wizard-lightName">{t(key)}</span>
1749
+ </div>
1750
+ <span className="zr-wizard-lightState" data-state={state}>{labelText}</span>
1751
+ </li>
1752
+ )
1753
+ })}
1754
+ </ul>
1755
+
1756
+ {startResult !== undefined && !startResult.ok ? (() => {
1757
+ const copy = startFailureCopy(startResult)
1758
+ return <p className="zr-wizard-notice" data-tone="error">{t('wizard.startFailed', { reason: t(copy.key, copy.params) })}</p>
1759
+ })() : null}
1760
+
1761
+ <div className="zr-wizard-targetTabs" role="tablist">
1762
+ <button
1763
+ type="button"
1764
+ role="tab"
1765
+ aria-selected={pairTarget === 'web'}
1766
+ className="zr-wizard-targetTab"
1767
+ data-selected={pairTarget === 'web'}
1768
+ disabled={disabled}
1769
+ onClick={() => setPairTarget('web')}
1770
+ >
1771
+ {t('wizard.pairTarget.web')}
1772
+ </button>
1773
+ <button
1774
+ type="button"
1775
+ role="tab"
1776
+ aria-selected={pairTarget === 'desktop-client'}
1777
+ className="zr-wizard-targetTab"
1778
+ data-selected={pairTarget === 'desktop-client'}
1779
+ disabled={disabled}
1780
+ onClick={() => setPairTarget('desktop-client')}
1781
+ >
1782
+ {t('wizard.pairTarget.desktop')}
1783
+ </button>
1784
+ </div>
1785
+
1786
+ {pairTarget === 'web' ? (
1787
+ <>
1788
+ {webPairRequested && startResult?.ok === true && startResult.pairingCode !== '' ? (
1789
+ <div className="zr-wizard-pairPanel">
1790
+ <svg
1791
+ className="zr-wizard-qr"
1792
+ data-usable={qr !== null}
1793
+ role="img"
1794
+ aria-label={t('wizard.qrHint')}
1795
+ shapeRendering="crispEdges"
1796
+ viewBox={qr === null ? undefined : `-16 -16 ${(qr.length + 8) * 4} ${(qr.length + 8) * 4}`}
1797
+ >
1798
+ {qr === null ? null : (
1799
+ <>
1800
+ <rect x={-16} y={-16} width={(qr.length + 8) * 4} height={(qr.length + 8) * 4} fill="#fff" />
1801
+ <path d={qrSvgPath(qr, 4)} fill="#000" />
1802
+ </>
1803
+ )}
1804
+ </svg>
1805
+ <div className="zr-wizard-pairMeta">
1806
+ <span className="zr-wizard-label">{t('wizard.pairingCode')}</span>
1807
+ <p className="zr-wizard-code">{startResult.pairingCode}</p>
1808
+ <p className="zr-wizard-countdown">{t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(startResult.expiresAt, now)) })}</p>
1809
+ <p className="zr-wizard-hint">{t('wizard.qrHint')}</p>
1810
+ </div>
1811
+ </div>
1812
+ ) : null}
1813
+
1814
+ {poll?.result === 'pass' ? <p className="zr-wizard-notice" data-tone="ok">{t('wizard.verifyPass', { deviceId: poll.deviceId })}</p> : null}
1815
+ {poll?.result === 'cookie-insecure' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyCookieInsecure')}</p> : null}
1816
+ {poll?.result === 'code-expired' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyExpired')}</p> : null}
1817
+ {poll?.result === 'stale' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyStale')}</p> : null}
1818
+ {webPairRequested && (poll?.result === 'pending' || poll?.result === 'not-started') ? <p className="zr-wizard-hint">{t('wizard.verifyPending')}</p> : null}
1819
+ {!webPairRequested && poll === undefined ? <p className="zr-wizard-hint">{t('wizard.verifyNotStarted')}</p> : null}
1820
+ </>
1821
+ ) : (
1822
+ <div className="zr-wizard-desktopBox">
1823
+ <p className="zr-wizard-hint">{t('wizard.desktop.guide')}</p>
1824
+ <div className="zr-wizard-field">
1825
+ <span className="zr-wizard-label">{t('wizard.desktop.addressLabel')}</span>
1826
+ <code className="zr-wizard-input">{resolvedHostEndpoint}</code>
1827
+ </div>
1828
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? (
1829
+ <div className="zr-wizard-pairMeta">
1830
+ <span className="zr-wizard-label">{t('wizard.pairingCode')}</span>
1831
+ <p className="zr-wizard-code">{desktopPairing.code}</p>
1832
+ <p className="zr-wizard-countdown">
1833
+ {t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(desktopPairing.expiresAt, now)) })}
1834
+ </p>
1835
+ </div>
1836
+ ) : null}
1837
+ <div className="zr-wizard-row">
1838
+ <button
1839
+ type="button"
1840
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1841
+ disabled={disabled || desktopPairBusy}
1842
+ onClick={() => { void runDesktopPair() }}
1843
+ >
1844
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? t('wizard.desktop.regenerate') : t('wizard.desktop.generate')}
1845
+ </button>
1846
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? (
1847
+ <button
1848
+ type="button"
1849
+ className="zr-wizard-btn"
1850
+ disabled={disabled}
1851
+ onClick={() => { void copyDesktopInfo() }}
1852
+ >
1853
+ {desktopCopied ? t('wizard.card.copied') : t('wizard.desktop.copyInfo')}
1854
+ </button>
1855
+ ) : null}
1856
+ </div>
1857
+ </div>
1858
+ )}
1859
+ </>
1860
+ ) : (
1861
+ <>
1862
+ <ul className="zr-wizard-lights">
1863
+ {CLIENT_LIGHT_ORDER.map(({ key, light }) => (
1864
+ <li key={key} className="zr-wizard-light" data-state={clientLights[light]}>
1865
+ <div className="zr-wizard-lightMain">
1866
+ <span className="zr-wizard-dot" data-state={clientLights[light]} />
1867
+ <span className="zr-wizard-lightName">{t(key)}</span>
1868
+ </div>
1869
+ <span className="zr-wizard-lightState" data-state={clientLights[light]}>{t(LIGHT_LABEL[clientLights[light]])}</span>
1870
+ </li>
1871
+ ))}
1872
+ </ul>
1873
+
1874
+ <div className="zr-wizard-clientBox">
1875
+ <p className="zr-wizard-label">{t('settings.client.statusTitle')}</p>
1876
+ <p
1877
+ className="zr-wizard-notice"
1878
+ data-tone={clientView?.state === 'connected' ? 'ok' : clientLights.connection === 'fail' ? 'error' : undefined}
1879
+ >
1880
+ {clientView === undefined
1881
+ ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1882
+ : clientStatusCopy(clientView, now, t)}
1883
+ </p>
1884
+ <div className="zr-wizard-row">
1885
+ <button
1886
+ type="button"
1887
+ className="zr-wizard-btn"
1888
+ disabled={clientLoading}
1889
+ onClick={() => { void loadClientStatus() }}
1890
+ >
1891
+ {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1892
+ </button>
1893
+ {clientView?.nextRetryAt !== undefined ? (
1894
+ <button
1895
+ type="button"
1896
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1897
+ disabled={clientReconnectBusy}
1898
+ onClick={() => { void runClientReconnect() }}
1899
+ >
1900
+ {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1901
+ </button>
1902
+ ) : null}
1903
+ {roleSnapshot.deviceToken.configured ? (
1904
+ <button
1905
+ type="button"
1906
+ className="zr-wizard-btn"
1907
+ disabled={clientUnpairBusy}
1908
+ onClick={() => { void runClientUnpair() }}
1909
+ >
1910
+ {clientUnpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair')}
1911
+ </button>
1912
+ ) : null}
1913
+ </div>
1914
+ </div>
1915
+ </>
1916
+ )}
1917
+ </div>
1918
+
1919
+ <div className="zr-wizard-modalFooter">
1920
+ <button
1921
+ type="button"
1922
+ className="zr-wizard-btn"
1923
+ disabled={disabled}
1924
+ onClick={() => setStep('configure')}
1925
+ >
1926
+ {t('wizard.back')}
1927
+ </button>
1928
+ <div className="zr-wizard-row">
1929
+ {wizardRole === 'host' && resolvedVerifyUrl !== '' ? (
1930
+ <button
1931
+ type="button"
1932
+ className="zr-wizard-btn"
1933
+ disabled={disabled || probingUrl}
1934
+ onClick={() => { void probeAccessUrl(resolvedVerifyUrl) }}
1935
+ >
1936
+ {probingUrl ? t('wizard.light.probing') : t('wizard.devicesModal.retest')}
1937
+ </button>
1938
+ ) : null}
1939
+ {wizardRole === 'host' && pairTarget === 'web' ? (
1940
+ <button
1941
+ type="button"
1942
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1943
+ disabled={disabled || probingUrl}
1944
+ onClick={() => { void startVerification() }}
1945
+ >
1946
+ {verified ? t('wizard.reverify') : t('wizard.startVerify')}
1947
+ </button>
1948
+ ) : null}
1949
+ {wizardRole === 'host' && pairTarget === 'desktop-client' ? (
1950
+ <button
1951
+ type="button"
1952
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1953
+ disabled={disabled || desktopPairBusy}
1954
+ onClick={() => { void runDesktopPair() }}
1955
+ >
1956
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? t('wizard.desktop.regenerate') : t('wizard.desktop.generate')}
1957
+ </button>
1958
+ ) : null}
1959
+ <button
1960
+ type="button"
1961
+ className="zr-wizard-btn"
1962
+ onClick={() => setModalOpen(false)}
1963
+ >
1964
+ {t('wizard.modal.finish')}
1965
+ </button>
1966
+ </div>
1967
+ </div>
1968
+ </>
1969
+ ) : null}
1970
+ </div>
1971
+ </div>
1972
+ ) : null}
1973
+
1974
+ {devicesModalOpen ? (
1975
+ <div
1976
+ data-zen-remote="wizard-modal"
1977
+ onClick={() => {
1978
+ if (!deviceBusy) setDevicesModalOpen(false)
1979
+ }}
1980
+ >
1981
+ <div
1982
+ className="zr-wizard-modalDialog"
1983
+ role="dialog"
1984
+ aria-modal="true"
1985
+ aria-label={t('wizard.devicesModal.title')}
1986
+ onClick={(event) => event.stopPropagation()}
1987
+ >
1988
+ <div className="zr-wizard-modalHeader">
1989
+ <div className="zr-wizard-headerText">
1990
+ <p className="zr-wizard-modalTitle">{t('wizard.devicesModal.title')}</p>
1991
+ <p className="zr-wizard-subtitle">{t('wizard.devicesModal.subtitle')}</p>
1992
+ </div>
1993
+ <button
1994
+ type="button"
1995
+ className="zr-wizard-cardCloseIcon"
1996
+ aria-label={t('wizard.card.close')}
1997
+ disabled={deviceBusy}
1998
+ onClick={() => setDevicesModalOpen(false)}
1999
+ >
2000
+ ×
2001
+ </button>
2002
+ </div>
2003
+
2004
+ <div className="zr-wizard-modalBody">
2005
+ {deviceError ? (
2006
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.actionFail')}</p>
2007
+ ) : null}
2008
+
2009
+ {allDevices.length === 0 ? (
2010
+ <p className="zr-wizard-notice">{t('settings.devicesEmpty')}</p>
2011
+ ) : (
2012
+ <ul className="zr-wizard-deviceList">
2013
+ {allDevices.map((device) => (
2014
+ <li key={device.id} className="zr-wizard-deviceItem">
2015
+ <div className="zr-wizard-deviceHead">
2016
+ <div className="zr-wizard-deviceTitleRow">
2017
+ <span className="zr-wizard-lightName">{device.name}</span>
2018
+ <span className="zr-wizard-deviceBadge">
2019
+ {device.role === 'web' ? t('settings.deviceRoleWeb') : t('settings.deviceRoleDesktop')}
2020
+ </span>
2021
+ {device.role === 'web' ? (
2022
+ <span className="zr-wizard-deviceBadge">
2023
+ {device.hasPush ? t('settings.devicePushOn') : t('settings.devicePushOff')}
2024
+ </span>
2025
+ ) : null}
2026
+ </div>
2027
+ <span className="zr-wizard-hint">
2028
+ {t('settings.deviceLastSeen', { time: new Date(device.lastSeen).toLocaleString() })}
2029
+ </span>
2030
+ </div>
2031
+
2032
+ <div className="zr-wizard-deviceActions">
2033
+ {device.role === 'web' ? (
2034
+ <select
2035
+ className="zr-wizard-select"
2036
+ aria-label={t('settings.deviceKindSelect', { name: device.name })}
2037
+ disabled={disabled || deviceBusy}
2038
+ value={device.kind}
2039
+ onChange={(event) => {
2040
+ void runDeviceAction({ action: 'set-kind', id: device.id, kind: event.currentTarget.value })
2041
+ }}
2042
+ >
2043
+ <option value="auto">{t('settings.kindAuto')}</option>
2044
+ <option value="phone">{t('settings.kindPhone')}</option>
2045
+ <option value="desktop">{t('settings.kindDesktop')}</option>
2046
+ </select>
2047
+ ) : null}
2048
+ <button
2049
+ type="button"
2050
+ className="zr-wizard-linkBtn"
2051
+ disabled={disabled || deviceBusy}
2052
+ onClick={() => setRenamingDevice({ id: device.id, draft: device.name })}
2053
+ >
2054
+ {t('settings.deviceRename')}
2055
+ </button>
2056
+ <button
2057
+ type="button"
2058
+ className="zr-wizard-linkBtn"
2059
+ disabled={disabled || deviceBusy}
2060
+ onClick={() => {
2061
+ if (window.confirm(t('settings.revokeConfirm'))) {
2062
+ void runDeviceAction({ action: 'revoke', id: device.id })
2063
+ }
2064
+ }}
2065
+ >
2066
+ {t('settings.deviceRevoke')}
2067
+ </button>
2068
+ </div>
2069
+
2070
+ {renamingDevice?.id === device.id ? (
2071
+ <div className="zr-wizard-row">
2072
+ <input
2073
+ className="zr-wizard-input"
2074
+ style={{ flex: 1, minWidth: 160 }}
2075
+ aria-label={t('settings.renamePrompt')}
2076
+ maxLength={40}
2077
+ value={renamingDevice.draft}
2078
+ autoFocus
2079
+ onChange={(event) => setRenamingDevice({ id: device.id, draft: event.currentTarget.value })}
2080
+ onKeyDown={(event) => {
2081
+ if (event.key === 'Enter') {
2082
+ const trimmed = renamingDevice.draft.trim()
2083
+ setRenamingDevice(null)
2084
+ if (trimmed !== '' && trimmed !== device.name) {
2085
+ void runDeviceAction({ action: 'rename', id: device.id, name: trimmed })
2086
+ }
2087
+ }
2088
+ if (event.key === 'Escape') setRenamingDevice(null)
2089
+ }}
2090
+ />
2091
+ <button
2092
+ type="button"
2093
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2094
+ disabled={deviceBusy}
2095
+ onClick={() => {
2096
+ const trimmed = renamingDevice.draft.trim()
2097
+ setRenamingDevice(null)
2098
+ if (trimmed !== '' && trimmed !== device.name) {
2099
+ void runDeviceAction({ action: 'rename', id: device.id, name: trimmed })
2100
+ }
2101
+ }}
2102
+ >
2103
+ {t('settings.renameSave')}
2104
+ </button>
2105
+ <button
2106
+ type="button"
2107
+ className="zr-wizard-btn"
2108
+ disabled={deviceBusy}
2109
+ onClick={() => setRenamingDevice(null)}
2110
+ >
2111
+ {t('settings.renameCancel')}
2112
+ </button>
2113
+ </div>
2114
+ ) : null}
2115
+ </li>
2116
+ ))}
2117
+ </ul>
2118
+ )}
2119
+ </div>
2120
+
2121
+ <div className="zr-wizard-modalFooter">
2122
+ <div className="zr-wizard-row">
2123
+ {allDevices.length > 0 ? (
2124
+ <button
2125
+ type="button"
2126
+ className="zr-wizard-btn"
2127
+ disabled={disabled || deviceBusy}
2128
+ onClick={() => {
2129
+ if (window.confirm(t('settings.revokeAllConfirm'))) {
2130
+ void runDeviceAction({ action: 'revoke-all' })
2131
+ }
2132
+ }}
2133
+ >
2134
+ {t('settings.revokeAll')}
2135
+ </button>
2136
+ ) : null}
2137
+ </div>
2138
+ <div className="zr-wizard-row">
2139
+ <button
2140
+ type="button"
2141
+ className="zr-wizard-btn"
2142
+ disabled={disabled || deviceBusy}
2143
+ onClick={() => {
2144
+ setDevicesModalOpen(false)
2145
+ openWizardAt('verify', { role: 'host', target: 'desktop-client' })
2146
+ }}
2147
+ >
2148
+ {t('wizard.overview.pairDesktop')}
2149
+ </button>
2150
+ <button
2151
+ type="button"
2152
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2153
+ disabled={disabled || deviceBusy}
2154
+ onClick={() => {
2155
+ setDevicesModalOpen(false)
2156
+ openWizardAt('verify', { role: 'host', target: 'web' })
2157
+ }}
2158
+ >
2159
+ {t('wizard.overview.pairPhone')}
2160
+ </button>
2161
+ <button
2162
+ type="button"
2163
+ className="zr-wizard-btn"
2164
+ disabled={deviceBusy}
2165
+ onClick={() => setDevicesModalOpen(false)}
2166
+ >
2167
+ {t('wizard.card.close')}
2168
+ </button>
2169
+ </div>
2170
+ </div>
2171
+ </div>
2172
+ </div>
2173
+ ) : null}
2174
+
2175
+ {card === 'proxy' ? (
2176
+ <ProxyCard
2177
+ t={t}
2178
+ domain={(() => {
2179
+ if (domain.trim() !== '') return domain.trim()
2180
+ try {
2181
+ const parsed = new URL(serverUrl.trim())
2182
+ if (parsed.hostname !== '') return parsed.hostname
2183
+ } catch { /* fall back to placeholder */ }
2184
+ return 'dsh.example.com'
2185
+ })()}
2186
+ upstreamPort={upstreamPortOf(wizardStatus)}
2187
+ onCheck={() => {
2188
+ setStep('verify')
2189
+ void startVerification()
2190
+ }}
2191
+ onClose={() => setCard(undefined)}
2192
+ />
2193
+ ) : null}
2194
+
2195
+ {card === 'token' ? (
2196
+ <TokenCard
2197
+ t={t}
2198
+ onUse={(token) => {
2199
+ void (async () => {
2200
+ await saveTokenValue(token)
2201
+ setCard(undefined)
2202
+ })()
2203
+ }}
2204
+ onClose={() => setCard(undefined)}
2205
+ />
2206
+ ) : null}
2207
+
2208
+ {card === 'migrate' ? (
2209
+ <MigrateCard
2210
+ t={t}
2211
+ onRecheck={() => {
2212
+ setCard(undefined)
2213
+ void checkDomain()
2214
+ }}
2215
+ onClose={() => setCard(undefined)}
2216
+ />
2217
+ ) : null}
2218
+
2219
+ {card === 'portforward' ? (
2220
+ <PortForwardCard t={t} port={Number(httpsPort.trim()) || 8443} onClose={() => setCard(undefined)} />
2221
+ ) : null}
2222
+ </div>
2223
+ )
2224
+ }