dsh-zen-remote 2.3.10 → 2.4.0
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/CHANGELOG.md +44 -72
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +573 -366
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +575 -370
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-5: the process entry point behind `bin/zen-remote.mjs`. `runCli` turns
|
|
3
|
+
* a parsed argv into exactly one verdict: static answers (help, version,
|
|
4
|
+
* the SPEC's `--role peer` refusal, unknown commands) render here; every
|
|
5
|
+
* plugin-driving command first comes alive through `ensureDshRunning` and
|
|
6
|
+
* then dispatches into the CLI-3/CLI-4 command functions over the discovered
|
|
7
|
+
* IPC socket; `setup` owns its preflight (install + launch) and runs the
|
|
8
|
+
* three-step wizard.
|
|
9
|
+
*
|
|
10
|
+
* Two things belong ONLY here: the interactivity verdict for the whole run
|
|
11
|
+
* (TTY && !`--yes`, with the default readline prompter created lazily and —
|
|
12
|
+
* importantly — closed in the end, or an unanswered stdin handle would keep
|
|
13
|
+
* the process alive after the answer printed), and the structured error
|
|
14
|
+
* boundary that turns a MissingRequiredOptionError into one precise
|
|
15
|
+
* bilingual line instead of a stack trace. All dependencies are injectable
|
|
16
|
+
* so the tests drive the real entry function with fakes (SPEC's test seam
|
|
17
|
+
* three).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from 'node:fs'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { dirname, join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
import { parseCliArgs } from './args.js'
|
|
25
|
+
import type { ParsedCliArgs } from './args.js'
|
|
26
|
+
import { createReadlinePrompter, ensureDshRunning, isInteractiveSession, MissingRequiredOptionError } from './bootstrap.js'
|
|
27
|
+
import type { CliPrompter, ExecProbe } from './bootstrap.js'
|
|
28
|
+
import { discoverCliSockets } from './ipc-client.js'
|
|
29
|
+
import { CLI_PROTOCOL_VERSION } from './ipc-server.js'
|
|
30
|
+
import { createTranslator, resolveCliLang } from './i18n.js'
|
|
31
|
+
import type { CliTranslator } from './i18n.js'
|
|
32
|
+
import { runStatusCommand } from './commands-status.js'
|
|
33
|
+
import { runClientListCommand, runClientPairCommand, runClientUnpairCommand } from './commands-client.js'
|
|
34
|
+
import {
|
|
35
|
+
runHostDeviceListCommand,
|
|
36
|
+
runHostDevicePairCommand,
|
|
37
|
+
runHostDeviceRevokeCommand,
|
|
38
|
+
runHostIngressSnippetCommand,
|
|
39
|
+
runHostIngressStartCommand,
|
|
40
|
+
runHostIngressStopCommand,
|
|
41
|
+
runHostIngressVerifyCommand,
|
|
42
|
+
} from './commands-host.js'
|
|
43
|
+
import { runSetupCommand } from './command-setup.js'
|
|
44
|
+
import type { SetupCommandContext } from './command-setup.js'
|
|
45
|
+
|
|
46
|
+
export interface CliRuntimeDeps {
|
|
47
|
+
/** Environment for the language resolution and the `DSH_HOME` fallback. */
|
|
48
|
+
env?: NodeJS.ProcessEnv
|
|
49
|
+
/** The DSH home the sockets / profiles live under. Default
|
|
50
|
+
* `<env.DSH_HOME ?? ~/.dsh>` — the same resolution config.ts uses. */
|
|
51
|
+
dshHome?: string
|
|
52
|
+
/** Whether this run can prompt. Default `process.stdin.isTTY === true`. */
|
|
53
|
+
isTty?: boolean
|
|
54
|
+
stdout?: { write(text: string): void }
|
|
55
|
+
/** The ask-everything funnel; the default readline prompter is built
|
|
56
|
+
* lazily (and closed at the end) when a run may actually prompt. */
|
|
57
|
+
prompter?: CliPrompter
|
|
58
|
+
/** `--version`'s answer; default the package.json version next to lib/. */
|
|
59
|
+
packageVersion?: string
|
|
60
|
+
/** Bind-shape override (tests pin every platform branch). */
|
|
61
|
+
platform?: NodeJS.Platform
|
|
62
|
+
/** uid for the launchd `gui/<uid>/…` target. */
|
|
63
|
+
uid?: number
|
|
64
|
+
macosAppPath?: string
|
|
65
|
+
systemUnitDir?: string
|
|
66
|
+
/** Test seams, forwarded into the preflight / bootstrap. */
|
|
67
|
+
discover?: typeof discoverCliSockets
|
|
68
|
+
spawnCommand?: (command: string, args: readonly string[]) => Promise<number>
|
|
69
|
+
execProbe?: ExecProbe
|
|
70
|
+
now?: () => Date
|
|
71
|
+
cwd?: string
|
|
72
|
+
pollIntervalMs?: number
|
|
73
|
+
abortSignal?: AbortSignal
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readPackageVersion(): string {
|
|
77
|
+
try {
|
|
78
|
+
const parsed: unknown = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8'))
|
|
79
|
+
if (parsed !== null && typeof parsed === 'object') {
|
|
80
|
+
const version = (parsed as { version?: unknown }).version
|
|
81
|
+
if (typeof version === 'string') return version
|
|
82
|
+
}
|
|
83
|
+
} catch { /* packaged oddities fall back to a visible placeholder */ }
|
|
84
|
+
return 'unknown'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The complete bilingual command tree — the `help.command.*` /
|
|
88
|
+
* `help.option.*` dictionary keys ARE the tree's source of truth. */
|
|
89
|
+
function printHelp(t: CliTranslator, out: { write(text: string): void }): void {
|
|
90
|
+
const lines = [
|
|
91
|
+
t('help.title'),
|
|
92
|
+
'',
|
|
93
|
+
t('help.usage'),
|
|
94
|
+
'',
|
|
95
|
+
t('help.description'),
|
|
96
|
+
'',
|
|
97
|
+
t('help.commands'),
|
|
98
|
+
` ${t('help.command.setup')}`,
|
|
99
|
+
` ${t('help.command.status')}`,
|
|
100
|
+
` ${t('help.command.hostDevice')}`,
|
|
101
|
+
` ${t('help.command.hostIngress')}`,
|
|
102
|
+
` ${t('help.command.clientPair')}`,
|
|
103
|
+
` ${t('help.command.clientUnpair')}`,
|
|
104
|
+
` ${t('help.command.clientList')}`,
|
|
105
|
+
'',
|
|
106
|
+
t('help.options'),
|
|
107
|
+
...([
|
|
108
|
+
'lang', 'json', 'socket', 'wait', 'yes', 'noWait', 'debug', 'role', 'profile',
|
|
109
|
+
'ingress', 'domain', 'cfToken', 'email', 'httpsPort', 'publicUrl', 'tunnelId',
|
|
110
|
+
'proxy', 'deviceRole', 'hostUrl', 'code', 'name', 'all',
|
|
111
|
+
] as const).map((option) => ` ${t(`help.option.${option}`)}`),
|
|
112
|
+
'',
|
|
113
|
+
t('help.footer'),
|
|
114
|
+
]
|
|
115
|
+
out.write(`${lines.join('\n')}\n`)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The static verdicts that need no plugin and no IPC, in priority order. */
|
|
119
|
+
function staticVerdict(parsed: ParsedCliArgs, t: CliTranslator, out: { write(text: string): void }, packageVersion: string): number | undefined {
|
|
120
|
+
if (parsed.command === 'help') {
|
|
121
|
+
printHelp(t, out)
|
|
122
|
+
return 0
|
|
123
|
+
}
|
|
124
|
+
if (parsed.command === 'version') {
|
|
125
|
+
out.write(`${packageVersion}\n`)
|
|
126
|
+
return 0
|
|
127
|
+
}
|
|
128
|
+
if (parsed.options.roleIssue === 'peer-not-enabled') {
|
|
129
|
+
out.write(`${t('err.role.peerNotEnabled')}\n`)
|
|
130
|
+
return 1
|
|
131
|
+
}
|
|
132
|
+
if (parsed.options.roleIssue === 'invalid') {
|
|
133
|
+
out.write(`${t('err.role.invalid', { value: parsed.options.role ?? '' })}\n`)
|
|
134
|
+
return 1
|
|
135
|
+
}
|
|
136
|
+
if (parsed.command === 'unknown') {
|
|
137
|
+
out.write(`${t('err.unknownCommand', { command: parsed.unknownCommand ?? '' })}\n`)
|
|
138
|
+
return 1
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Run one CLI invocation (argv WITHOUT the node binary and script path) and
|
|
145
|
+
* answer its exit code. The bin entry assigns it to `process.exitCode`.
|
|
146
|
+
*/
|
|
147
|
+
export async function runCli(argv: readonly string[], deps?: CliRuntimeDeps): Promise<number> {
|
|
148
|
+
const d = deps ?? {}
|
|
149
|
+
const parsed = parseCliArgs(argv)
|
|
150
|
+
const env = d.env ?? process.env
|
|
151
|
+
const t = createTranslator(resolveCliLang(parsed.options.lang, env))
|
|
152
|
+
const out = d.stdout ?? process.stdout
|
|
153
|
+
const dshHome = d.dshHome ?? env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
154
|
+
const interactive = isInteractiveSession(parsed, d.isTty ?? process.stdin.isTTY === true)
|
|
155
|
+
|
|
156
|
+
// The default readline prompter is created lazily and owned by this run —
|
|
157
|
+
// an unclosed one would hold stdin open after the answer is printed.
|
|
158
|
+
let ownedPrompter: CliPrompter | undefined
|
|
159
|
+
const prompterFor = (): CliPrompter | undefined => {
|
|
160
|
+
if (!interactive) return undefined
|
|
161
|
+
if (d.prompter !== undefined) return d.prompter
|
|
162
|
+
if (ownedPrompter === undefined) ownedPrompter = createReadlinePrompter(undefined, undefined, t)
|
|
163
|
+
return ownedPrompter
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// The real Ctrl-C seam (context.ts's contract): without an injected signal,
|
|
167
|
+
// the process's SIGINT drives a run-owned AbortController, so the verify
|
|
168
|
+
// wait can skip gracefully. A SECOND Ctrl-C force-quits — the listener must
|
|
169
|
+
// never swallow Ctrl-C for phases that cannot react to the signal. The
|
|
170
|
+
// listener is removed in the end; nothing leaks across runs (or into the
|
|
171
|
+
// tests' process).
|
|
172
|
+
const controller = new AbortController()
|
|
173
|
+
const onSigint = (): void => {
|
|
174
|
+
if (controller.signal.aborted) {
|
|
175
|
+
process.exit(130)
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
controller.abort()
|
|
179
|
+
}
|
|
180
|
+
if (d.abortSignal === undefined) process.on('SIGINT', onSigint)
|
|
181
|
+
const abortSignal = d.abortSignal ?? controller.signal
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
const verdict = staticVerdict(parsed, t, out, d.packageVersion ?? readPackageVersion())
|
|
185
|
+
if (verdict !== undefined) return verdict
|
|
186
|
+
|
|
187
|
+
if (parsed.command === 'setup') {
|
|
188
|
+
const setupCtx: SetupCommandContext = {
|
|
189
|
+
args: parsed,
|
|
190
|
+
dshHome,
|
|
191
|
+
interactive,
|
|
192
|
+
t,
|
|
193
|
+
stdout: out,
|
|
194
|
+
...(interactive ? { prompter: prompterFor() } : {}),
|
|
195
|
+
...(d.pollIntervalMs === undefined ? {} : { pollIntervalMs: d.pollIntervalMs }),
|
|
196
|
+
abortSignal,
|
|
197
|
+
...(d.now === undefined ? {} : { now: d.now }),
|
|
198
|
+
...(d.cwd === undefined ? {} : { cwd: d.cwd }),
|
|
199
|
+
...(d.discover === undefined ? {} : { discover: d.discover }),
|
|
200
|
+
...(d.spawnCommand === undefined ? {} : { spawnCommand: d.spawnCommand }),
|
|
201
|
+
...(d.execProbe === undefined ? {} : { execProbe: d.execProbe }),
|
|
202
|
+
...(d.platform === undefined ? {} : { platform: d.platform }),
|
|
203
|
+
...(d.uid === undefined ? {} : { uid: d.uid }),
|
|
204
|
+
...(d.macosAppPath === undefined ? {} : { macosAppPath: d.macosAppPath }),
|
|
205
|
+
...(d.systemUnitDir === undefined ? {} : { systemUnitDir: d.systemUnitDir }),
|
|
206
|
+
}
|
|
207
|
+
return await runSetupCommand(setupCtx)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// status / host.* / client.*: a live plugin is the precondition — find
|
|
211
|
+
// it (a version-mismatching socket is its own refusal) and bind.
|
|
212
|
+
const discover = d.discover ?? discoverCliSockets
|
|
213
|
+
const platform = d.platform ?? process.platform
|
|
214
|
+
const found = await discover({
|
|
215
|
+
dshHome,
|
|
216
|
+
...(parsed.options.socket === undefined ? {} : { explicitSocket: parsed.options.socket }),
|
|
217
|
+
platform,
|
|
218
|
+
})
|
|
219
|
+
if (found.live.length === 0 && found.versionMismatch.length > 0) {
|
|
220
|
+
const mismatched = found.versionMismatch[0]
|
|
221
|
+
out.write(`${t('err.protocolVersion', {
|
|
222
|
+
found: String(mismatched?.cliProtocolVersion ?? '?'),
|
|
223
|
+
expected: CLI_PROTOCOL_VERSION,
|
|
224
|
+
})}\n`)
|
|
225
|
+
return 1
|
|
226
|
+
}
|
|
227
|
+
const up = await ensureDshRunning({
|
|
228
|
+
dshHome,
|
|
229
|
+
waitSeconds: parsed.options.wait,
|
|
230
|
+
...(parsed.options.socket === undefined ? {} : { explicitSocket: parsed.options.socket }),
|
|
231
|
+
...(d.platform === undefined ? {} : { platform: d.platform }),
|
|
232
|
+
...(d.uid === undefined ? {} : { uid: d.uid }),
|
|
233
|
+
...(d.execProbe === undefined ? {} : { execProbe: d.execProbe }),
|
|
234
|
+
discover,
|
|
235
|
+
...(d.macosAppPath === undefined ? {} : { macosAppPath: d.macosAppPath }),
|
|
236
|
+
...(d.systemUnitDir === undefined ? {} : { systemUnitDir: d.systemUnitDir }),
|
|
237
|
+
stdout: out,
|
|
238
|
+
t,
|
|
239
|
+
})
|
|
240
|
+
if (!up.started) return 1
|
|
241
|
+
const ipc = up.client
|
|
242
|
+
const prompter = prompterFor()
|
|
243
|
+
const ctx = {
|
|
244
|
+
args: parsed,
|
|
245
|
+
ipc,
|
|
246
|
+
t,
|
|
247
|
+
interactive,
|
|
248
|
+
...(prompter === undefined ? {} : { prompter }),
|
|
249
|
+
stdout: out,
|
|
250
|
+
...(d.now === undefined ? {} : { now: d.now }),
|
|
251
|
+
...(d.cwd === undefined ? {} : { cwd: d.cwd }),
|
|
252
|
+
...(d.pollIntervalMs === undefined ? {} : { pollIntervalMs: d.pollIntervalMs }),
|
|
253
|
+
abortSignal,
|
|
254
|
+
}
|
|
255
|
+
switch (parsed.command) {
|
|
256
|
+
case 'status': return await runStatusCommand(ctx)
|
|
257
|
+
case 'host.device.pair': return await runHostDevicePairCommand(ctx)
|
|
258
|
+
case 'host.device.list': return await runHostDeviceListCommand(ctx)
|
|
259
|
+
case 'host.device.revoke': return await runHostDeviceRevokeCommand(ctx)
|
|
260
|
+
case 'host.ingress.start': return await runHostIngressStartCommand(ctx)
|
|
261
|
+
case 'host.ingress.stop': return await runHostIngressStopCommand(ctx)
|
|
262
|
+
case 'host.ingress.verify': return await runHostIngressVerifyCommand(ctx)
|
|
263
|
+
case 'host.ingress.snippet': return await runHostIngressSnippetCommand(ctx)
|
|
264
|
+
case 'client.pair': return await runClientPairCommand(ctx)
|
|
265
|
+
case 'client.unpair': return await runClientUnpairCommand(ctx)
|
|
266
|
+
case 'client.list': return await runClientListCommand(ctx)
|
|
267
|
+
default: {
|
|
268
|
+
// staticVerdict already handled help / version / unknown / setup —
|
|
269
|
+
// the default arm is unreachable and only satisfies the exhaustiveness.
|
|
270
|
+
out.write(`${t('err.unknownCommand', { command: parsed.unknownCommand ?? '' })}\n`)
|
|
271
|
+
return 1
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof MissingRequiredOptionError) {
|
|
276
|
+
out.write(`${t('err.missingOption', { option: error.optionName })}\n`)
|
|
277
|
+
return 1
|
|
278
|
+
}
|
|
279
|
+
out.write(`${t('err.internal', { error: error instanceof Error ? error.message : String(error) })}\n`)
|
|
280
|
+
return 1
|
|
281
|
+
} finally {
|
|
282
|
+
if (d.abortSignal === undefined) process.removeListener('SIGINT', onSigint)
|
|
283
|
+
ownedPrompter?.close()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI-2 terminal QR rendering, zero dependencies: reuse the repo's own
|
|
3
|
+
* minimal encoder (`src/client-data/qr.ts`) and draw the module matrix with
|
|
4
|
+
* Unicode half-block characters — two module ROWS per terminal line, so the
|
|
5
|
+
* symbol stays square on screen (terminal cells are roughly twice as tall as
|
|
6
|
+
* they are wide):
|
|
7
|
+
*
|
|
8
|
+
* dark top + dark bottom → `█` dark top + light bottom → `▀`
|
|
9
|
+
* light top + dark bottom → `▄` light top + light bottom → ` `
|
|
10
|
+
*
|
|
11
|
+
* A 2-module quiet zone of light modules surrounds the symbol on every side
|
|
12
|
+
* (ISO 18004 recommendance) — without it many scanners refuse to lock on.
|
|
13
|
+
* Text too long for the encoder (over 106 UTF-8 bytes, its version-5-L
|
|
14
|
+
* capacity) returns `null`; the caller shows the plain URL instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { qrMatrix } from '../client-data/qr.js'
|
|
18
|
+
|
|
19
|
+
const QUIET_ZONE_MODULES = 2
|
|
20
|
+
|
|
21
|
+
export function renderTerminalQr(text: string): string | null {
|
|
22
|
+
const matrix = qrMatrix(text)
|
|
23
|
+
if (matrix === null) return null
|
|
24
|
+
const size = matrix.length
|
|
25
|
+
const dim = size + QUIET_ZONE_MODULES * 2
|
|
26
|
+
|
|
27
|
+
// Padded matrix: quiet zone (light) + the symbol itself.
|
|
28
|
+
const dark: boolean[][] = Array.from({ length: dim }, () => new Array<boolean>(dim).fill(false))
|
|
29
|
+
for (let row = 0; row < size; row += 1) {
|
|
30
|
+
const source = matrix[row]
|
|
31
|
+
const target = dark[row + QUIET_ZONE_MODULES]
|
|
32
|
+
if (source === undefined || target === undefined) continue
|
|
33
|
+
for (let col = 0; col < size; col += 1) target[col + QUIET_ZONE_MODULES] = source[col] ?? false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lines: string[] = []
|
|
37
|
+
for (let row = 0; row < dim; row += 2) {
|
|
38
|
+
const top = dark[row] ?? []
|
|
39
|
+
const bottom = dark[row + 1] ?? []
|
|
40
|
+
let line = ''
|
|
41
|
+
for (let col = 0; col < dim; col += 1) {
|
|
42
|
+
const topDark = top[col] ?? false
|
|
43
|
+
const bottomDark = bottom[col] ?? false
|
|
44
|
+
line += topDark && bottomDark ? '█' : topDark ? '▀' : bottomDark ? '▄' : ' '
|
|
45
|
+
}
|
|
46
|
+
lines.push(line)
|
|
47
|
+
}
|
|
48
|
+
return lines.join('\n')
|
|
49
|
+
}
|
|
@@ -53,7 +53,10 @@ export function RemoteStatusIcon({ sessionId, status, t }: RemoteStatusIconProps
|
|
|
53
53
|
// The explicit virtual-id gate stays: the page-wide store's snapshot can
|
|
54
54
|
// still be ready from a PREVIOUS remote session when a local one opens.
|
|
55
55
|
if (!isVirtual(sessionId) || !snap.ready) return null
|
|
56
|
-
|
|
56
|
+
// MS-6: the session's virtual id picks its OWN server's entry — with
|
|
57
|
+
// several servers paired, A online and B offline light and grey their own
|
|
58
|
+
// sessions independently.
|
|
59
|
+
const description = describeRemoteStatus(snap.view, t, sessionId)
|
|
57
60
|
const close = (): void => {
|
|
58
61
|
// Offline recovers on its own; revoked / unpaired do not (T34-fix) —
|
|
59
62
|
// each says its own word instead of "reconnecting".
|
|
@@ -64,6 +64,10 @@ export interface CurrentSessionReporterOptions {
|
|
|
64
64
|
intervalMs?: number
|
|
65
65
|
/** The unconditional re-send window (T66); default 30000ms. */
|
|
66
66
|
resendIntervalMs?: number
|
|
67
|
+
/** Optional UI language reader (`'zh'` | `'en'`); when present (or when
|
|
68
|
+
* `<html lang>` is set in the browser), the value rides the POST body as
|
|
69
|
+
* `lang` so the backend merger can localize sidebar group suffixes. */
|
|
70
|
+
getLang?: () => 'zh' | 'en' | undefined
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
/** One reportable value — `unavailable` never travels. */
|
|
@@ -83,9 +87,19 @@ export function startCurrentSessionReporter(options: CurrentSessionReporterOptio
|
|
|
83
87
|
return undefined
|
|
84
88
|
}
|
|
85
89
|
}
|
|
86
|
-
const
|
|
90
|
+
const readLang = (): 'zh' | 'en' | undefined => {
|
|
91
|
+
if (options.getLang !== undefined) return options.getLang()
|
|
92
|
+
const doc = (globalThis as { document?: { documentElement?: { lang?: unknown } } }).document
|
|
93
|
+
const htmlLang = doc?.documentElement?.lang
|
|
94
|
+
if (typeof htmlLang === 'string' && htmlLang !== '') {
|
|
95
|
+
return htmlLang.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
96
|
+
}
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
const post = (report: ReportableReport, lang: 'zh' | 'en' | undefined): Promise<unknown> => {
|
|
87
100
|
const doFetch = options.fetchImpl ?? fetch
|
|
88
|
-
const
|
|
101
|
+
const base = report.kind === 'open' ? { sessionId: report.sessionId } : { sessionId: null }
|
|
102
|
+
const body = lang !== undefined ? { ...base, lang } : base
|
|
89
103
|
return doFetch(options.route ?? CURRENT_SESSION_ROUTE, {
|
|
90
104
|
method: 'POST',
|
|
91
105
|
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
@@ -100,6 +114,7 @@ export function startCurrentSessionReporter(options: CurrentSessionReporterOptio
|
|
|
100
114
|
// T66: the timestamp of the last accepted post drives the slow unconditional
|
|
101
115
|
// re-send (0 = due immediately, which is the page-load post).
|
|
102
116
|
let lastReported: CurrentSessionReport | undefined
|
|
117
|
+
let lastReportedLang: 'zh' | 'en' | undefined
|
|
103
118
|
let lastSentAt = 0
|
|
104
119
|
const resendEveryMs = options.resendIntervalMs ?? RESEND_INTERVAL_MS
|
|
105
120
|
let role: 'host' | 'client' | undefined
|
|
@@ -115,15 +130,24 @@ export function startCurrentSessionReporter(options: CurrentSessionReporterOptio
|
|
|
115
130
|
if (role !== 'client') return
|
|
116
131
|
const report = parseCurrentSessionStorage(readRaw())
|
|
117
132
|
if (report.kind === 'unavailable') return
|
|
133
|
+
const lang = readLang()
|
|
118
134
|
const now = Date.now()
|
|
119
135
|
// T66: inside the re-send window an unchanged value says nothing; once
|
|
120
136
|
// the window passes, the CURRENT value re-posts even unchanged — the
|
|
121
137
|
// backend may have been reset underneath the page.
|
|
122
138
|
const resendDue = now - lastSentAt >= resendEveryMs
|
|
123
|
-
if (
|
|
139
|
+
if (
|
|
140
|
+
!resendDue &&
|
|
141
|
+
lastReported !== undefined &&
|
|
142
|
+
currentSessionReportEquals(report, lastReported) &&
|
|
143
|
+
lang === lastReportedLang
|
|
144
|
+
) {
|
|
145
|
+
return
|
|
146
|
+
}
|
|
124
147
|
try {
|
|
125
|
-
await post(report)
|
|
148
|
+
await post(report, lang)
|
|
126
149
|
lastReported = report
|
|
150
|
+
lastReportedLang = lang
|
|
127
151
|
lastSentAt = now
|
|
128
152
|
} catch {
|
|
129
153
|
// Silent: the next tick sees the unchanged lastReported and retries.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClientContext } from '../compat/types.ts'
|
|
2
|
-
import {
|
|
2
|
+
import { classifyModelGroupsForSession, REMOTE_SERVER_ATTRIBUTE, type ModelGroupHeading, type ModelGroupSide } from '../../client-data/model-group-side.ts'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* T74: stamp every model-menu group with the side of the pairing it belongs
|
|
@@ -11,6 +11,13 @@ import { classifyModelGroups, type ModelGroupHeading } from '../../client-data/m
|
|
|
11
11
|
* virtual-id.ts) heading is decided at all, so every other menu and the
|
|
12
12
|
* whole rest of the page is never touched.
|
|
13
13
|
*
|
|
14
|
+
* MS-4: the open remote session's server id (`data-zr-remote-server` on
|
|
15
|
+
* <html>, written by effects/remote-session.ts) is the classifier's
|
|
16
|
+
* `activeRemoteServerId` — a remote group whose heading decodes to a
|
|
17
|
+
* DIFFERENT paired server is stamped `remote-other`. Switching between two
|
|
18
|
+
* servers' sessions changes no group DOM at all, so a second observer
|
|
19
|
+
* watches that one <html> attribute and rescans the moment it moves.
|
|
20
|
+
*
|
|
14
21
|
* Synchronous in the observer callback, never behind rAF: the callback is a
|
|
15
22
|
* microtask and runs before this frame paints, so a group that must be
|
|
16
23
|
* hidden is hidden the first time it is drawn. A rAF would land a frame late
|
|
@@ -22,8 +29,8 @@ import { classifyModelGroups, type ModelGroupHeading } from '../../client-data/m
|
|
|
22
29
|
const GROUP_SELECTOR = 'section[role="group"]'
|
|
23
30
|
|
|
24
31
|
/** The stamp this effect writes; remote-session.css.ts hides
|
|
25
|
-
* `[data-zr-group="local"]`
|
|
26
|
-
*
|
|
32
|
+
* `[data-zr-group="local"]` and `[data-zr-group="remote-other"]` in a
|
|
33
|
+
* remote session and both remote stamps everywhere else. */
|
|
27
34
|
const STAMP = 'data-zr-group'
|
|
28
35
|
|
|
29
36
|
/**
|
|
@@ -69,7 +76,7 @@ function isGroupish(node: Node): boolean {
|
|
|
69
76
|
|
|
70
77
|
export function installModelGroupSide(ctx: ClientContext): void {
|
|
71
78
|
ctx.effect(() => {
|
|
72
|
-
const applyVerdict = (section: Element, verdict:
|
|
79
|
+
const applyVerdict = (section: Element, verdict: ModelGroupSide): void => {
|
|
73
80
|
// Write only on change — the observer does not watch attributes (so a
|
|
74
81
|
// stamp cannot re-trigger it), but a removed section's container may
|
|
75
82
|
// be re-decided, and an unconditional set would still churn
|
|
@@ -99,7 +106,8 @@ export function installModelGroupSide(ctx: ClientContext): void {
|
|
|
99
106
|
const text = headingTextOf(section, (id) => document.getElementById(id))
|
|
100
107
|
rows.push({ section, containerKey: key, headingText: text ?? '' })
|
|
101
108
|
}
|
|
102
|
-
const
|
|
109
|
+
const activeRemoteServerId = document.documentElement.getAttribute(REMOTE_SERVER_ATTRIBUTE) ?? undefined
|
|
110
|
+
const verdicts = classifyModelGroupsForSession(rows, activeRemoteServerId)
|
|
103
111
|
rows.forEach((row, i) => applyVerdict(row.section, verdicts[i]))
|
|
104
112
|
}
|
|
105
113
|
|
|
@@ -115,6 +123,11 @@ export function installModelGroupSide(ctx: ClientContext): void {
|
|
|
115
123
|
}
|
|
116
124
|
})
|
|
117
125
|
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
|
|
126
|
+
// MS-4: the session's server moves on <html> alone (A's session → B's
|
|
127
|
+
// with the menu open touches no group) — rescan synchronously, same
|
|
128
|
+
// no-rAF discipline as above.
|
|
129
|
+
const serverObserver = new MutationObserver(() => scan())
|
|
130
|
+
serverObserver.observe(document.documentElement, { attributes: true, attributeFilter: [REMOTE_SERVER_ATTRIBUTE] })
|
|
118
131
|
// The initial paint may already hold groups (session restored under an
|
|
119
132
|
// open menu): stamp them synchronously at install, like every observer
|
|
120
133
|
// effect here does.
|
|
@@ -122,6 +135,7 @@ export function installModelGroupSide(ctx: ClientContext): void {
|
|
|
122
135
|
|
|
123
136
|
return () => {
|
|
124
137
|
observer.disconnect()
|
|
138
|
+
serverObserver.disconnect()
|
|
125
139
|
// Undo every stamp so the host DOM reads exactly as before the effect
|
|
126
140
|
// ran — the CSS rules key on the stamp, so this un-hides everything.
|
|
127
141
|
for (const section of document.querySelectorAll(`${GROUP_SELECTOR}[${STAMP}]`)) {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ClientContext, MobileSessionRow } from '../compat/types.ts'
|
|
2
2
|
import { mainSessionIdOf } from '../compat/types.ts'
|
|
3
|
-
import { isVirtual } from '../../virtual-id.ts'
|
|
3
|
+
import { fromVirtual, isVirtual } from '../../virtual-id.ts'
|
|
4
4
|
import { REMOTE_SESSION_CSS } from '../styles/remote-session.css.ts'
|
|
5
|
-
import { BRIDGE_STAMP_ATTRIBUTE, BRIDGE_STAMP_PLUGIN, getRemoteStatusStore,
|
|
5
|
+
import { BRIDGE_STAMP_ATTRIBUTE, BRIDGE_STAMP_PLUGIN, getRemoteStatusStore, shouldStampBridgeForSession, subscribeIfVirtual } from '../../client-data/remote-status.ts'
|
|
6
6
|
import { en, zh } from '../locales.ts'
|
|
7
|
+
import { REMOTE_SERVER_ATTRIBUTE } from '../../client-data/model-group-side.ts'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The remote-session marker (T41b): the CURRENT main-view session id, read
|
|
@@ -14,6 +15,12 @@ import { en, zh } from '../locales.ts'
|
|
|
14
15
|
* every open-on-the-server-machine entry, so the decision lives at the one
|
|
15
16
|
* place that knows the session and never inside component DOM.
|
|
16
17
|
*
|
|
18
|
+
* MS-4: with several servers paired, the virtual id's serverId half is
|
|
19
|
+
* stamped alongside as `data-zr-remote-server="<serverId>"` — the model
|
|
20
|
+
* menu's per-server isolation (effects/model-group-side.ts reads it to hide
|
|
21
|
+
* the OTHER paired servers' groups; remote-session.css.ts hides them). Both
|
|
22
|
+
* attributes move together: a local session (or unmount) clears both.
|
|
23
|
+
*
|
|
17
24
|
* The official tie-break is kept verbatim: when the previous answer is still
|
|
18
25
|
* mainView-retained it STAYS the answer, and only otherwise does the first
|
|
19
26
|
* mainView-retained row in catalog order win — so two simultaneously
|
|
@@ -66,8 +73,14 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
66
73
|
const apply = (): void => {
|
|
67
74
|
const sessionId = currentMainSessionIdOf(ctx)
|
|
68
75
|
const root = document.documentElement
|
|
69
|
-
|
|
70
|
-
|
|
76
|
+
const parts = sessionId !== undefined ? fromVirtual(sessionId) : undefined
|
|
77
|
+
if (parts !== undefined) {
|
|
78
|
+
root.setAttribute('data-zr-remote-session', '1')
|
|
79
|
+
if (root.getAttribute(REMOTE_SERVER_ATTRIBUTE) !== parts.serverId) root.setAttribute(REMOTE_SERVER_ATTRIBUTE, parts.serverId)
|
|
80
|
+
} else {
|
|
81
|
+
root.removeAttribute('data-zr-remote-session')
|
|
82
|
+
root.removeAttribute(REMOTE_SERVER_ATTRIBUTE)
|
|
83
|
+
}
|
|
71
84
|
}
|
|
72
85
|
apply()
|
|
73
86
|
const offs: Array<() => void> = [ctx.sessions.list.subscribe(apply)]
|
|
@@ -79,6 +92,7 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
79
92
|
return () => {
|
|
80
93
|
for (const off of offs) off()
|
|
81
94
|
document.documentElement.removeAttribute('data-zr-remote-session')
|
|
95
|
+
document.documentElement.removeAttribute(REMOTE_SERVER_ATTRIBUTE)
|
|
82
96
|
}
|
|
83
97
|
}, 'dsh-zen-remote: remote-session attribute')
|
|
84
98
|
|
|
@@ -103,7 +117,7 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
103
117
|
}
|
|
104
118
|
const applyBridge = (): void => {
|
|
105
119
|
const view = store.getSnapshot().view
|
|
106
|
-
if (
|
|
120
|
+
if (shouldStampBridgeForSession(view, currentMainSessionIdOf(ctx), BRIDGE_STAMP_PLUGIN)) {
|
|
107
121
|
root.setAttribute(BRIDGE_STAMP_ATTRIBUTE, '1')
|
|
108
122
|
root.style.removeProperty('--zr-bridge-note')
|
|
109
123
|
} else {
|
|
@@ -121,7 +135,13 @@ export function installRemoteSessionGuard(ctx: ClientContext): void {
|
|
|
121
135
|
applyBridge()
|
|
122
136
|
return
|
|
123
137
|
}
|
|
124
|
-
|
|
138
|
+
// MS-6: remote A → remote B keeps the subscription, but the verdict
|
|
139
|
+
// belongs to the session's own server and must be re-judged.
|
|
140
|
+
if (virtualNow) {
|
|
141
|
+
applyBridge()
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
if (offStore !== undefined) {
|
|
125
145
|
offStore()
|
|
126
146
|
offStore = undefined
|
|
127
147
|
clear()
|