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