@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9
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.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/lan-bind.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAN bind toggle: the managed cordis patch block that pins the web
|
|
3
|
+
* server's bind. On = a block binding 0.0.0.0; off = the same block binding
|
|
4
|
+
* 127.0.0.1. The block takes effect on the next `dsh web` start (the live
|
|
5
|
+
* patch watcher cannot rebind a running listener, and on this harness line
|
|
6
|
+
* the user patch layer cannot evaluate webStartup-dependent expressions
|
|
7
|
+
* reliably - static values are the only dependable form), so the plugin
|
|
8
|
+
* re-asserts the block at every boot and the settings card reports the
|
|
9
|
+
* running bind honestly.
|
|
10
|
+
*
|
|
11
|
+
* Same-id patch rows REPLACE the row config wholesale, so the block carries
|
|
12
|
+
* the official web-app webserver row's full config (bind + compression)
|
|
13
|
+
* with the bind values materialized. The CLI's --host 0.0.0.0 guard stays
|
|
14
|
+
* intact: deliberate LAN exposure happens through this configuration layer
|
|
15
|
+
* only. Until the user flips the toggle once, the plugin never touches the
|
|
16
|
+
* patch file.
|
|
17
|
+
*
|
|
18
|
+
* The block discipline (markers, atomic write, strip-and-rewrite) follows
|
|
19
|
+
* the dsh-LAN reference implementation (MIT).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
23
|
+
import { dirname, join, resolve, sep } from 'node:path'
|
|
24
|
+
import { dshHome } from './dsh-home.ts'
|
|
25
|
+
|
|
26
|
+
/** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
|
|
27
|
+
const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
28
|
+
|
|
29
|
+
export const LAN_BIND_BLOCK_BEGIN = '# --- remote-web-ui lan-bind block (managed - do not edit) ---'
|
|
30
|
+
export const LAN_BIND_BLOCK_END = '# --- end remote-web-ui lan-bind block ---'
|
|
31
|
+
|
|
32
|
+
/** The two bind hosts the managed block pins. */
|
|
33
|
+
export type LanBindHost = '0.0.0.0' | '127.0.0.1'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The absolute path of the profile patch file this toggle manages. The value
|
|
37
|
+
* is config-controlled (and the DSH_PROFILE env fallback bypasses schema
|
|
38
|
+
* validation entirely), so the path is guarded twice: the profile must be a
|
|
39
|
+
* single safe path segment, and the resolved file must stay under the
|
|
40
|
+
* profiles directory.
|
|
41
|
+
*/
|
|
42
|
+
export function profilePatchFile(profile: string, home: string = dshHome()): string {
|
|
43
|
+
if (!PROFILE_PATTERN.test(profile)) {
|
|
44
|
+
throw new Error(`remote-web-ui: unsafe lan-bind profile ${JSON.stringify(profile)}`)
|
|
45
|
+
}
|
|
46
|
+
const file = resolve(join(home, 'profiles', profile, 'cordis.patch.yml'))
|
|
47
|
+
const root = resolve(join(home, 'profiles')) + sep
|
|
48
|
+
if (!file.startsWith(root)) {
|
|
49
|
+
throw new Error(`remote-web-ui: lan-bind profile ${JSON.stringify(profile)} escapes the profiles directory`)
|
|
50
|
+
}
|
|
51
|
+
return file
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readPatchContent(file: string): string {
|
|
55
|
+
if (!existsSync(file)) return ''
|
|
56
|
+
return readFileSync(file, 'utf8')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Escape one literal string for embedding into a RegExp pattern. */
|
|
60
|
+
function escapeRegex(text: string): string {
|
|
61
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Remove the managed block (and its markers) from patch content. */
|
|
65
|
+
export function stripManagedBlock(content: string): string {
|
|
66
|
+
const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN)
|
|
67
|
+
const end = escapeRegex(LAN_BIND_BLOCK_END)
|
|
68
|
+
const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, 'g')
|
|
69
|
+
return content.replace(pattern, '\n')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Render the managed block for one bind state. The values are static: the
|
|
74
|
+
* user patch layer has no reliable lazy service evaluation, and the plugin
|
|
75
|
+
* re-asserts the block at every boot so CLI flags (--port, --host) win by
|
|
76
|
+
* rewriting it before the next start.
|
|
77
|
+
*/
|
|
78
|
+
export function managedBlock(host: LanBindHost, port: number): string {
|
|
79
|
+
return [
|
|
80
|
+
LAN_BIND_BLOCK_BEGIN,
|
|
81
|
+
'- id: webserver',
|
|
82
|
+
" name: '@deepseek-ai/dsh-host-webserver'",
|
|
83
|
+
' config:',
|
|
84
|
+
` host: '${host}'`,
|
|
85
|
+
` port: ${String(port)}`,
|
|
86
|
+
' compression: gzip',
|
|
87
|
+
' compressionLevel: 1',
|
|
88
|
+
' compressionThresholdBytes: 1024',
|
|
89
|
+
LAN_BIND_BLOCK_END,
|
|
90
|
+
'',
|
|
91
|
+
].join('\n')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The bind state a managed block pins (undefined when there is no block). */
|
|
95
|
+
export interface ManagedBindState {
|
|
96
|
+
host: LanBindHost | (string & {})
|
|
97
|
+
port: number | undefined
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Parse the block's pinned bind out of patch content. A block whose values
|
|
102
|
+
* were hand-edited reports the literals as-is so the card can surface them
|
|
103
|
+
* instead of silently claiming one of the two known states.
|
|
104
|
+
*/
|
|
105
|
+
export function managedBindOf(content: string): ManagedBindState | undefined {
|
|
106
|
+
const begin = content.indexOf(LAN_BIND_BLOCK_BEGIN)
|
|
107
|
+
if (begin === -1) return undefined
|
|
108
|
+
const end = content.indexOf(LAN_BIND_BLOCK_END, begin)
|
|
109
|
+
const block = end === -1 ? content.slice(begin) : content.slice(begin, end)
|
|
110
|
+
const hostMatch = /host:\s*'([^']+)'/.exec(block)
|
|
111
|
+
const portMatch = /port:\s*(\d+)/.exec(block)
|
|
112
|
+
return {
|
|
113
|
+
host: hostMatch?.[1] ?? '',
|
|
114
|
+
port: portMatch !== null ? Number(portMatch[1]) : undefined,
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Full file-level state for the settings card and the boot re-assert. */
|
|
119
|
+
export function lanBindState(profile: string, home: string = dshHome()): { blockPresent: boolean; host?: string; port?: number } {
|
|
120
|
+
const state = managedBindOf(readPatchContent(profilePatchFile(profile, home)))
|
|
121
|
+
if (state === undefined) return { blockPresent: false }
|
|
122
|
+
return { blockPresent: true, host: state.host, port: state.port }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Write (or rewrite) the managed block with the given bind. The rest of the
|
|
127
|
+
* patch file is preserved; the block is rewritten atomically (unique temp
|
|
128
|
+
* file + rename, so concurrent writers can never rename a half-written
|
|
129
|
+
* file) with the original file's permissions preserved. A hand-truncated
|
|
130
|
+
* unterminated block (BEGIN marker without END, which stripManagedBlock
|
|
131
|
+
* cannot match) is truncated at its BEGIN marker first so the rewrite can
|
|
132
|
+
* never stack a second webserver row onto the orphan.
|
|
133
|
+
*/
|
|
134
|
+
export function writeLanBind(host: LanBindHost, port: number, profile: string, home: string = dshHome()): void {
|
|
135
|
+
const file = profilePatchFile(profile, home)
|
|
136
|
+
const stripped = stripManagedBlock(readPatchContent(file))
|
|
137
|
+
const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN)
|
|
138
|
+
const base = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()
|
|
139
|
+
const content = `${base}\n\n${managedBlock(host, port)}`
|
|
140
|
+
const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600
|
|
141
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
142
|
+
const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
143
|
+
writeFileSync(temp, content, { mode })
|
|
144
|
+
renameSync(temp, file)
|
|
145
|
+
}
|
package/src/loopback-proxy.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { request as httpRequest } from 'node:http'
|
|
|
12
12
|
import { connect } from 'node:net'
|
|
13
13
|
import type { Duplex } from 'node:stream'
|
|
14
14
|
import { writeJson } from './http.ts'
|
|
15
|
+
import type { InnerAuth } from './inner-auth.ts'
|
|
15
16
|
|
|
16
17
|
/** WebSocket handshake headers forwarded to the loopback upstream. */
|
|
17
18
|
const WS_FORWARD_HEADERS = [
|
|
@@ -37,12 +38,30 @@ const HTTP_FORWARD_RESPONSE_HEADERS = [
|
|
|
37
38
|
* @param res - the outer response.
|
|
38
39
|
* @param port - local webServer port.
|
|
39
40
|
* @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
|
|
41
|
+
* @param auth - when given, the process's inner browser-auth credential is
|
|
42
|
+
* attached so the connection plugin's /api route (fence + browser auth,
|
|
43
|
+
* authority-bound cookie, no loopback exemption on this cohort) accepts
|
|
44
|
+
* the re-issued request; a 401 answer invalidates the cached credential.
|
|
40
45
|
*/
|
|
41
46
|
export function proxyLoopbackHttp(
|
|
42
47
|
req: IncomingMessage,
|
|
43
48
|
res: ServerResponse,
|
|
44
49
|
port: number,
|
|
45
50
|
upstreamPath: string,
|
|
51
|
+
auth?: InnerAuth,
|
|
52
|
+
): void {
|
|
53
|
+
void Promise.resolve(auth?.ready())
|
|
54
|
+
.catch(() => undefined)
|
|
55
|
+
.then((cookie) => { pipeLoopbackHttp(req, res, port, upstreamPath, auth, typeof cookie === 'string' ? cookie : undefined) })
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pipeLoopbackHttp(
|
|
59
|
+
req: IncomingMessage,
|
|
60
|
+
res: ServerResponse,
|
|
61
|
+
port: number,
|
|
62
|
+
upstreamPath: string,
|
|
63
|
+
auth: InnerAuth | undefined,
|
|
64
|
+
cookie: string | undefined,
|
|
46
65
|
): void {
|
|
47
66
|
const headers: Record<string, string> = {
|
|
48
67
|
host: `127.0.0.1:${String(port)}`,
|
|
@@ -54,6 +73,7 @@ export function proxyLoopbackHttp(
|
|
|
54
73
|
if (typeof contentLength === 'string') headers['content-length'] = contentLength
|
|
55
74
|
const accept = req.headers.accept
|
|
56
75
|
if (typeof accept === 'string') headers.accept = accept
|
|
76
|
+
if (cookie !== undefined) headers.cookie = cookie
|
|
57
77
|
|
|
58
78
|
const upstream = httpRequest({
|
|
59
79
|
host: '127.0.0.1',
|
|
@@ -62,6 +82,10 @@ export function proxyLoopbackHttp(
|
|
|
62
82
|
method: req.method,
|
|
63
83
|
headers,
|
|
64
84
|
}, (upstreamRes) => {
|
|
85
|
+
// The cached credential went stale (secret rotation, credential store
|
|
86
|
+
// reset): drop it so the next request re-redeems. The in-flight response
|
|
87
|
+
// still pipes through untouched.
|
|
88
|
+
if (upstreamRes.statusCode === 401 && cookie !== undefined) auth?.invalidate()
|
|
65
89
|
const out: OutgoingHttpHeaders = {}
|
|
66
90
|
for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
|
|
67
91
|
const value = upstreamRes.headers[name]
|
|
@@ -87,6 +111,8 @@ export function proxyLoopbackHttp(
|
|
|
87
111
|
* @param head - bytes already read past the handshake.
|
|
88
112
|
* @param port - local webServer port.
|
|
89
113
|
* @param upstreamPath - path + query on 127.0.0.1.
|
|
114
|
+
* @param cookie - the inner browser-auth credential for the handshake, when
|
|
115
|
+
* the upstream route enforces it (the gateway event-stream mux does).
|
|
90
116
|
*/
|
|
91
117
|
export function proxyLoopbackUpgrade(
|
|
92
118
|
req: IncomingMessage,
|
|
@@ -94,6 +120,7 @@ export function proxyLoopbackUpgrade(
|
|
|
94
120
|
head: Buffer,
|
|
95
121
|
port: number,
|
|
96
122
|
upstreamPath: string,
|
|
123
|
+
cookie?: string,
|
|
97
124
|
): void {
|
|
98
125
|
const lines = [
|
|
99
126
|
`GET ${upstreamPath} HTTP/1.1`,
|
|
@@ -101,6 +128,7 @@ export function proxyLoopbackUpgrade(
|
|
|
101
128
|
'Upgrade: websocket',
|
|
102
129
|
'Connection: Upgrade',
|
|
103
130
|
]
|
|
131
|
+
if (cookie !== undefined) lines.push(`Cookie: ${cookie}`)
|
|
104
132
|
for (const name of WS_FORWARD_HEADERS) {
|
|
105
133
|
const value = req.headers[name]
|
|
106
134
|
if (value === undefined) continue
|
|
@@ -109,6 +137,12 @@ export function proxyLoopbackUpgrade(
|
|
|
109
137
|
const handshake = `${lines.join('\r\n')}\r\n\r\n`
|
|
110
138
|
|
|
111
139
|
const upstream = connect(port, '127.0.0.1')
|
|
140
|
+
// A half-open tunnel path (dead-but-alive edge connection) would otherwise
|
|
141
|
+
// let the phone's mux socket sit stale for minutes: keepalive probes the
|
|
142
|
+
// pipe periodically and destroys both legs on the first real gap, so the
|
|
143
|
+
// client's reconnect + stream re-baseline re-syncs the feed in seconds.
|
|
144
|
+
;(socket as unknown as import('node:net').Socket).setKeepAlive(true, 20_000)
|
|
145
|
+
upstream.setKeepAlive(true, 20_000)
|
|
112
146
|
const tearDown = (): void => {
|
|
113
147
|
upstream.destroy()
|
|
114
148
|
socket.destroy()
|
package/src/pairing.ts
CHANGED
|
@@ -7,8 +7,12 @@
|
|
|
7
7
|
* Security invariants:
|
|
8
8
|
* - One active token at a time; `issue()` replaces it, so a refreshed QR
|
|
9
9
|
* immediately invalidates the previous link.
|
|
10
|
-
* - A token
|
|
11
|
-
*
|
|
10
|
+
* - A token stays re-usable until it expires or is replaced: the first
|
|
11
|
+
* successful `accept()` marks it consumed, but the same link may pair
|
|
12
|
+
* again within the window (each re-accept mints a fresh device session).
|
|
13
|
+
* Mobile flows routinely split across cookie contexts (camera preview to
|
|
14
|
+
* in-app browser to the system browser), and the later context must be
|
|
15
|
+
* able to complete its own pairing from the same link.
|
|
12
16
|
* - Tokens expire; `accept()` on an expired token is refused like an
|
|
13
17
|
* unknown one (no oracle for validity).
|
|
14
18
|
* - `stop()` revokes every device session and clears the token, so paired
|
|
@@ -51,8 +55,16 @@ export interface TokenRecord {
|
|
|
51
55
|
address?: string
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
/**
|
|
55
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Default idle-expiry window: 30 days without a heartbeat or a gated
|
|
60
|
+
* request. The reopen service worker refreshes lastSeenAt on every
|
|
61
|
+
* navigation it serves, so the window only runs out through genuine
|
|
62
|
+
* disuse; 30 days matches the browser-credential lifetimes the surrounding
|
|
63
|
+
* flow was built around, while the effective device lifetime stays shorter
|
|
64
|
+
* than the 365-day cookie because of this sweep. Override per deployment
|
|
65
|
+
* through the idleExpireMs config.
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_IDLE_EXPIRE_MS = 30 * 24 * 60 * 60 * 1000
|
|
56
68
|
|
|
57
69
|
/** Cap on the persisted/displayed User-Agent string. */
|
|
58
70
|
const MAX_USER_AGENT_CHARS = 180
|
|
@@ -379,8 +391,10 @@ export class PairingService {
|
|
|
379
391
|
*/
|
|
380
392
|
accept(token: string, userAgent?: string): AcceptResult {
|
|
381
393
|
const record = this.tokens.get(token)
|
|
382
|
-
|
|
383
|
-
|
|
394
|
+
// A consumed token stays a valid bearer credential until expiry or
|
|
395
|
+
// replacement (see the module doc): re-accept mints a fresh device.
|
|
396
|
+
if (record === undefined || this.stopped || this.clock.now() > record.expiresAt) {
|
|
397
|
+
return { ok: false, code: 'invalid' }
|
|
384
398
|
}
|
|
385
399
|
record.consumed = true
|
|
386
400
|
const deviceId = this.clock.randomToken()
|
package/src/remote-api.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The remote desktop data channel: `/remote` is this plugin's own prefix, so
|
|
3
|
-
* the paired-device cookie is the access control
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* the paired-device cookie is the access control. After that gate, every
|
|
4
|
+
* fenced same-origin path the browser rewrote here is re-issued to 127.0.0.1
|
|
5
|
+
* as a loopback-shaped request so sibling plugin fences (and the connection
|
|
6
|
+
* plugin's `/api`) accept it — no `--trusted-host` and no per-plugin pairing
|
|
7
|
+
* consult.
|
|
8
8
|
*
|
|
9
9
|
* Security model:
|
|
10
10
|
* - While `requirePairingForLan` is on (default), every request must carry a
|
|
11
11
|
* live paired-device cookie, enforced before any bytes are forwarded and
|
|
12
12
|
* before any host call. With the policy off, the cookie gate is skipped
|
|
13
|
-
* (the
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* (the local-only denials below still apply).
|
|
14
|
+
* - A paired remote desktop is a full-control credential: the browser half
|
|
15
|
+
* flips the official UI into host mode (the transport ownsHost hook), so
|
|
16
|
+
* the configuration plane (settings, credentials, presets, deliverables)
|
|
17
|
+
* rides this channel like every other call. The four control planes in
|
|
18
|
+
* LOCAL_ONLY_PREFIXES stay physically local.
|
|
19
19
|
* - Everything else is HTTP- or WebSocket-proxied to the local port with
|
|
20
20
|
* Host rewritten, Origin and cookies dropped, and a synthetic same-origin
|
|
21
21
|
* browser marker added after authentication. Plugin loopback fences then
|
|
@@ -28,24 +28,27 @@ import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver'
|
|
|
28
28
|
import type { PairingService } from './pairing.ts'
|
|
29
29
|
import { readCookie } from './gate.ts'
|
|
30
30
|
import { writeJson } from './http.ts'
|
|
31
|
+
import type { InnerAuth } from './inner-auth.ts'
|
|
31
32
|
import { proxyLoopbackHttp, proxyLoopbackUpgrade } from './loopback-proxy.ts'
|
|
32
33
|
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
PLUGIN_MANAGER_PATH,
|
|
34
|
+
REMOTE_DEVICE_HEADER,
|
|
35
|
+
REMOTE_DEVICE_QUERY,
|
|
36
36
|
REMOTE_PREFIX,
|
|
37
37
|
REMOTE_UPGRADE_PATHS,
|
|
38
|
-
|
|
38
|
+
localOnlyDenial,
|
|
39
39
|
} from './remote-methods.ts'
|
|
40
40
|
|
|
41
41
|
export {
|
|
42
42
|
DESKTOP_LAUNCHER_PATH,
|
|
43
|
-
|
|
43
|
+
LOCAL_ONLY_PREFIXES,
|
|
44
44
|
PLUGIN_MANAGER_PATH,
|
|
45
45
|
REMOTE_API_PATHS,
|
|
46
|
+
REMOTE_DEVICE_HEADER,
|
|
47
|
+
REMOTE_DEVICE_QUERY,
|
|
46
48
|
REMOTE_PREFIX,
|
|
47
49
|
REMOTE_UPGRADE_PATHS,
|
|
48
50
|
WEB_UI_SETTINGS_BRIDGE_PATH,
|
|
51
|
+
localOnlyDenial,
|
|
49
52
|
} from './remote-methods.ts'
|
|
50
53
|
export { REMOTE_API_PREFIX } from './remote-methods.ts'
|
|
51
54
|
|
|
@@ -76,6 +79,13 @@ export interface RemoteApiDeps {
|
|
|
76
79
|
* edit takes effect without a restart. Defaults to true.
|
|
77
80
|
*/
|
|
78
81
|
requirePairingForLan?: boolean | (() => boolean)
|
|
82
|
+
/**
|
|
83
|
+
* The process's inner browser-auth credential attached to re-issued
|
|
84
|
+
* requests (the connection plugin's /api route enforces that cookie and
|
|
85
|
+
* the pairing gate above already ran). Undefined keeps the previous
|
|
86
|
+
* cookie-less behavior — the inner route then answers 401 on this cohort.
|
|
87
|
+
*/
|
|
88
|
+
auth?: InnerAuth
|
|
79
89
|
}
|
|
80
90
|
|
|
81
91
|
/** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
|
|
@@ -104,31 +114,28 @@ export function innerPathOf(pathname: string): string | undefined {
|
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
/**
|
|
107
|
-
* Whether a paired inner path must stay physically local
|
|
117
|
+
* Whether a paired inner path must stay physically local (delegates to the
|
|
118
|
+
* shared LOCAL_ONLY_PREFIXES table).
|
|
108
119
|
* @returns a denial message, or undefined when the path may be proxied.
|
|
109
120
|
*/
|
|
110
121
|
export function loopbackOnlyDenial(innerPath: string): string | undefined {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
if (method !== '' && !method.includes('/') && LOOPBACK_ONLY_METHODS.has(method)) {
|
|
129
|
-
return `${method} is loopback-only and stays unreachable from a paired remote desktop`
|
|
130
|
-
}
|
|
131
|
-
return undefined
|
|
122
|
+
return localOnlyDenial(innerPath)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a live device credential for a gated HTTP request: the pairing
|
|
127
|
+
* cookie first, then the cookieless header the boot patch attaches. Unknown
|
|
128
|
+
* or revoked ids are a no-op - a stale id never re-arms a device.
|
|
129
|
+
* @returns the touched device id, or undefined when neither credential is live.
|
|
130
|
+
*/
|
|
131
|
+
export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService): string | undefined {
|
|
132
|
+
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
|
|
133
|
+
const headerDevice = typeof req.headers[REMOTE_DEVICE_HEADER] === 'string'
|
|
134
|
+
? (req.headers[REMOTE_DEVICE_HEADER] as string)
|
|
135
|
+
: undefined
|
|
136
|
+
const id = cookieDevice ?? headerDevice
|
|
137
|
+
if (id === undefined) return undefined
|
|
138
|
+
return service.touchDevice(id) ? id : undefined
|
|
132
139
|
}
|
|
133
140
|
|
|
134
141
|
/**
|
|
@@ -145,9 +152,8 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
|
145
152
|
// (a stale client rewrite must not 403); loopback-only denials stay below.
|
|
146
153
|
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
|
|
147
154
|
if (require) {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
if (!paired) {
|
|
155
|
+
const paired = pairedDeviceIdOf(req, service)
|
|
156
|
+
if (paired === undefined) {
|
|
151
157
|
req.resume()
|
|
152
158
|
envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop')
|
|
153
159
|
return
|
|
@@ -175,7 +181,7 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
|
175
181
|
return
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
proxyLoopbackHttp(req, res, port, `${inner}${url.search}
|
|
184
|
+
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth)
|
|
179
185
|
}
|
|
180
186
|
|
|
181
187
|
return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }]
|
|
@@ -209,8 +215,15 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
|
|
|
209
215
|
const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
|
210
216
|
const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
|
|
211
217
|
if (require) {
|
|
212
|
-
|
|
213
|
-
|
|
218
|
+
// WebSocket handshakes cannot carry headers from the Web API, so the
|
|
219
|
+
// cookieless credential rides the query; the cookie stays the primary.
|
|
220
|
+
let queryDevice: string | undefined
|
|
221
|
+
try {
|
|
222
|
+
queryDevice = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get(REMOTE_DEVICE_QUERY) ?? undefined
|
|
223
|
+
} catch { /* fall through to the cookie */ }
|
|
224
|
+
const deviceId = pairedDeviceIdOf(req, service)
|
|
225
|
+
const paired = deviceId ?? (queryDevice !== undefined && service.touchDevice(queryDevice) ? queryDevice : undefined)
|
|
226
|
+
if (paired === undefined) {
|
|
214
227
|
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
|
|
215
228
|
socket.destroy()
|
|
216
229
|
return
|
|
@@ -223,7 +236,16 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
|
|
|
223
236
|
socket.destroy()
|
|
224
237
|
return
|
|
225
238
|
}
|
|
226
|
-
|
|
239
|
+
// The inner handshake needs the browser-auth credential (the gateway
|
|
240
|
+
// event-stream route enforces it); resolving it is async, so the
|
|
241
|
+
// handshake bytes are written once it settles. A missing credential
|
|
242
|
+
// proceeds as before — the gateway route then refuses.
|
|
243
|
+
void Promise.resolve(deps.auth?.ready())
|
|
244
|
+
.catch(() => undefined)
|
|
245
|
+
.then((cookie) => {
|
|
246
|
+
if (socket.destroyed) return
|
|
247
|
+
proxyLoopbackUpgrade(req, socket, head, port, inner, typeof cookie === 'string' ? cookie : undefined)
|
|
248
|
+
})
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
return REMOTE_UPGRADE_PATHS.map((path) => ({
|
|
@@ -10,8 +10,15 @@
|
|
|
10
10
|
* (hooks + pending unpaired signal) instead of patching twice.
|
|
11
11
|
*
|
|
12
12
|
* The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
|
|
13
|
-
* data the browser patch consumes — the two cannot drift apart.
|
|
14
|
-
*
|
|
13
|
+
* data the browser patch consumes — the two cannot drift apart. On this
|
|
14
|
+
* 0.1.2-alpha.2 line the "configuration plane is local" behavior lives in
|
|
15
|
+
* the browser (client plugins branch on connection.isLoopback), so the
|
|
16
|
+
* script also flips the official UI into host mode by installing the
|
|
17
|
+
* transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
|
|
18
|
+
* connection plugin reads it: the paired remote desktop gets the full
|
|
19
|
+
* settings/credentials/presets surface, and every call still rides the
|
|
20
|
+
* gated /remote channel. The script self-skips on loopback origins and
|
|
21
|
+
* never throws.
|
|
15
22
|
* @module @linxin666/dsh-remote-web-ui/remote-channel-boot
|
|
16
23
|
*/
|
|
17
24
|
|
|
@@ -32,7 +39,23 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
32
39
|
'var w=window,loc=w.location,h=loc.hostname;' +
|
|
33
40
|
// Loopback origins keep the original paths (mirrors isLoopbackHostname).
|
|
34
41
|
"if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
42
|
+
// Host mode: the paired remote desktop presents itself as the machine
|
|
43
|
+
// owner, so the official UI keeps its full configuration surface (the
|
|
44
|
+
// settings mirror, document controller, and deliverables open actions
|
|
45
|
+
// all branch on connection.isLoopback). Must run before any boot entry.
|
|
46
|
+
'try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}' +
|
|
35
47
|
'var R=' + json + ';' +
|
|
48
|
+
// The cookieless device credential: read lazily per call - the
|
|
49
|
+
// /pair-app capture script sets it in head AFTER this boot script ran,
|
|
50
|
+
// so a parse-time read would always see null.
|
|
51
|
+
'function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}' +
|
|
52
|
+
'function att(init){' +
|
|
53
|
+
'var dv=rdv();' +
|
|
54
|
+
'if(dv===null)return init;' +
|
|
55
|
+
'var h=init&&init.headers;' +
|
|
56
|
+
'if(typeof Headers!=="undefined"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}' +
|
|
57
|
+
'if(typeof h==="object"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}' +
|
|
58
|
+
'return init}' +
|
|
36
59
|
'function sf(p){' +
|
|
37
60
|
'if(p.indexOf(R.pairPrefix)===0)return false;' +
|
|
38
61
|
'if(p.indexOf(R.updatePrefix)===0)return false;' +
|
|
@@ -80,7 +103,7 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
80
103
|
'var next=new URL(url);' +
|
|
81
104
|
'next.pathname=rp(url.pathname);' +
|
|
82
105
|
'var target=typeof input==="string"||input instanceof URL?next.toString():new Request(next,input);' +
|
|
83
|
-
'return Promise.resolve(of.call(w,target,init)).then(function(res){' +
|
|
106
|
+
'return Promise.resolve(of.call(w,target,att(init))).then(function(res){' +
|
|
84
107
|
// denied() is sync-false for non-403 and a promise otherwise.
|
|
85
108
|
'void Promise.resolve(denied(res)).then(signal);' +
|
|
86
109
|
'return res})}' +
|
|
@@ -92,6 +115,8 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
92
115
|
'if(o!==""&&o===loc.origin&&sw(p.pathname)){' +
|
|
93
116
|
'var nx=new URL(p);' +
|
|
94
117
|
'nx.pathname=rp(p.pathname);' +
|
|
118
|
+
'var dvv=rdv();' +
|
|
119
|
+
'if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);' +
|
|
95
120
|
'return protocols!==undefined?new OW(nx,protocols):new OW(nx)}' +
|
|
96
121
|
'return protocols!==undefined?new OW(url,protocols):new OW(url)};' +
|
|
97
122
|
'w.WebSocket.prototype=OW.prototype;' +
|
|
@@ -103,6 +128,8 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
103
128
|
'if(so(p)&&sf(p.pathname)){' +
|
|
104
129
|
'var nx=new URL(p);' +
|
|
105
130
|
'nx.pathname=rp(p.pathname);' +
|
|
131
|
+
'var dvv=rdv();' +
|
|
132
|
+
'if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);' +
|
|
106
133
|
'return new OE(nx,cfg)}' +
|
|
107
134
|
'return new OE(url,cfg)};' +
|
|
108
135
|
'w.EventSource.prototype=OE.prototype}' +
|
|
@@ -123,9 +150,49 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
123
150
|
'for(var i=0;i<restores.length;i++)restores[i]();' +
|
|
124
151
|
'try{delete w[' + seat + ']}catch(e){w[' + seat + ']=undefined}};' +
|
|
125
152
|
'w[' + seat + ']=seat' +
|
|
153
|
+
// The boot watchdog rides the same IIFE and skip conditions: it needs
|
|
154
|
+
// the non-loopback gate above (the desktop boots over loopback without
|
|
155
|
+
// a tunnel in the path) and the try/catch fail-closed envelope.
|
|
156
|
+
buildBootWatchdogScript() +
|
|
126
157
|
'}catch(e){}' +
|
|
127
158
|
'})();'
|
|
128
159
|
}
|
|
129
160
|
|
|
161
|
+
/** Boot-watchdog latch: one self-reload per session while the boot is broken. */
|
|
162
|
+
export const BOOT_WATCHDOG_KEY = 'dsh-remote-boot-reload'
|
|
163
|
+
|
|
164
|
+
/** Total wait for the app's first conversation surface before the reload. */
|
|
165
|
+
const BOOT_WATCHDOG_WAIT_MS = 15_000
|
|
166
|
+
|
|
167
|
+
/** Watchdog poll cadence. */
|
|
168
|
+
const BOOT_WATCHDOG_POLL_MS = 1_000
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the boot-watchdog script fragment. A remote boot has no recovery
|
|
172
|
+
* path of its own: the SPA mounts nothing when a boot-critical request dies
|
|
173
|
+
* (a tunnel-edge 429 under burst, a dropped stream, a boot-order race), and
|
|
174
|
+
* the phone then shows a permanently blank shell. The watchdog polls for the
|
|
175
|
+
* app's conversation surface and reloads once when it never appears; the
|
|
176
|
+
* sessionStorage latch keeps a genuinely broken deployment from looping,
|
|
177
|
+
* and a successful boot clears the latch so a later failure can recover.
|
|
178
|
+
*/
|
|
179
|
+
export function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS): string {
|
|
180
|
+
const key = JSON.stringify(BOOT_WATCHDOG_KEY)
|
|
181
|
+
return (
|
|
182
|
+
// Leading semicolon: the preceding seat assignment ends without one,
|
|
183
|
+
// and the fragment must parse when spliced into the IIFE.
|
|
184
|
+
';function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector("[data-conversation-scroll]")||w.document.querySelector("[data-slot=\\"conversation\\"]")))}catch(e){return false}}' +
|
|
185
|
+
'function wTick(n){try{' +
|
|
186
|
+
'if(wBoot()){try{w.sessionStorage.removeItem(' + key + ')}catch(e){}return}' +
|
|
187
|
+
'if(n<' + waitMs + '){w.setTimeout(function(){wTick(n+' + pollMs + ')},' + pollMs + ');return}' +
|
|
188
|
+
'var done=false;try{done=w.sessionStorage.getItem(' + key + ')==="1"}catch(e){}' +
|
|
189
|
+
'if(done)return;' +
|
|
190
|
+
'try{w.sessionStorage.setItem(' + key + ',"1")}catch(e){}' +
|
|
191
|
+
'w.location.reload()' +
|
|
192
|
+
'}catch(e){}}' +
|
|
193
|
+
'if(typeof w.setTimeout==="function")w.setTimeout(function(){wTick(0)},' + pollMs + ');'
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
130
197
|
/** The script this plugin contributes; built once from the live rules. */
|
|
131
198
|
export const REMOTE_CHANNEL_BOOT_SCRIPT: string = buildRemoteChannelBootScript()
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
/** The gated mirror prefix (must match src/remote-methods.ts). */
|
|
10
10
|
export const REMOTE_PREFIX = '/remote'
|
|
11
11
|
|
|
12
|
+
import { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY } from './remote-methods.ts'
|
|
13
|
+
|
|
14
|
+
export { REMOTE_DEVICE_HEADER, REMOTE_DEVICE_QUERY }
|
|
15
|
+
|
|
12
16
|
/** Connection-plugin method prefix under the gated channel. */
|
|
13
17
|
export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
|
|
14
18
|
|
|
@@ -24,6 +28,12 @@ export interface RemoteChannelRules {
|
|
|
24
28
|
readonly gitPrefix: string
|
|
25
29
|
readonly petPrefix: string
|
|
26
30
|
readonly wsPaths: readonly string[]
|
|
31
|
+
/** Header carrying the cookieless device credential on gated fetches. */
|
|
32
|
+
readonly deviceHeader: string
|
|
33
|
+
/** sessionStorage key holding the device credential (set by /pair-app). */
|
|
34
|
+
readonly deviceKey: string
|
|
35
|
+
/** Query parameter carrying it on WebSocket upgrades. */
|
|
36
|
+
readonly deviceQuery: string
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
/** The live rule set. */
|
|
@@ -37,13 +47,18 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
|
|
|
37
47
|
sidebarPrefix: '/sidebar/',
|
|
38
48
|
gitPrefix: '/git/',
|
|
39
49
|
petPrefix: '/pet/',
|
|
50
|
+
// The official 0.1.2-alpha.2 line opens exactly one stream socket (the
|
|
51
|
+
// Typert gateway mux, /api/remote.mux); the legacy /api/events.* paths
|
|
52
|
+
// were removed by the SDK and stay absent here.
|
|
40
53
|
wsPaths: [
|
|
41
|
-
'/api/
|
|
42
|
-
'/api/events.host',
|
|
54
|
+
'/api/remote.mux',
|
|
43
55
|
'/sidebar/ws/terminal',
|
|
44
56
|
'/sidebar/ws/agent-terminals',
|
|
45
57
|
'/api/dsh-ssh/terminal',
|
|
46
58
|
],
|
|
59
|
+
deviceHeader: REMOTE_DEVICE_HEADER,
|
|
60
|
+
deviceKey: 'dsh-remote-device',
|
|
61
|
+
deviceQuery: REMOTE_DEVICE_QUERY,
|
|
47
62
|
}
|
|
48
63
|
|
|
49
64
|
/** The window global the boot patch publishes its seat under. */
|