dsh-zen-remote 2.2.8 → 2.3.1

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 (162) 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-routes.js +52 -0
  13. package/lib/client-routes.js.map +1 -1
  14. package/lib/client.js +3390 -10
  15. package/lib/config.js +70 -0
  16. package/lib/config.js.map +1 -1
  17. package/lib/index.js +232 -2
  18. package/lib/index.js.map +1 -1
  19. package/lib/intercept.js +43 -3
  20. package/lib/intercept.js.map +1 -1
  21. package/lib/lan-gate-server.cjs +29 -3
  22. package/lib/merge-streams.js +42 -12
  23. package/lib/merge-streams.js.map +1 -1
  24. package/lib/relay-client.js +71 -0
  25. package/lib/relay-client.js.map +1 -1
  26. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  27. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  28. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  29. package/lib/types/admin-routes.d.ts +13 -0
  30. package/lib/types/admin-routes.d.ts.map +1 -1
  31. package/lib/types/client/index.d.ts.map +1 -1
  32. package/lib/types/client/locales.d.ts +204 -1
  33. package/lib/types/client/locales.d.ts.map +1 -1
  34. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  35. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  37. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  39. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  41. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  43. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  45. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  46. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  47. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  48. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  49. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  50. package/lib/types/client-data/qr.d.ts +20 -0
  51. package/lib/types/client-data/qr.d.ts.map +1 -0
  52. package/lib/types/client-data/remote-status.d.ts +39 -2
  53. package/lib/types/client-data/remote-status.d.ts.map +1 -1
  54. package/lib/types/client-data/settings-form.d.ts +7 -2
  55. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  56. package/lib/types/client-data/wizard.d.ts +247 -0
  57. package/lib/types/client-data/wizard.d.ts.map +1 -0
  58. package/lib/types/client-routes.d.ts +19 -0
  59. package/lib/types/client-routes.d.ts.map +1 -1
  60. package/lib/types/config.d.ts +27 -0
  61. package/lib/types/config.d.ts.map +1 -1
  62. package/lib/types/index.d.ts.map +1 -1
  63. package/lib/types/intercept.d.ts.map +1 -1
  64. package/lib/types/merge-streams.d.ts.map +1 -1
  65. package/lib/types/relay-client.d.ts +10 -0
  66. package/lib/types/relay-client.d.ts.map +1 -1
  67. package/lib/types/wizard/binary-io.d.ts +111 -0
  68. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  69. package/lib/types/wizard/binary.d.ts +118 -0
  70. package/lib/types/wizard/binary.d.ts.map +1 -0
  71. package/lib/types/wizard/caddy-process.d.ts +101 -0
  72. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  73. package/lib/types/wizard/caddy.d.ts +53 -0
  74. package/lib/types/wizard/caddy.d.ts.map +1 -0
  75. package/lib/types/wizard/cloudflare.d.ts +112 -0
  76. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  77. package/lib/types/wizard/cloudflared.d.ts +71 -0
  78. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  79. package/lib/types/wizard/probe.d.ts +66 -0
  80. package/lib/types/wizard/probe.d.ts.map +1 -0
  81. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  82. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  83. package/lib/types/wizard/routes.d.ts +152 -0
  84. package/lib/types/wizard/routes.d.ts.map +1 -0
  85. package/lib/types/wizard/source-detect.d.ts +53 -0
  86. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  87. package/lib/types/wizard/status.d.ts +115 -0
  88. package/lib/types/wizard/status.d.ts.map +1 -0
  89. package/lib/types/wizard/token-store.d.ts +27 -0
  90. package/lib/types/wizard/token-store.d.ts.map +1 -0
  91. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  92. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  93. package/lib/types/wizard/verify.d.ts +97 -0
  94. package/lib/types/wizard/verify.d.ts.map +1 -0
  95. package/lib/wizard/binary-io.js +386 -0
  96. package/lib/wizard/binary-io.js.map +1 -0
  97. package/lib/wizard/binary.js +138 -0
  98. package/lib/wizard/binary.js.map +1 -0
  99. package/lib/wizard/caddy-process.js +162 -0
  100. package/lib/wizard/caddy-process.js.map +1 -0
  101. package/lib/wizard/caddy.js +80 -0
  102. package/lib/wizard/caddy.js.map +1 -0
  103. package/lib/wizard/cloudflare.js +334 -0
  104. package/lib/wizard/cloudflare.js.map +1 -0
  105. package/lib/wizard/cloudflared.js +158 -0
  106. package/lib/wizard/cloudflared.js.map +1 -0
  107. package/lib/wizard/probe.js +118 -0
  108. package/lib/wizard/probe.js.map +1 -0
  109. package/lib/wizard/proxy-snippets.js +112 -0
  110. package/lib/wizard/proxy-snippets.js.map +1 -0
  111. package/lib/wizard/routes.js +462 -0
  112. package/lib/wizard/routes.js.map +1 -0
  113. package/lib/wizard/source-detect.js +77 -0
  114. package/lib/wizard/source-detect.js.map +1 -0
  115. package/lib/wizard/status.js +94 -0
  116. package/lib/wizard/status.js.map +1 -0
  117. package/lib/wizard/token-store.js +71 -0
  118. package/lib/wizard/token-store.js.map +1 -0
  119. package/lib/wizard/tunnel-orchestrator.js +155 -0
  120. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  121. package/lib/wizard/verify.js +171 -0
  122. package/lib/wizard/verify.js.map +1 -0
  123. package/package.json +37 -37
  124. package/scripts/check-settings-form.mjs +5 -4
  125. package/scripts/check-wizard-ui.mjs +690 -0
  126. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  127. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  128. package/src/admin-routes.ts +44 -2
  129. package/src/client/index.tsx +9 -0
  130. package/src/client/locales.ts +410 -2
  131. package/src/client/wizard/GatewayWizard.tsx +2220 -0
  132. package/src/client/wizard/WizardCard.tsx +81 -0
  133. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  134. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  135. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  136. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  137. package/src/client/wizard/register-wizard.ts +99 -0
  138. package/src/client/wizard/wizard-css.ts +948 -0
  139. package/src/client-data/qr.ts +284 -0
  140. package/src/client-data/remote-status.ts +128 -2
  141. package/src/client-data/settings-form.ts +12 -3
  142. package/src/client-data/wizard.ts +432 -0
  143. package/src/client-routes.ts +53 -0
  144. package/src/config.ts +87 -0
  145. package/src/index.ts +227 -2
  146. package/src/intercept.ts +43 -3
  147. package/src/merge-streams.ts +41 -12
  148. package/src/relay-client.ts +76 -0
  149. package/src/wizard/binary-io.ts +449 -0
  150. package/src/wizard/binary.ts +177 -0
  151. package/src/wizard/caddy-process.ts +228 -0
  152. package/src/wizard/caddy.ts +97 -0
  153. package/src/wizard/cloudflare.ts +413 -0
  154. package/src/wizard/cloudflared.ts +196 -0
  155. package/src/wizard/probe.ts +161 -0
  156. package/src/wizard/proxy-snippets.ts +128 -0
  157. package/src/wizard/routes.ts +537 -0
  158. package/src/wizard/source-detect.ts +87 -0
  159. package/src/wizard/status.ts +180 -0
  160. package/src/wizard/token-store.ts +73 -0
  161. package/src/wizard/tunnel-orchestrator.ts +197 -0
  162. package/src/wizard/verify.ts +236 -0
@@ -0,0 +1,2220 @@
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 must bind to `0.0.0.0`
604
+ // rather than loopback-only `127.0.0.1`.
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
+ await props.saveRowFields({
622
+ ...baseFields,
623
+ ingressMode: path,
624
+ ingressDomain: domain.trim(),
625
+ ingressHttpsPort: readiness.httpsPort,
626
+ })
627
+ }
628
+ // If this machine was previously in client mode or just rebound
629
+ // `host` to `0.0.0.0` for LAN access, wait for the restart-watcher to
630
+ // reload the plugin row and respawn the local gateway child process
631
+ // before saving the token or probing.
632
+ if (wasClient || needsLanHostBind) {
633
+ setProbedClient(false)
634
+ if (needsLanHostBind && !wasClient) {
635
+ // Give the restart-watcher time to observe the `host` change and
636
+ // restart the gateway child on 0.0.0.0.
637
+ await new Promise((resolve) => setTimeout(resolve, 900))
638
+ }
639
+ for (let i = 0; i < 6; i++) {
640
+ await new Promise((resolve) => setTimeout(resolve, 650))
641
+ try {
642
+ const res = await fetch(ADMIN_STATUS_ROUTE)
643
+ if (res.ok) {
644
+ const body = (await res.json()) as AdminStatusBody
645
+ if (body?.ok === true && body.gatewayReachable === true) break
646
+ }
647
+ } catch {
648
+ // keep waiting for gateway startup
649
+ }
650
+ }
651
+ await loadHostStatus()
652
+ }
653
+ } catch {
654
+ // Even if the row mutation is pending mirror sync, proceed to token save / verify.
655
+ }
656
+ if ((path === 'caddy' || path === 'cloudflared') && cfToken.trim() !== '') {
657
+ const saved = await saveTokenValue(cfToken.trim())
658
+ if (!saved) return
659
+ }
660
+ // Mode-switch process synchronization: stop any managed ingress process
661
+ // that does not belong to the newly saved path so an old tunnel or
662
+ // Caddy child never keeps running across a mode change.
663
+ if (path !== 'caddy') {
664
+ fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' }).catch(() => {})
665
+ setCaddyState('stopped')
666
+ setCaddyStartError(undefined)
667
+ }
668
+ if (path !== 'cloudflared') {
669
+ fetch(WIZARD_TUNNEL_STOP_ROUTE, { method: 'POST' }).catch(() => {})
670
+ setTunnelState('stopped')
671
+ setTunnelResult(undefined)
672
+ setTunnelCheck(undefined)
673
+ setAdoptTunnelId(undefined)
674
+ }
675
+ await loadHostStatus()
676
+ setHostSavedInSession(true)
677
+ setStep('verify')
678
+ } finally {
679
+ setSavingConfig(false)
680
+ }
681
+ }
682
+
683
+ const startVerification = async (): Promise<void> => {
684
+ const publicUrl = resolvedVerifyUrl
685
+ setNotWired(false)
686
+ setWebPairRequested(true)
687
+ setProbingUrl(true)
688
+ try {
689
+ const response = await fetch(WIZARD_VERIFY_ROUTE, {
690
+ method: 'POST',
691
+ headers: { 'Content-Type': 'application/json' },
692
+ body: JSON.stringify({ publicUrl }),
693
+ })
694
+ if (response.status === 501) {
695
+ setNotWired(true)
696
+ return
697
+ }
698
+ const body = (await response.json()) as WizardVerifyStart
699
+ if (body.ok) setGatewayReachable(true)
700
+ setStartResult(body)
701
+ setPoll(body.ok ? { result: 'not-started' } : undefined)
702
+ } catch {
703
+ setStartResult({ ok: false, reason: 'gateway-unreachable' })
704
+ setPoll(undefined)
705
+ } finally {
706
+ setProbingUrl(false)
707
+ }
708
+ }
709
+
710
+ const runDesktopPair = async (): Promise<void> => {
711
+ setDesktopPairBusy(true)
712
+ setDesktopCopied(false)
713
+ try {
714
+ const response = await fetch(ADMIN_PAIR_ROUTE, {
715
+ method: 'POST',
716
+ headers: { 'Content-Type': 'application/json' },
717
+ body: JSON.stringify({ role: 'desktop-client' }),
718
+ })
719
+ if (response.ok) {
720
+ const body = (await response.json()) as { ok?: unknown; code?: unknown; expiresAt?: unknown }
721
+ if (body?.ok === true && typeof body.code === 'string' && typeof body.expiresAt === 'number') {
722
+ setDesktopPairing({ code: body.code, expiresAt: body.expiresAt })
723
+ }
724
+ }
725
+ } catch {
726
+ // ignore network error; user can retry
727
+ } finally {
728
+ setDesktopPairBusy(false)
729
+ }
730
+ }
731
+
732
+ const resolvedHostEndpoint = useMemo(() => {
733
+ if (path === 'external' || path === 'lan') {
734
+ const trimmed = serverUrl.trim()
735
+ if (trimmed !== '') return trimmed
736
+ }
737
+ if (readiness.ok && readiness.publicUrl !== '') return readiness.publicUrl
738
+ if (wizardStatus?.publicUrl !== undefined && wizardStatus.publicUrl !== '') return wizardStatus.publicUrl
739
+ const port = upstreamPortOf(wizardStatus)
740
+ if (wizardStatus?.lanAddress !== undefined && wizardStatus.lanAddress !== '') {
741
+ return `http://${wizardStatus.lanAddress}:${port}`
742
+ }
743
+ return `http://<服务端局域网IP>:${port}`
744
+ }, [path, serverUrl, readiness, wizardStatus])
745
+
746
+ const copyDesktopInfo = async (): Promise<void> => {
747
+ if (desktopPairing === undefined) return
748
+ const text = `服务端地址:${resolvedHostEndpoint}\n桌面客户端配对码:${desktopPairing.code}`
749
+ try {
750
+ await navigator.clipboard?.writeText(text)
751
+ setDesktopCopied(true)
752
+ } catch {
753
+ // ignore clipboard refusal
754
+ }
755
+ }
756
+
757
+ const runClientClaim = async (): Promise<void> => {
758
+ setFormError(null)
759
+ setClientClaimFail(null)
760
+ const trimmedUrl = clientServerUrl.trim()
761
+ const normalizedCode = normalizePairingCode(clientPairCode)
762
+ if (trimmedUrl === '' || !/^https?:\/\//i.test(trimmedUrl)) {
763
+ setFormError('bad-client-url')
764
+ return
765
+ }
766
+ if (normalizedCode === '') {
767
+ setFormError('bad-client-code')
768
+ return
769
+ }
770
+ setClientClaimBusy(true)
771
+ try {
772
+ // If the deployment is currently running as 'host', switch role to 'client'
773
+ // first so the backend restart-watcher reloads the plugin into client mode
774
+ // and registers CLIENT_CLAIM_ROUTE.
775
+ if (!effectiveClient) {
776
+ try {
777
+ await props.saveRowFields({
778
+ role: 'client',
779
+ ...(clientDeviceName.trim() !== '' ? { serverName: clientDeviceName.trim() } : {}),
780
+ })
781
+ await new Promise((resolve) => setTimeout(resolve, 1600))
782
+ } catch {
783
+ // proceed to claim attempt
784
+ }
785
+ }
786
+ const deviceName = claimDeviceNameOf(clientDeviceName, t('settings.client.deviceNameDefault'))
787
+ const response = await fetch(CLIENT_CLAIM_ROUTE, {
788
+ method: 'POST',
789
+ headers: { 'Content-Type': 'application/json' },
790
+ body: JSON.stringify({
791
+ serverUrl: trimmedUrl,
792
+ code: normalizedCode,
793
+ deviceName,
794
+ }),
795
+ })
796
+ const body = (await response.json()) as ClaimRouteBody
797
+ if (body?.ok === true && typeof body.token === 'string' && body.token !== '') {
798
+ const urlToWrite = typeof body.serverUrl === 'string' && body.serverUrl !== '' ? body.serverUrl : trimmedUrl
799
+ const echoed = typeof body.deviceName === 'string' && body.deviceName.trim() !== '' ? body.deviceName.trim() : deviceName
800
+ await config.writeClientPairing(urlToWrite, body.token, echoed)
801
+ setClientPairCode('')
802
+ await loadClientStatus()
803
+ setStep('verify')
804
+ } else {
805
+ const failReason = body && typeof body.code === 'string' ? body.code : 'unexpected'
806
+ const retryAfterMs = body && typeof body.retryAfterMs === 'number' ? body.retryAfterMs : undefined
807
+ setClientClaimFail(retryAfterMs === undefined ? { code: failReason } : { code: failReason, retryAfterMs })
808
+ }
809
+ } catch {
810
+ setClientClaimFail({ code: 'unreachable' })
811
+ } finally {
812
+ setClientClaimBusy(false)
813
+ }
814
+ }
815
+
816
+ const runClientReconnect = async (): Promise<void> => {
817
+ setClientReconnectBusy(true)
818
+ try {
819
+ await fetch(CLIENT_RECONNECT_ROUTE, { method: 'POST' })
820
+ await loadClientStatus()
821
+ } catch {
822
+ // ignore
823
+ } finally {
824
+ setClientReconnectBusy(false)
825
+ }
826
+ }
827
+
828
+ const runClientUnpair = async (): Promise<void> => {
829
+ setClientUnpairBusy(true)
830
+ try {
831
+ await config.clearDeviceToken()
832
+ await loadClientStatus()
833
+ setStep('configure')
834
+ } catch {
835
+ // ignore
836
+ } finally {
837
+ setClientUnpairBusy(false)
838
+ }
839
+ }
840
+
841
+ // Managed-Caddy & Tunnel controls (GW-13a / GW-13b): keyed strictly off the
842
+ // currently selected `path` so switching away from Tunnel or Caddy never
843
+ // leaves the previous mode's status bar or notices on screen.
844
+ const caddyPath = wizardRole === 'host' && path === 'caddy'
845
+ const tunnelPath = wizardRole === 'host' && path === 'cloudflared'
846
+ const pollIngressState = async (route: string, apply: (state: CaddyState) => void): Promise<void> => {
847
+ try {
848
+ const response = await fetch(route)
849
+ if (!response.ok) return
850
+ const body = (await response.json()) as { state?: unknown }
851
+ if (body?.state === 'running' || body?.state === 'stopped' || body?.state === 'never-started') apply(body.state)
852
+ } catch {
853
+ // keep the last state
854
+ }
855
+ }
856
+ useEffect(() => {
857
+ if (step !== 'verify' || !caddyPath) return
858
+ const beat = (): Promise<void> => pollIngressState(WIZARD_CADDY_STATUS_ROUTE, setCaddyState)
859
+ void beat()
860
+ const handle = setInterval(() => { void beat() }, 3000)
861
+ return () => clearInterval(handle)
862
+ }, [step, caddyPath])
863
+ useEffect(() => {
864
+ if (step !== 'verify' || !tunnelPath) return
865
+ const beat = (): Promise<void> => pollIngressState(WIZARD_TUNNEL_STATUS_ROUTE, setTunnelState)
866
+ void beat()
867
+ const handle = setInterval(() => { void beat() }, 3000)
868
+ return () => clearInterval(handle)
869
+ }, [step, tunnelPath])
870
+
871
+ const startCaddy = async (): Promise<void> => {
872
+ setCaddyStartError(undefined)
873
+ setCaddyBusy(true)
874
+ try {
875
+ const parsedPort = Number(httpsPort.trim())
876
+ const response = await fetch(WIZARD_CADDY_START_ROUTE, {
877
+ method: 'POST',
878
+ headers: { 'Content-Type': 'application/json' },
879
+ body: JSON.stringify({
880
+ domain: domain.trim(),
881
+ ...(Number.isSafeInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? { httpsPort: parsedPort } : {}),
882
+ }),
883
+ })
884
+ const body = (await response.json()) as { ok?: unknown; error?: unknown; detail?: unknown; installUrl?: unknown }
885
+ if (body?.ok === true) {
886
+ setCaddyState('running')
887
+ setTunnelState('stopped')
888
+ return
889
+ }
890
+ if (response.status === 501) {
891
+ setNotWired(true)
892
+ return
893
+ }
894
+ setCaddyStartError(caddyFailureCopy(String(body?.error ?? ''), typeof body?.detail === 'string' ? body.detail : undefined, typeof body?.installUrl === 'string' ? body.installUrl : undefined))
895
+ } catch {
896
+ setCaddyStartError({ key: 'wizard.caddy.failSpawn', params: {} })
897
+ } finally {
898
+ setCaddyBusy(false)
899
+ }
900
+ }
901
+
902
+ const stopCaddy = async (): Promise<void> => {
903
+ try {
904
+ await fetch(WIZARD_CADDY_STOP_ROUTE, { method: 'POST' })
905
+ setCaddyState('stopped')
906
+ } catch {
907
+ // the 3 s poll will correct the label
908
+ }
909
+ }
910
+
911
+ // Tunnel path (GW-13b): the check-domain probe (the migration card's
912
+ // re-check) and the full setup. A conflict carries the same-name list —
913
+ // each entry offers adoption, which re-runs setup with its tunnelId.
914
+ const checkDomain = async (): Promise<void> => {
915
+ const targetDomain = path === 'external' ? serverUrl.trim() : domain.trim()
916
+ setTunnelCheck(undefined)
917
+ try {
918
+ const response = await fetch(WIZARD_TUNNEL_CHECK_ROUTE, {
919
+ method: 'POST',
920
+ headers: { 'Content-Type': 'application/json' },
921
+ body: JSON.stringify({ domain: targetDomain }),
922
+ })
923
+ const body = (await response.json()) as TunnelCheckWire
924
+ setTunnelCheck(body)
925
+ } catch {
926
+ setTunnelCheck({ ok: false, reason: 'network' })
927
+ }
928
+ }
929
+
930
+ const setupTunnelNow = async (tunnelId?: string): Promise<void> => {
931
+ setTunnelBusy(true)
932
+ try {
933
+ const response = await fetch(WIZARD_TUNNEL_SETUP_ROUTE, {
934
+ method: 'POST',
935
+ headers: { 'Content-Type': 'application/json' },
936
+ body: JSON.stringify({ domain: domain.trim(), ...(tunnelId !== undefined ? { tunnelId } : {}) }),
937
+ })
938
+ const body = (await response.json()) as TunnelSetupWire
939
+ setTunnelResult(body)
940
+ if (body.ok) {
941
+ setTunnelState('running')
942
+ setCaddyState('stopped')
943
+ }
944
+ } catch {
945
+ setTunnelResult({ ok: false, reason: 'network' })
946
+ } finally {
947
+ setTunnelBusy(false)
948
+ }
949
+ }
950
+
951
+ // Only show on the bundle detail page (never on a component row page).
952
+ const subject = (props as { subject?: { kind?: string; pkg?: { name?: string } } }).subject
953
+ if (subject !== undefined && (subject.kind === 'row' || (subject.pkg?.name !== undefined && subject.pkg.name !== 'dsh-zen-remote'))) {
954
+ return null
955
+ }
956
+
957
+ const baseHostLights = deriveWizardLights({ gatewayReachable, poll, startResult })
958
+ const lights = pairTarget === 'desktop-client' && desktopDevices.length > 0
959
+ ? { ...baseHostLights, pairing: 'pass' as const }
960
+ : baseHostLights
961
+ const clientLights = deriveClientWizardLights({
962
+ tokenConfigured: roleSnapshot.deviceToken.configured || clientView?.state === 'connected',
963
+ clientState: clientView?.state,
964
+ interceptInstalled: clientView?.intercept?.installed,
965
+ })
966
+ const verified = poll?.result === 'pass'
967
+ const qr = startResult?.ok === true ? qrMatrix(startResult.publicUrl) : null
968
+
969
+ const stepLabel = (name: WizardStep): string => t(name === 'pick' ? 'wizard.step.pick' : name === 'configure' ? 'wizard.step.configure' : 'wizard.step.verify')
970
+ const currentStepIndex = STEP_ORDER.indexOf(step)
971
+ const pathName = (value: WizardPath): string => t(
972
+ value === 'caddy'
973
+ ? 'wizard.path.caddy'
974
+ : value === 'external'
975
+ ? 'wizard.path.external'
976
+ : value === 'cloudflared'
977
+ ? 'wizard.path.cloudflared'
978
+ : 'wizard.path.lan',
979
+ )
980
+
981
+ const hasSavedHostConfig = hostSavedInSession
982
+ || config.rowValue('role') === 'host'
983
+ || (typeof config.rowValue('ingressPublicUrl') === 'string' && (config.rowValue('ingressPublicUrl') as string).trim() !== '')
984
+ const configured = isWizardConfigured({
985
+ effectiveClient,
986
+ wizardStatus,
987
+ pairedDeviceCount: webDeviceCount + desktopDevices.length,
988
+ hasSavedHostConfig,
989
+ tokenConfigured: roleSnapshot.deviceToken.configured,
990
+ clientServerUrl,
991
+ clientState: clientView?.state,
992
+ })
993
+
994
+ const statusSummary = (() => {
995
+ if (effectiveClient) {
996
+ if (clientView?.state === 'connected') {
997
+ const label = clientView.serverName !== '' ? clientView.serverName : (clientView.serverUrl || t('wizard.role.client'))
998
+ return { state: 'pass' as const, text: `${t('wizard.role.client')} · ${label}` }
999
+ }
1000
+ if (roleSnapshot.deviceToken.configured) {
1001
+ return { state: 'pending' as const, text: t('wizard.role.client') }
1002
+ }
1003
+ return { state: 'idle' as const, text: t('settings.client.statusUnpaired') }
1004
+ }
1005
+ if (wizardStatus === undefined || wizardStatus.mode === 'off') {
1006
+ if (configured && gatewayReachable) {
1007
+ return { state: 'pass' as const, text: t('wizard.path.lan') }
1008
+ }
1009
+ return { state: 'idle' as const, text: t('wizard.off') }
1010
+ }
1011
+ if (wizardStatus.mode === 'caddy') {
1012
+ const running = caddyState === 'running' || (caddyState === undefined && wizardStatus.caddyRunning)
1013
+ return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.caddy.running' : 'wizard.caddy.stopped') }
1014
+ }
1015
+ if (wizardStatus.mode === 'cloudflared') {
1016
+ const running = tunnelState === 'running' || (tunnelState === undefined && wizardStatus.cloudflaredRunning)
1017
+ return { state: running ? 'pass' as const : 'pending' as const, text: t(running ? 'wizard.tunnel.running' : 'wizard.tunnel.stopped') }
1018
+ }
1019
+ return { state: 'pass' as const, text: wizardStatus.publicUrl || t('wizard.path.external') }
1020
+ })()
1021
+
1022
+ const openWizardAt = (targetStep: WizardStep, options?: { role?: WizardRole; target?: 'web' | 'desktop-client' }): void => {
1023
+ setFormError(null)
1024
+ if (options?.role !== undefined) setWizardRole(options.role)
1025
+ if (options?.target !== undefined) setPairTarget(options.target)
1026
+ setStep(targetStep)
1027
+ setModalOpen(true)
1028
+ }
1029
+
1030
+ return (
1031
+ <div data-zen-remote="wizard">
1032
+ <div className="zr-wizard-header">
1033
+ <div className="zr-wizard-headerText">
1034
+ <p className="zr-wizard-title">{t('wizard.title')}</p>
1035
+ <p className="zr-wizard-subtitle">{t('wizard.subtitle')}</p>
1036
+ </div>
1037
+ <div className="zr-wizard-statusBadge" data-state={statusSummary.state}>
1038
+ <span className="zr-wizard-dot" data-state={statusSummary.state === 'pass' ? 'pass' : 'pending'} />
1039
+ <span>{statusSummary.text}</span>
1040
+ </div>
1041
+ </div>
1042
+
1043
+ {statusLoaded && viaGateway && !effectiveClient ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.remoteOnly')}</p> : null}
1044
+ {notWired ? <p className="zr-wizard-notice">{t('wizard.notWired')}</p> : null}
1045
+
1046
+ {configured ? (
1047
+ effectiveClient ? (
1048
+ <>
1049
+ <div className="zr-wizard-overviewGrid">
1050
+ <div className="zr-wizard-overviewItem">
1051
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.roleLabel')}</p>
1052
+ <p className="zr-wizard-overviewValue">{t('wizard.role.client')}</p>
1053
+ <p className="zr-wizard-overviewSub">{clientDeviceName.trim() !== '' ? clientDeviceName : t('settings.client.deviceNameDefault')}</p>
1054
+ </div>
1055
+ <div className="zr-wizard-overviewItem">
1056
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientCredLabel')}</p>
1057
+ <p className="zr-wizard-overviewValue">
1058
+ <span className="zr-wizard-dot" data-state={clientLights.credential} />
1059
+ <span>{roleSnapshot.deviceToken.configured ? t('settings.client.tokenSet') : t('settings.client.statusUnpaired')}</span>
1060
+ </p>
1061
+ <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.credential])}</p>
1062
+ </div>
1063
+ <div className="zr-wizard-overviewItem">
1064
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientConnLabel')}</p>
1065
+ <p className="zr-wizard-overviewValue">
1066
+ <span className="zr-wizard-dot" data-state={clientLights.connection} />
1067
+ <span>
1068
+ {clientView === undefined
1069
+ ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1070
+ : clientStatusCopy(clientView, now, t)}
1071
+ </span>
1072
+ </p>
1073
+ <p className="zr-wizard-overviewSub">{t(LIGHT_LABEL[clientLights.connection])}</p>
1074
+ </div>
1075
+ <div className="zr-wizard-overviewItem">
1076
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.clientServerLabel')}</p>
1077
+ <p className="zr-wizard-overviewValue">
1078
+ <span className="zr-wizard-dot" data-state={clientLights.sync} />
1079
+ <span>{clientView?.serverUrl || clientServerUrl || '—'}</span>
1080
+ </p>
1081
+ <p className="zr-wizard-overviewSub">{t('wizard.client.lightSync')}:{t(LIGHT_LABEL[clientLights.sync])}</p>
1082
+ </div>
1083
+ </div>
1084
+
1085
+ <div className="zr-wizard-footer">
1086
+ <div className="zr-wizard-row">
1087
+ <button
1088
+ type="button"
1089
+ className="zr-wizard-btn"
1090
+ disabled={clientLoading}
1091
+ onClick={() => { void loadClientStatus() }}
1092
+ >
1093
+ {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1094
+ </button>
1095
+ {clientView?.nextRetryAt !== undefined ? (
1096
+ <button
1097
+ type="button"
1098
+ className="zr-wizard-btn"
1099
+ disabled={clientReconnectBusy}
1100
+ onClick={() => { void runClientReconnect() }}
1101
+ >
1102
+ {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1103
+ </button>
1104
+ ) : null}
1105
+ </div>
1106
+ <div className="zr-wizard-row">
1107
+ <button
1108
+ type="button"
1109
+ className="zr-wizard-btn"
1110
+ onClick={() => openWizardAt('pick', { role: 'client' })}
1111
+ >
1112
+ {t('wizard.overview.reconfigure')}
1113
+ </button>
1114
+ <button
1115
+ type="button"
1116
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1117
+ onClick={() => openWizardAt(roleSnapshot.deviceToken.configured ? 'verify' : 'configure', { role: 'client' })}
1118
+ >
1119
+ {t('wizard.overview.clientDetail')}
1120
+ </button>
1121
+ </div>
1122
+ </div>
1123
+ </>
1124
+ ) : (
1125
+ <>
1126
+ <div className="zr-wizard-overviewGrid">
1127
+ <div className="zr-wizard-overviewItem">
1128
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.roleLabel')}</p>
1129
+ <p className="zr-wizard-overviewValue">{t('wizard.role.host')}</p>
1130
+ <p className="zr-wizard-overviewSub">{hostName.trim() !== '' ? hostName : t('settings.sourceDefault')}</p>
1131
+ </div>
1132
+ <div className="zr-wizard-overviewItem">
1133
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.gatewayLabel')}</p>
1134
+ <p className="zr-wizard-overviewValue">
1135
+ <span className="zr-wizard-dot" data-state={gatewayReachable ? 'pass' : 'fail'} />
1136
+ <span>
1137
+ {gatewayReachable
1138
+ ? t('wizard.overview.gatewayReady', { port: String(upstreamPortOf(wizardStatus)) })
1139
+ : t('wizard.overview.gatewayDown')}
1140
+ </span>
1141
+ </p>
1142
+ <p className="zr-wizard-overviewSub">{autoShare ? t('wizard.autoShare') : t('settings.fieldAutoShare')}</p>
1143
+ </div>
1144
+ <div className="zr-wizard-overviewItem">
1145
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.ingressLabel')}</p>
1146
+ <p className="zr-wizard-overviewValue">
1147
+ <span className="zr-wizard-dot" data-state={statusSummary.state === 'pass' ? 'pass' : 'pending'} />
1148
+ <span>{pathName(path)}</span>
1149
+ </p>
1150
+ <p className="zr-wizard-overviewSub">{resolvedHostEndpoint}</p>
1151
+ </div>
1152
+ <div className="zr-wizard-overviewItem">
1153
+ <div className="zr-wizard-overviewHead">
1154
+ <p className="zr-wizard-overviewLabel">{t('wizard.overview.devicesLabel')}</p>
1155
+ <button
1156
+ type="button"
1157
+ className="zr-wizard-linkBtn"
1158
+ disabled={disabled}
1159
+ onClick={() => {
1160
+ setDeviceError(false)
1161
+ setRenamingDevice(null)
1162
+ setDevicesModalOpen(true)
1163
+ }}
1164
+ >
1165
+ {t('wizard.overview.manageDevices')}
1166
+ </button>
1167
+ </div>
1168
+ <p className="zr-wizard-overviewValue">
1169
+ <span className="zr-wizard-dot" data-state={allDevices.length > 0 ? 'pass' : 'pending'} />
1170
+ <span>
1171
+ {allDevices.length > 0
1172
+ ? t('wizard.overview.devicesSummary', { web: String(webDeviceCount), desktop: String(desktopDevices.length) })
1173
+ : t('wizard.overview.devicesNone')}
1174
+ </span>
1175
+ </p>
1176
+ <p className="zr-wizard-overviewSub">{statusSummary.text}</p>
1177
+ </div>
1178
+ </div>
1179
+
1180
+ <div className="zr-wizard-footer">
1181
+ <div className="zr-wizard-row">
1182
+ <button
1183
+ type="button"
1184
+ className="zr-wizard-btn"
1185
+ disabled={disabled}
1186
+ onClick={() => openWizardAt('pick', { role: 'host' })}
1187
+ >
1188
+ {t('wizard.overview.reconfigure')}
1189
+ </button>
1190
+ <button
1191
+ type="button"
1192
+ className="zr-wizard-btn"
1193
+ disabled={disabled}
1194
+ onClick={() => {
1195
+ setDeviceError(false)
1196
+ setRenamingDevice(null)
1197
+ setDevicesModalOpen(true)
1198
+ }}
1199
+ >
1200
+ {t('wizard.overview.manageDevices')}
1201
+ </button>
1202
+ </div>
1203
+ <div className="zr-wizard-row">
1204
+ <button
1205
+ type="button"
1206
+ className="zr-wizard-btn"
1207
+ disabled={disabled}
1208
+ onClick={() => openWizardAt('verify', { role: 'host', target: 'desktop-client' })}
1209
+ >
1210
+ {t('wizard.overview.pairDesktop')}
1211
+ </button>
1212
+ <button
1213
+ type="button"
1214
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1215
+ disabled={disabled}
1216
+ onClick={() => openWizardAt('verify', { role: 'host', target: 'web' })}
1217
+ >
1218
+ {t('wizard.overview.pairPhone')}
1219
+ </button>
1220
+ </div>
1221
+ </div>
1222
+ </>
1223
+ )
1224
+ ) : (
1225
+ <div className="zr-wizard-emptyBanner">
1226
+ <p className="zr-wizard-emptyText">{t('wizard.overview.emptyIntro')}</p>
1227
+ <button
1228
+ type="button"
1229
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1230
+ disabled={disabled}
1231
+ onClick={() => openWizardAt('pick')}
1232
+ >
1233
+ {t('wizard.overview.startSetup')}
1234
+ </button>
1235
+ </div>
1236
+ )}
1237
+
1238
+ {modalOpen ? (
1239
+ <div
1240
+ data-zen-remote="wizard-modal"
1241
+ onClick={() => {
1242
+ if (!savingConfig && !clientClaimBusy) setModalOpen(false)
1243
+ }}
1244
+ >
1245
+ <div
1246
+ className="zr-wizard-modalDialog"
1247
+ role="dialog"
1248
+ aria-modal="true"
1249
+ aria-label={t('wizard.modal.title')}
1250
+ onClick={(event) => event.stopPropagation()}
1251
+ >
1252
+ <div className="zr-wizard-modalHeader">
1253
+ <p className="zr-wizard-modalTitle">{t('wizard.modal.title')}</p>
1254
+ <button
1255
+ type="button"
1256
+ className="zr-wizard-cardCloseIcon"
1257
+ aria-label={t('wizard.card.close')}
1258
+ disabled={savingConfig || clientClaimBusy}
1259
+ onClick={() => setModalOpen(false)}
1260
+ >
1261
+ ×
1262
+ </button>
1263
+ </div>
1264
+
1265
+ <ol className="zr-wizard-stepper" aria-label={t('wizard.modal.title')}>
1266
+ {STEP_ORDER.map((name, index) => {
1267
+ const state = currentStepIndex > index ? 'done' : currentStepIndex === index ? 'current' : 'upcoming'
1268
+ return (
1269
+ <li key={name} style={{ display: 'contents' }}>
1270
+ <div className="zr-wizard-stepItem" data-state={state} aria-current={state === 'current' ? 'step' : undefined}>
1271
+ <span className="zr-wizard-stepCircle" aria-hidden="true">
1272
+ {state === 'done' ? (
1273
+ <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
1274
+ <path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
1275
+ </svg>
1276
+ ) : (
1277
+ index + 1
1278
+ )}
1279
+ </span>
1280
+ <div className="zr-wizard-stepText">
1281
+ <span className="zr-wizard-stepTag">{t('wizard.modal.stepTag', { num: String(index + 1) })}</span>
1282
+ <span className="zr-wizard-stepName">{stepLabel(name)}</span>
1283
+ </div>
1284
+ </div>
1285
+ {index < STEP_ORDER.length - 1 ? (
1286
+ <div className="zr-wizard-stepConnector" data-done={currentStepIndex > index} aria-hidden="true" />
1287
+ ) : null}
1288
+ </li>
1289
+ )
1290
+ })}
1291
+ </ol>
1292
+
1293
+ {step === 'pick' ? (
1294
+ <>
1295
+ <div className="zr-wizard-modalBody">
1296
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step1Desc')}</p>
1297
+ <p className="zr-wizard-sectionTitle">{t('wizard.role.sectionTitle')}</p>
1298
+ <div className="zr-wizard-roleGrid" role="radiogroup" aria-label={t('wizard.role.sectionTitle')}>
1299
+ {ROLE_OPTIONS.map(([roleValue, nameKey, descKey]) => {
1300
+ const selected = wizardRole === roleValue
1301
+ return (
1302
+ <button
1303
+ key={roleValue}
1304
+ type="button"
1305
+ role="radio"
1306
+ aria-checked={selected}
1307
+ className="zr-wizard-path"
1308
+ data-selected={selected}
1309
+ disabled={disabled}
1310
+ onClick={() => {
1311
+ setFormError(null)
1312
+ setWizardRole(roleValue)
1313
+ }}
1314
+ >
1315
+ <div className="zr-wizard-pathHead">
1316
+ <span className="zr-wizard-pathRadio" data-selected={selected} aria-hidden="true" />
1317
+ <span className="zr-wizard-pathName">{t(nameKey)}</span>
1318
+ </div>
1319
+ <p className="zr-wizard-pathDesc">{t(descKey)}</p>
1320
+ </button>
1321
+ )
1322
+ })}
1323
+ </div>
1324
+
1325
+ {wizardRole === 'host' ? (
1326
+ <>
1327
+ <p className="zr-wizard-sectionTitle">{t('wizard.ingressSectionTitle')}</p>
1328
+ <div className="zr-wizard-paths" role="radiogroup" aria-label={t('wizard.ingressSectionTitle')}>
1329
+ {PATH_OPTIONS.map(([value, nameKey, descKey]) => {
1330
+ const selected = path === value
1331
+ return (
1332
+ <button
1333
+ key={value}
1334
+ type="button"
1335
+ role="radio"
1336
+ aria-checked={selected}
1337
+ className="zr-wizard-path"
1338
+ data-selected={selected}
1339
+ disabled={disabled}
1340
+ onClick={() => {
1341
+ selectPath(value)
1342
+ }}
1343
+ onDoubleClick={() => {
1344
+ if (disabled) return
1345
+ selectPath(value)
1346
+ setStep(stepAfterPick())
1347
+ }}
1348
+ >
1349
+ <div className="zr-wizard-pathHead">
1350
+ <span className="zr-wizard-pathRadio" data-selected={selected} aria-hidden="true" />
1351
+ <span className="zr-wizard-pathName">{t(nameKey)}</span>
1352
+ </div>
1353
+ <p className="zr-wizard-pathDesc">{t(descKey)}</p>
1354
+ </button>
1355
+ )
1356
+ })}
1357
+ </div>
1358
+ </>
1359
+ ) : (
1360
+ <p className="zr-wizard-notice">{t('wizard.role.clientPickNote')}</p>
1361
+ )}
1362
+ </div>
1363
+
1364
+ <div className="zr-wizard-modalFooter">
1365
+ <button
1366
+ type="button"
1367
+ className="zr-wizard-btn"
1368
+ onClick={() => setModalOpen(false)}
1369
+ >
1370
+ {t('wizard.modal.cancel')}
1371
+ </button>
1372
+ <div className="zr-wizard-row">
1373
+ <span className="zr-wizard-footerHint">
1374
+ {wizardRole === 'host'
1375
+ ? `${t('wizard.role.host')} · ${pathName(path)}`
1376
+ : t('wizard.role.client')}
1377
+ </span>
1378
+ <button
1379
+ type="button"
1380
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1381
+ disabled={disabled}
1382
+ onClick={() => {
1383
+ setFormError(null)
1384
+ setStep(stepAfterPick())
1385
+ }}
1386
+ >
1387
+ {t('wizard.next')}
1388
+ </button>
1389
+ </div>
1390
+ </div>
1391
+ </>
1392
+ ) : null}
1393
+
1394
+ {step === 'configure' ? (
1395
+ <>
1396
+ <div className="zr-wizard-modalBody">
1397
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step2Desc')}</p>
1398
+ <div className="zr-wizard-selectedBar">
1399
+ <span className="zr-wizard-selectedLabel">
1400
+ {t('wizard.modal.selectedPlan', {
1401
+ plan: wizardRole === 'host'
1402
+ ? `${t('wizard.role.host')} · ${pathName(path)}`
1403
+ : t('wizard.role.client'),
1404
+ })}
1405
+ </span>
1406
+ </div>
1407
+
1408
+ {wizardRole === 'host' ? (
1409
+ <>
1410
+ <div className="zr-wizard-formStack">
1411
+ <div className="zr-wizard-field">
1412
+ <label className="zr-wizard-label" htmlFor="zr-wizard-host-name">{t('wizard.hostName')}</label>
1413
+ <input
1414
+ id="zr-wizard-host-name"
1415
+ className="zr-wizard-input"
1416
+ type="text"
1417
+ placeholder={t('wizard.hostNamePlaceholder')}
1418
+ value={hostName}
1419
+ disabled={disabled}
1420
+ onChange={(event) => setHostName(event.target.value)}
1421
+ />
1422
+ <p className="zr-wizard-hint">{t('wizard.hostNameHint')}</p>
1423
+ </div>
1424
+
1425
+ <label className="zr-wizard-checkRow">
1426
+ <input
1427
+ type="checkbox"
1428
+ checked={autoShare}
1429
+ disabled={disabled}
1430
+ onChange={(event) => setAutoShare(event.target.checked)}
1431
+ />
1432
+ <span>{t('wizard.autoShare')}</span>
1433
+ </label>
1434
+ </div>
1435
+
1436
+ {path === 'lan' ? (
1437
+ <div className="zr-wizard-field">
1438
+ <label className="zr-wizard-label" htmlFor="zr-wizard-lan-url">{t('wizard.lanUrl')}</label>
1439
+ <input
1440
+ id="zr-wizard-lan-url"
1441
+ className="zr-wizard-input"
1442
+ type="url"
1443
+ placeholder={t('wizard.lanUrlPlaceholder')}
1444
+ value={serverUrl}
1445
+ disabled={disabled}
1446
+ onChange={(event) => setServerUrl(event.target.value)}
1447
+ />
1448
+ <p className="zr-wizard-hint">{t('wizard.lanUrlHint')}</p>
1449
+ </div>
1450
+ ) : path === 'external' ? (
1451
+ <div className="zr-wizard-field">
1452
+ <label className="zr-wizard-label" htmlFor="zr-wizard-server-url">{t('wizard.serverUrl')}</label>
1453
+ <input
1454
+ id="zr-wizard-server-url"
1455
+ className="zr-wizard-input"
1456
+ type="url"
1457
+ placeholder="https://dsh.example.com"
1458
+ value={serverUrl}
1459
+ disabled={disabled}
1460
+ onChange={(event) => setServerUrl(event.target.value)}
1461
+ />
1462
+ <p className="zr-wizard-hint">{t('wizard.serverUrlHint')}</p>
1463
+ <div className="zr-wizard-row">
1464
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('proxy')}>
1465
+ {t('wizard.proxySnippet')}
1466
+ </button>
1467
+ </div>
1468
+ </div>
1469
+ ) : (
1470
+ <div className="zr-wizard-formStack">
1471
+ <div className="zr-wizard-formGrid">
1472
+ <div className="zr-wizard-field">
1473
+ <label className="zr-wizard-label" htmlFor="zr-wizard-domain">{t('wizard.domain')}</label>
1474
+ <input
1475
+ id="zr-wizard-domain"
1476
+ className="zr-wizard-input"
1477
+ type="text"
1478
+ placeholder="dsh.example.com"
1479
+ value={domain}
1480
+ disabled={disabled}
1481
+ onChange={(event) => setDomain(event.target.value)}
1482
+ />
1483
+ <p className="zr-wizard-hint">{t('wizard.domainHint')}</p>
1484
+ </div>
1485
+ <div className="zr-wizard-field zr-wizard-fieldPort">
1486
+ <label className="zr-wizard-label" htmlFor="zr-wizard-port">{t('wizard.httpsPort')}</label>
1487
+ <input
1488
+ id="zr-wizard-port"
1489
+ className="zr-wizard-input"
1490
+ type="text"
1491
+ inputMode="numeric"
1492
+ value={httpsPort}
1493
+ disabled={disabled}
1494
+ onChange={(event) => setHttpsPort(event.target.value)}
1495
+ />
1496
+ </div>
1497
+ </div>
1498
+
1499
+ {path === 'caddy' && isValidPort(httpsPort.trim()) && httpsPort.trim() !== '443' ? (
1500
+ <div className="zr-wizard-row">
1501
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('portforward')}>
1502
+ {t('wizard.portForwardEntry')}
1503
+ </button>
1504
+ </div>
1505
+ ) : null}
1506
+
1507
+ <div className="zr-wizard-field">
1508
+ <div className="zr-wizard-labelRow">
1509
+ <label className="zr-wizard-label" htmlFor="zr-wizard-token">{t('wizard.cfToken')}</label>
1510
+ {tokenSaved ? <span className="zr-wizard-savedTag">{t('wizard.token.saved')}</span> : null}
1511
+ </div>
1512
+ <input
1513
+ id="zr-wizard-token"
1514
+ className="zr-wizard-input"
1515
+ type="password"
1516
+ placeholder={tokenSaved ? '••••••••••••••••' : ''}
1517
+ value={cfToken}
1518
+ disabled={disabled}
1519
+ onChange={(event) => setCfToken(event.target.value)}
1520
+ />
1521
+ <p className="zr-wizard-hint">{t('wizard.cfTokenHint')}</p>
1522
+ <div className="zr-wizard-row">
1523
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('token')}>
1524
+ {t('wizard.cfTokenHowTo')}
1525
+ </button>
1526
+ {path === 'cloudflared' ? (
1527
+ <button type="button" className="zr-wizard-linkBtn" disabled={disabled} onClick={() => setCard('migrate')}>
1528
+ {t('wizard.migrateEntry')}
1529
+ </button>
1530
+ ) : null}
1531
+ </div>
1532
+ </div>
1533
+ </div>
1534
+ )}
1535
+
1536
+ {tokenNotice === 'failed' ? (
1537
+ <p className="zr-wizard-notice" data-tone="error">
1538
+ {t('wizard.token.saveFailed')}
1539
+ </p>
1540
+ ) : null}
1541
+ {formError !== null ? (
1542
+ <p className="zr-wizard-notice" data-tone="error">
1543
+ {t(formError === 'bad-domain' ? 'wizard.badDomain' : formError === 'bad-port' ? 'wizard.badPort' : formError === 'missing-token' ? 'wizard.missingToken' : 'wizard.badServerUrl')}
1544
+ </p>
1545
+ ) : null}
1546
+ </>
1547
+ ) : (
1548
+ <>
1549
+ <div className="zr-wizard-formStack">
1550
+ <div className="zr-wizard-field">
1551
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-device">{t('wizard.client.deviceName')}</label>
1552
+ <input
1553
+ id="zr-wizard-client-device"
1554
+ className="zr-wizard-input"
1555
+ type="text"
1556
+ placeholder={t('wizard.client.deviceNamePlaceholder')}
1557
+ value={clientDeviceName}
1558
+ disabled={clientClaimBusy}
1559
+ onChange={(event) => setClientDeviceName(event.target.value)}
1560
+ />
1561
+ <p className="zr-wizard-hint">{t('wizard.client.deviceNameHint')}</p>
1562
+ </div>
1563
+
1564
+ <div className="zr-wizard-field">
1565
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-url">{t('wizard.client.serverUrl')}</label>
1566
+ <input
1567
+ id="zr-wizard-client-url"
1568
+ className="zr-wizard-input"
1569
+ type="url"
1570
+ placeholder={t('wizard.client.serverUrlPlaceholder')}
1571
+ value={clientServerUrl}
1572
+ disabled={clientClaimBusy}
1573
+ onChange={(event) => setClientServerUrl(event.target.value)}
1574
+ />
1575
+ <p className="zr-wizard-hint">{t('wizard.client.serverUrlHint')}</p>
1576
+ </div>
1577
+
1578
+ <div className="zr-wizard-field">
1579
+ <label className="zr-wizard-label" htmlFor="zr-wizard-client-code">{t('wizard.client.pairingCode')}</label>
1580
+ <input
1581
+ id="zr-wizard-client-code"
1582
+ className="zr-wizard-input"
1583
+ type="text"
1584
+ autoComplete="off"
1585
+ spellCheck={false}
1586
+ placeholder={t('wizard.client.pairingCodePlaceholder')}
1587
+ value={clientPairCode}
1588
+ disabled={clientClaimBusy}
1589
+ onChange={(event) => setClientPairCode(normalizePairingCode(event.target.value))}
1590
+ />
1591
+ <p className="zr-wizard-hint">{t('wizard.client.pairingCodeHint')}</p>
1592
+ </div>
1593
+ </div>
1594
+
1595
+ {formError === 'bad-client-url' ? (
1596
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.client.failInvalid')}</p>
1597
+ ) : null}
1598
+ {formError === 'bad-client-code' ? (
1599
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.client.failBadCode')}</p>
1600
+ ) : null}
1601
+ {clientClaimFail !== null ? (
1602
+ <p className="zr-wizard-notice" data-tone="error">{claimFailCopy(clientClaimFail.code, clientClaimFail.retryAfterMs, t)}</p>
1603
+ ) : null}
1604
+ </>
1605
+ )}
1606
+ </div>
1607
+
1608
+ <div className="zr-wizard-modalFooter">
1609
+ <button
1610
+ type="button"
1611
+ className="zr-wizard-btn"
1612
+ disabled={disabled || savingConfig || clientClaimBusy}
1613
+ onClick={() => {
1614
+ setFormError(null)
1615
+ setStep('pick')
1616
+ }}
1617
+ >
1618
+ {t('wizard.back')}
1619
+ </button>
1620
+ {wizardRole === 'host' ? (
1621
+ <button
1622
+ type="button"
1623
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1624
+ disabled={disabled || savingConfig}
1625
+ onClick={() => { void advanceFromConfigure() }}
1626
+ >
1627
+ {t('wizard.modal.saveAndNext')}
1628
+ </button>
1629
+ ) : (
1630
+ <div className="zr-wizard-row">
1631
+ {roleSnapshot.deviceToken.configured ? (
1632
+ <button type="button" className="zr-wizard-btn" disabled={clientClaimBusy} onClick={() => setStep('verify')}>
1633
+ {t('wizard.client.skipToStatus')}
1634
+ </button>
1635
+ ) : null}
1636
+ <button
1637
+ type="button"
1638
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1639
+ disabled={clientClaimBusy}
1640
+ onClick={() => { void runClientClaim() }}
1641
+ >
1642
+ {clientClaimBusy ? t('wizard.client.pairingBusy') : t('wizard.client.pairAndConnect')}
1643
+ </button>
1644
+ </div>
1645
+ )}
1646
+ </div>
1647
+ </>
1648
+ ) : null}
1649
+
1650
+ {step === 'verify' ? (
1651
+ <>
1652
+ <div className="zr-wizard-modalBody">
1653
+ <p className="zr-wizard-stepIntro">{t('wizard.modal.step3Desc')}</p>
1654
+ {wizardRole === 'host' ? (
1655
+ <>
1656
+ {caddyPath ? (
1657
+ <div className="zr-wizard-serviceBar" data-zen-remote="wizard-caddy-controls">
1658
+ <div className="zr-wizard-serviceInfo">
1659
+ <span className="zr-wizard-dot" data-state={caddyState === 'running' ? 'pass' : 'pending'} />
1660
+ <span className="zr-wizard-label">{t('wizard.ingress')}</span>
1661
+ <span className="zr-wizard-hint">
1662
+ {caddyBusy
1663
+ ? t('wizard.caddy.starting')
1664
+ : t(caddyState === 'running' ? 'wizard.caddy.running' : caddyState === 'stopped' ? 'wizard.caddy.stopped' : 'wizard.caddy.neverStarted')}
1665
+ </span>
1666
+ </div>
1667
+ <div className="zr-wizard-row">
1668
+ <button type="button" className="zr-wizard-btn zr-wizard-btnPrimary" disabled={disabled || caddyBusy || caddyState === 'running'} onClick={() => { void startCaddy() }}>
1669
+ {caddyBusy ? t('wizard.caddy.starting') : t('wizard.caddy.start')}
1670
+ </button>
1671
+ <button type="button" className="zr-wizard-btn" disabled={disabled || caddyBusy || caddyState !== 'running'} onClick={() => { void stopCaddy() }}>
1672
+ {t('wizard.caddy.stop')}
1673
+ </button>
1674
+ </div>
1675
+ </div>
1676
+ ) : null}
1677
+ {tunnelPath ? (
1678
+ <div className="zr-wizard-serviceBar" data-zen-remote="wizard-tunnel-controls">
1679
+ <div className="zr-wizard-serviceInfo">
1680
+ <span className="zr-wizard-dot" data-state={tunnelState === 'running' ? 'pass' : 'pending'} />
1681
+ <span className="zr-wizard-label">{t('wizard.ingress')}</span>
1682
+ <span className="zr-wizard-hint">
1683
+ {t(tunnelState === 'running' ? 'wizard.tunnel.running' : tunnelState === 'stopped' ? 'wizard.tunnel.stopped' : 'wizard.tunnel.neverStarted')}
1684
+ </span>
1685
+ </div>
1686
+ <div className="zr-wizard-row">
1687
+ <button type="button" className="zr-wizard-btn zr-wizard-btnPrimary" disabled={disabled || tunnelBusy} onClick={() => { void setupTunnelNow(adoptTunnelId) }}>
1688
+ {tunnelBusy ? t('wizard.tunnel.checking') : tunnelState === 'running' ? t('wizard.reverify') : t('wizard.tunnel.setup')}
1689
+ </button>
1690
+ <button type="button" className="zr-wizard-btn" disabled={disabled || tunnelState !== 'running'} onClick={() => { void (async () => {
1691
+ try {
1692
+ await fetch(WIZARD_TUNNEL_STOP_ROUTE, { method: 'POST' })
1693
+ setTunnelState('stopped')
1694
+ } catch { /* the poll corrects */ }
1695
+ })() }}>{t('wizard.tunnel.stop')}</button>
1696
+ </div>
1697
+ </div>
1698
+ ) : null}
1699
+ {tunnelPath && tunnelCheck !== undefined ? (() => {
1700
+ const copy = tunnelCheckCopy(tunnelCheck)
1701
+ return <p className="zr-wizard-notice" data-tone={copy.tone === 'error' ? 'error' : undefined}>
1702
+ {copy.key === 'wizard.check.failed'
1703
+ ? t('wizard.check.failed', { reason: tunnelCheck.ok ? '' : String(tunnelCheck.reason) })
1704
+ : t(copy.key as Parameters<typeof t>[0])}
1705
+ </p>
1706
+ })() : null}
1707
+ {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason === 'tunnel-conflict' && tunnelResult.existing !== undefined ? (
1708
+ <div className="zr-wizard-field">
1709
+ <p className="zr-wizard-label">{t('wizard.tunnel.conflict')}</p>
1710
+ <div className="zr-wizard-row">
1711
+ {tunnelResult.existing.map((tunnel) => (
1712
+ <button key={tunnel.id} type="button" className="zr-wizard-linkBtn" disabled={disabled || tunnelBusy}
1713
+ onClick={() => { setAdoptTunnelId(tunnel.id); void setupTunnelNow(tunnel.id) }}>
1714
+ {t('wizard.tunnel.useExisting')}:{tunnel.name}({tunnel.status})
1715
+ </button>
1716
+ ))}
1717
+ </div>
1718
+ </div>
1719
+ ) : null}
1720
+ {tunnelPath && tunnelResult !== undefined && tunnelResult.ok ? <p className="zr-wizard-notice">{t('wizard.tunnel.ok')}</p> : null}
1721
+ {tunnelPath && tunnelResult !== undefined && !tunnelResult.ok && tunnelResult.reason !== 'tunnel-conflict' ? (() => {
1722
+ const copy = tunnelFailureCopy(tunnelResult.reason, tunnelResult.detail)
1723
+ return <p className="zr-wizard-notice" data-tone="error">{t(copy.key as Parameters<typeof t>[0], copy.params)}</p>
1724
+ })() : null}
1725
+ {caddyPath && caddyStartError !== undefined ? (
1726
+ <p className="zr-wizard-notice" data-tone="error">
1727
+ {t('wizard.caddy.startFailed', { reason: t(caddyStartError.key as Parameters<typeof t>[0], caddyStartError.params) })}
1728
+ {caddyStartError.installUrl !== undefined ? (
1729
+ <> <a className="zr-wizard-link" href={caddyStartError.installUrl} target="_blank" rel="noreferrer">{caddyStartError.installUrl}</a></>
1730
+ ) : null}
1731
+ </p>
1732
+ ) : null}
1733
+
1734
+ <ul className="zr-wizard-lights">
1735
+ {LIGHT_ORDER.map(({ key, light }) => {
1736
+ const state = lights[light]
1737
+ const labelText = light === 'publicUrl' && probingUrl
1738
+ ? t('wizard.light.probing')
1739
+ : t(LIGHT_LABEL[state])
1740
+ return (
1741
+ <li key={key} className="zr-wizard-light" data-state={state}>
1742
+ <div className="zr-wizard-lightMain">
1743
+ <span className="zr-wizard-dot" data-state={state} />
1744
+ <span className="zr-wizard-lightName">{t(key)}</span>
1745
+ </div>
1746
+ <span className="zr-wizard-lightState" data-state={state}>{labelText}</span>
1747
+ </li>
1748
+ )
1749
+ })}
1750
+ </ul>
1751
+
1752
+ {startResult !== undefined && !startResult.ok ? (() => {
1753
+ const copy = startFailureCopy(startResult)
1754
+ return <p className="zr-wizard-notice" data-tone="error">{t('wizard.startFailed', { reason: t(copy.key, copy.params) })}</p>
1755
+ })() : null}
1756
+
1757
+ <div className="zr-wizard-targetTabs" role="tablist">
1758
+ <button
1759
+ type="button"
1760
+ role="tab"
1761
+ aria-selected={pairTarget === 'web'}
1762
+ className="zr-wizard-targetTab"
1763
+ data-selected={pairTarget === 'web'}
1764
+ disabled={disabled}
1765
+ onClick={() => setPairTarget('web')}
1766
+ >
1767
+ {t('wizard.pairTarget.web')}
1768
+ </button>
1769
+ <button
1770
+ type="button"
1771
+ role="tab"
1772
+ aria-selected={pairTarget === 'desktop-client'}
1773
+ className="zr-wizard-targetTab"
1774
+ data-selected={pairTarget === 'desktop-client'}
1775
+ disabled={disabled}
1776
+ onClick={() => setPairTarget('desktop-client')}
1777
+ >
1778
+ {t('wizard.pairTarget.desktop')}
1779
+ </button>
1780
+ </div>
1781
+
1782
+ {pairTarget === 'web' ? (
1783
+ <>
1784
+ {webPairRequested && startResult?.ok === true && startResult.pairingCode !== '' ? (
1785
+ <div className="zr-wizard-pairPanel">
1786
+ <svg
1787
+ className="zr-wizard-qr"
1788
+ data-usable={qr !== null}
1789
+ role="img"
1790
+ aria-label={t('wizard.qrHint')}
1791
+ shapeRendering="crispEdges"
1792
+ viewBox={qr === null ? undefined : `-16 -16 ${(qr.length + 8) * 4} ${(qr.length + 8) * 4}`}
1793
+ >
1794
+ {qr === null ? null : (
1795
+ <>
1796
+ <rect x={-16} y={-16} width={(qr.length + 8) * 4} height={(qr.length + 8) * 4} fill="#fff" />
1797
+ <path d={qrSvgPath(qr, 4)} fill="#000" />
1798
+ </>
1799
+ )}
1800
+ </svg>
1801
+ <div className="zr-wizard-pairMeta">
1802
+ <span className="zr-wizard-label">{t('wizard.pairingCode')}</span>
1803
+ <p className="zr-wizard-code">{startResult.pairingCode}</p>
1804
+ <p className="zr-wizard-countdown">{t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(startResult.expiresAt, now)) })}</p>
1805
+ <p className="zr-wizard-hint">{t('wizard.qrHint')}</p>
1806
+ </div>
1807
+ </div>
1808
+ ) : null}
1809
+
1810
+ {poll?.result === 'pass' ? <p className="zr-wizard-notice" data-tone="ok">{t('wizard.verifyPass', { deviceId: poll.deviceId })}</p> : null}
1811
+ {poll?.result === 'cookie-insecure' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyCookieInsecure')}</p> : null}
1812
+ {poll?.result === 'code-expired' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyExpired')}</p> : null}
1813
+ {poll?.result === 'stale' ? <p className="zr-wizard-notice" data-tone="error">{t('wizard.verifyStale')}</p> : null}
1814
+ {webPairRequested && (poll?.result === 'pending' || poll?.result === 'not-started') ? <p className="zr-wizard-hint">{t('wizard.verifyPending')}</p> : null}
1815
+ {!webPairRequested && poll === undefined ? <p className="zr-wizard-hint">{t('wizard.verifyNotStarted')}</p> : null}
1816
+ </>
1817
+ ) : (
1818
+ <div className="zr-wizard-desktopBox">
1819
+ <p className="zr-wizard-hint">{t('wizard.desktop.guide')}</p>
1820
+ <div className="zr-wizard-field">
1821
+ <span className="zr-wizard-label">{t('wizard.desktop.addressLabel')}</span>
1822
+ <code className="zr-wizard-input">{resolvedHostEndpoint}</code>
1823
+ </div>
1824
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? (
1825
+ <div className="zr-wizard-pairMeta">
1826
+ <span className="zr-wizard-label">{t('wizard.pairingCode')}</span>
1827
+ <p className="zr-wizard-code">{desktopPairing.code}</p>
1828
+ <p className="zr-wizard-countdown">
1829
+ {t('wizard.pairingSeconds', { seconds: String(pairingSecondsLeft(desktopPairing.expiresAt, now)) })}
1830
+ </p>
1831
+ </div>
1832
+ ) : null}
1833
+ <div className="zr-wizard-row">
1834
+ <button
1835
+ type="button"
1836
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1837
+ disabled={disabled || desktopPairBusy}
1838
+ onClick={() => { void runDesktopPair() }}
1839
+ >
1840
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? t('wizard.desktop.regenerate') : t('wizard.desktop.generate')}
1841
+ </button>
1842
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? (
1843
+ <button
1844
+ type="button"
1845
+ className="zr-wizard-btn"
1846
+ disabled={disabled}
1847
+ onClick={() => { void copyDesktopInfo() }}
1848
+ >
1849
+ {desktopCopied ? t('wizard.card.copied') : t('wizard.desktop.copyInfo')}
1850
+ </button>
1851
+ ) : null}
1852
+ </div>
1853
+ </div>
1854
+ )}
1855
+ </>
1856
+ ) : (
1857
+ <>
1858
+ <ul className="zr-wizard-lights">
1859
+ {CLIENT_LIGHT_ORDER.map(({ key, light }) => (
1860
+ <li key={key} className="zr-wizard-light" data-state={clientLights[light]}>
1861
+ <div className="zr-wizard-lightMain">
1862
+ <span className="zr-wizard-dot" data-state={clientLights[light]} />
1863
+ <span className="zr-wizard-lightName">{t(key)}</span>
1864
+ </div>
1865
+ <span className="zr-wizard-lightState" data-state={clientLights[light]}>{t(LIGHT_LABEL[clientLights[light]])}</span>
1866
+ </li>
1867
+ ))}
1868
+ </ul>
1869
+
1870
+ <div className="zr-wizard-clientBox">
1871
+ <p className="zr-wizard-label">{t('settings.client.statusTitle')}</p>
1872
+ <p
1873
+ className="zr-wizard-notice"
1874
+ data-tone={clientView?.state === 'connected' ? 'ok' : clientLights.connection === 'fail' ? 'error' : undefined}
1875
+ >
1876
+ {clientView === undefined
1877
+ ? (clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusUnpaired'))
1878
+ : clientStatusCopy(clientView, now, t)}
1879
+ </p>
1880
+ <div className="zr-wizard-row">
1881
+ <button
1882
+ type="button"
1883
+ className="zr-wizard-btn"
1884
+ disabled={clientLoading}
1885
+ onClick={() => { void loadClientStatus() }}
1886
+ >
1887
+ {clientLoading ? t('settings.client.statusRefreshing') : t('settings.client.statusRefresh')}
1888
+ </button>
1889
+ {clientView?.nextRetryAt !== undefined ? (
1890
+ <button
1891
+ type="button"
1892
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1893
+ disabled={clientReconnectBusy}
1894
+ onClick={() => { void runClientReconnect() }}
1895
+ >
1896
+ {clientReconnectBusy ? t('settings.client.reconnectBusy') : t('settings.client.reconnect')}
1897
+ </button>
1898
+ ) : null}
1899
+ {roleSnapshot.deviceToken.configured ? (
1900
+ <button
1901
+ type="button"
1902
+ className="zr-wizard-btn"
1903
+ disabled={clientUnpairBusy}
1904
+ onClick={() => { void runClientUnpair() }}
1905
+ >
1906
+ {clientUnpairBusy ? t('settings.client.unpairBusy') : t('settings.client.unpair')}
1907
+ </button>
1908
+ ) : null}
1909
+ </div>
1910
+ </div>
1911
+ </>
1912
+ )}
1913
+ </div>
1914
+
1915
+ <div className="zr-wizard-modalFooter">
1916
+ <button
1917
+ type="button"
1918
+ className="zr-wizard-btn"
1919
+ disabled={disabled}
1920
+ onClick={() => setStep('configure')}
1921
+ >
1922
+ {t('wizard.back')}
1923
+ </button>
1924
+ <div className="zr-wizard-row">
1925
+ {wizardRole === 'host' && resolvedVerifyUrl !== '' ? (
1926
+ <button
1927
+ type="button"
1928
+ className="zr-wizard-btn"
1929
+ disabled={disabled || probingUrl}
1930
+ onClick={() => { void probeAccessUrl(resolvedVerifyUrl) }}
1931
+ >
1932
+ {probingUrl ? t('wizard.light.probing') : t('wizard.devicesModal.retest')}
1933
+ </button>
1934
+ ) : null}
1935
+ {wizardRole === 'host' && pairTarget === 'web' ? (
1936
+ <button
1937
+ type="button"
1938
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1939
+ disabled={disabled || probingUrl}
1940
+ onClick={() => { void startVerification() }}
1941
+ >
1942
+ {verified ? t('wizard.reverify') : t('wizard.startVerify')}
1943
+ </button>
1944
+ ) : null}
1945
+ {wizardRole === 'host' && pairTarget === 'desktop-client' ? (
1946
+ <button
1947
+ type="button"
1948
+ className="zr-wizard-btn zr-wizard-btnPrimary"
1949
+ disabled={disabled || desktopPairBusy}
1950
+ onClick={() => { void runDesktopPair() }}
1951
+ >
1952
+ {desktopPairing !== undefined && desktopPairing.expiresAt > now ? t('wizard.desktop.regenerate') : t('wizard.desktop.generate')}
1953
+ </button>
1954
+ ) : null}
1955
+ <button
1956
+ type="button"
1957
+ className="zr-wizard-btn"
1958
+ onClick={() => setModalOpen(false)}
1959
+ >
1960
+ {t('wizard.modal.finish')}
1961
+ </button>
1962
+ </div>
1963
+ </div>
1964
+ </>
1965
+ ) : null}
1966
+ </div>
1967
+ </div>
1968
+ ) : null}
1969
+
1970
+ {devicesModalOpen ? (
1971
+ <div
1972
+ data-zen-remote="wizard-modal"
1973
+ onClick={() => {
1974
+ if (!deviceBusy) setDevicesModalOpen(false)
1975
+ }}
1976
+ >
1977
+ <div
1978
+ className="zr-wizard-modalDialog"
1979
+ role="dialog"
1980
+ aria-modal="true"
1981
+ aria-label={t('wizard.devicesModal.title')}
1982
+ onClick={(event) => event.stopPropagation()}
1983
+ >
1984
+ <div className="zr-wizard-modalHeader">
1985
+ <div className="zr-wizard-headerText">
1986
+ <p className="zr-wizard-modalTitle">{t('wizard.devicesModal.title')}</p>
1987
+ <p className="zr-wizard-subtitle">{t('wizard.devicesModal.subtitle')}</p>
1988
+ </div>
1989
+ <button
1990
+ type="button"
1991
+ className="zr-wizard-cardCloseIcon"
1992
+ aria-label={t('wizard.card.close')}
1993
+ disabled={deviceBusy}
1994
+ onClick={() => setDevicesModalOpen(false)}
1995
+ >
1996
+ ×
1997
+ </button>
1998
+ </div>
1999
+
2000
+ <div className="zr-wizard-modalBody">
2001
+ {deviceError ? (
2002
+ <p className="zr-wizard-notice" data-tone="error">{t('settings.actionFail')}</p>
2003
+ ) : null}
2004
+
2005
+ {allDevices.length === 0 ? (
2006
+ <p className="zr-wizard-notice">{t('settings.devicesEmpty')}</p>
2007
+ ) : (
2008
+ <ul className="zr-wizard-deviceList">
2009
+ {allDevices.map((device) => (
2010
+ <li key={device.id} className="zr-wizard-deviceItem">
2011
+ <div className="zr-wizard-deviceHead">
2012
+ <div className="zr-wizard-deviceTitleRow">
2013
+ <span className="zr-wizard-lightName">{device.name}</span>
2014
+ <span className="zr-wizard-deviceBadge">
2015
+ {device.role === 'web' ? t('settings.deviceRoleWeb') : t('settings.deviceRoleDesktop')}
2016
+ </span>
2017
+ {device.role === 'web' ? (
2018
+ <span className="zr-wizard-deviceBadge">
2019
+ {device.hasPush ? t('settings.devicePushOn') : t('settings.devicePushOff')}
2020
+ </span>
2021
+ ) : null}
2022
+ </div>
2023
+ <span className="zr-wizard-hint">
2024
+ {t('settings.deviceLastSeen', { time: new Date(device.lastSeen).toLocaleString() })}
2025
+ </span>
2026
+ </div>
2027
+
2028
+ <div className="zr-wizard-deviceActions">
2029
+ {device.role === 'web' ? (
2030
+ <select
2031
+ className="zr-wizard-select"
2032
+ aria-label={t('settings.deviceKindSelect', { name: device.name })}
2033
+ disabled={disabled || deviceBusy}
2034
+ value={device.kind}
2035
+ onChange={(event) => {
2036
+ void runDeviceAction({ action: 'set-kind', id: device.id, kind: event.currentTarget.value })
2037
+ }}
2038
+ >
2039
+ <option value="auto">{t('settings.kindAuto')}</option>
2040
+ <option value="phone">{t('settings.kindPhone')}</option>
2041
+ <option value="desktop">{t('settings.kindDesktop')}</option>
2042
+ </select>
2043
+ ) : null}
2044
+ <button
2045
+ type="button"
2046
+ className="zr-wizard-linkBtn"
2047
+ disabled={disabled || deviceBusy}
2048
+ onClick={() => setRenamingDevice({ id: device.id, draft: device.name })}
2049
+ >
2050
+ {t('settings.deviceRename')}
2051
+ </button>
2052
+ <button
2053
+ type="button"
2054
+ className="zr-wizard-linkBtn"
2055
+ disabled={disabled || deviceBusy}
2056
+ onClick={() => {
2057
+ if (window.confirm(t('settings.revokeConfirm'))) {
2058
+ void runDeviceAction({ action: 'revoke', id: device.id })
2059
+ }
2060
+ }}
2061
+ >
2062
+ {t('settings.deviceRevoke')}
2063
+ </button>
2064
+ </div>
2065
+
2066
+ {renamingDevice?.id === device.id ? (
2067
+ <div className="zr-wizard-row">
2068
+ <input
2069
+ className="zr-wizard-input"
2070
+ style={{ flex: 1, minWidth: 160 }}
2071
+ aria-label={t('settings.renamePrompt')}
2072
+ maxLength={40}
2073
+ value={renamingDevice.draft}
2074
+ autoFocus
2075
+ onChange={(event) => setRenamingDevice({ id: device.id, draft: event.currentTarget.value })}
2076
+ onKeyDown={(event) => {
2077
+ if (event.key === 'Enter') {
2078
+ const trimmed = renamingDevice.draft.trim()
2079
+ setRenamingDevice(null)
2080
+ if (trimmed !== '' && trimmed !== device.name) {
2081
+ void runDeviceAction({ action: 'rename', id: device.id, name: trimmed })
2082
+ }
2083
+ }
2084
+ if (event.key === 'Escape') setRenamingDevice(null)
2085
+ }}
2086
+ />
2087
+ <button
2088
+ type="button"
2089
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2090
+ disabled={deviceBusy}
2091
+ onClick={() => {
2092
+ const trimmed = renamingDevice.draft.trim()
2093
+ setRenamingDevice(null)
2094
+ if (trimmed !== '' && trimmed !== device.name) {
2095
+ void runDeviceAction({ action: 'rename', id: device.id, name: trimmed })
2096
+ }
2097
+ }}
2098
+ >
2099
+ {t('settings.renameSave')}
2100
+ </button>
2101
+ <button
2102
+ type="button"
2103
+ className="zr-wizard-btn"
2104
+ disabled={deviceBusy}
2105
+ onClick={() => setRenamingDevice(null)}
2106
+ >
2107
+ {t('settings.renameCancel')}
2108
+ </button>
2109
+ </div>
2110
+ ) : null}
2111
+ </li>
2112
+ ))}
2113
+ </ul>
2114
+ )}
2115
+ </div>
2116
+
2117
+ <div className="zr-wizard-modalFooter">
2118
+ <div className="zr-wizard-row">
2119
+ {allDevices.length > 0 ? (
2120
+ <button
2121
+ type="button"
2122
+ className="zr-wizard-btn"
2123
+ disabled={disabled || deviceBusy}
2124
+ onClick={() => {
2125
+ if (window.confirm(t('settings.revokeAllConfirm'))) {
2126
+ void runDeviceAction({ action: 'revoke-all' })
2127
+ }
2128
+ }}
2129
+ >
2130
+ {t('settings.revokeAll')}
2131
+ </button>
2132
+ ) : null}
2133
+ </div>
2134
+ <div className="zr-wizard-row">
2135
+ <button
2136
+ type="button"
2137
+ className="zr-wizard-btn"
2138
+ disabled={disabled || deviceBusy}
2139
+ onClick={() => {
2140
+ setDevicesModalOpen(false)
2141
+ openWizardAt('verify', { role: 'host', target: 'desktop-client' })
2142
+ }}
2143
+ >
2144
+ {t('wizard.overview.pairDesktop')}
2145
+ </button>
2146
+ <button
2147
+ type="button"
2148
+ className="zr-wizard-btn zr-wizard-btnPrimary"
2149
+ disabled={disabled || deviceBusy}
2150
+ onClick={() => {
2151
+ setDevicesModalOpen(false)
2152
+ openWizardAt('verify', { role: 'host', target: 'web' })
2153
+ }}
2154
+ >
2155
+ {t('wizard.overview.pairPhone')}
2156
+ </button>
2157
+ <button
2158
+ type="button"
2159
+ className="zr-wizard-btn"
2160
+ disabled={deviceBusy}
2161
+ onClick={() => setDevicesModalOpen(false)}
2162
+ >
2163
+ {t('wizard.card.close')}
2164
+ </button>
2165
+ </div>
2166
+ </div>
2167
+ </div>
2168
+ </div>
2169
+ ) : null}
2170
+
2171
+ {card === 'proxy' ? (
2172
+ <ProxyCard
2173
+ t={t}
2174
+ domain={(() => {
2175
+ if (domain.trim() !== '') return domain.trim()
2176
+ try {
2177
+ const parsed = new URL(serverUrl.trim())
2178
+ if (parsed.hostname !== '') return parsed.hostname
2179
+ } catch { /* fall back to placeholder */ }
2180
+ return 'dsh.example.com'
2181
+ })()}
2182
+ upstreamPort={upstreamPortOf(wizardStatus)}
2183
+ onCheck={() => {
2184
+ setStep('verify')
2185
+ void startVerification()
2186
+ }}
2187
+ onClose={() => setCard(undefined)}
2188
+ />
2189
+ ) : null}
2190
+
2191
+ {card === 'token' ? (
2192
+ <TokenCard
2193
+ t={t}
2194
+ onUse={(token) => {
2195
+ void (async () => {
2196
+ await saveTokenValue(token)
2197
+ setCard(undefined)
2198
+ })()
2199
+ }}
2200
+ onClose={() => setCard(undefined)}
2201
+ />
2202
+ ) : null}
2203
+
2204
+ {card === 'migrate' ? (
2205
+ <MigrateCard
2206
+ t={t}
2207
+ onRecheck={() => {
2208
+ setCard(undefined)
2209
+ void checkDomain()
2210
+ }}
2211
+ onClose={() => setCard(undefined)}
2212
+ />
2213
+ ) : null}
2214
+
2215
+ {card === 'portforward' ? (
2216
+ <PortForwardCard t={t} port={Number(httpsPort.trim()) || 8443} onClose={() => setCard(undefined)} />
2217
+ ) : null}
2218
+ </div>
2219
+ )
2220
+ }