@linxin666/dsh-remote-web-ui 0.4.0 → 0.4.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.i18n.yaml +2 -2
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +132 -29
- package/lib/client.js.map +1 -1
- package/lib/index.js +117 -15
- package/lib/types/client/RemoteEntry.js +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -3
- package/lib/types/client/locales.js +2 -2
- package/lib/types/client/pair-api.d.ts +4 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +11 -7
- package/lib/types/client/remote-channel.d.ts +27 -7
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +25 -17
- package/lib/types/client/update-api.js +2 -2
- package/lib/types/lan-bind.d.ts +4 -0
- package/lib/types/lan-bind.d.ts.map +1 -1
- package/lib/types/lan-bind.js +64 -1
- package/lib/types/pairing.d.ts +15 -2
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +26 -14
- package/lib/types/pairing.persist.test.js +43 -3
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +13 -3
- package/lib/types/remote-channel-rules.d.ts +74 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +104 -0
- package/lib/types/update.d.ts +10 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +32 -3
- package/package.json +20 -19
- package/src/client/RemoteEntry.tsx +1 -1
- package/src/client/index.ts +5 -3
- package/src/client/locales.ts +2 -2
- package/src/client/pair-api.ts +11 -7
- package/src/client/remote-channel.ts +38 -15
- package/src/client/remote.module.css +18 -0
- package/src/client/update-api.ts +2 -2
- package/src/lan-bind.ts +62 -1
- package/src/pairing.persist.test.ts +47 -3
- package/src/pairing.ts +26 -12
- package/src/remote-channel-boot.ts +13 -3
- package/src/remote-channel-rules.ts +113 -0
- package/src/update.ts +36 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.2",
|
|
5
5
|
"icon": "icon.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"engines": {
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"license": "Apache-2.0",
|
|
39
39
|
"dsh": {
|
|
40
40
|
"engines": {
|
|
41
|
-
"dsh": ">=0.1.7-rc.
|
|
41
|
+
"dsh": ">=0.1.7-rc.2"
|
|
42
42
|
},
|
|
43
43
|
"bundle": {
|
|
44
44
|
"patch": "./cordis.patch.yml"
|
|
@@ -57,31 +57,32 @@
|
|
|
57
57
|
"clsx": "^2.1.1",
|
|
58
58
|
"qrcode.react": "^4.2.0",
|
|
59
59
|
"schemastery": "^3.18.0",
|
|
60
|
+
"yaml": "^2.8.2",
|
|
60
61
|
"zod": "^4.4.3"
|
|
61
62
|
},
|
|
62
63
|
"peerDependencies": {
|
|
63
|
-
"@deepseek-ai/dsh": ">=0.1.7-rc.
|
|
64
|
+
"@deepseek-ai/dsh": ">=0.1.7-rc.2",
|
|
64
65
|
"react": "^18.2.0",
|
|
65
66
|
"react-dom": "^18.2.0"
|
|
66
67
|
},
|
|
67
68
|
"devDependencies": {
|
|
68
69
|
"@deepseek-ai/cordis": "^4.0.4",
|
|
69
|
-
"@deepseek-ai/dsh-agent": "^0.1.7-rc.
|
|
70
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.
|
|
71
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.
|
|
72
|
-
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.
|
|
73
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.
|
|
74
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.
|
|
75
|
-
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.
|
|
78
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.
|
|
79
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.
|
|
80
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.
|
|
81
|
-
"@deepseek-ai/dsh-commands": "^0.1.7-rc.
|
|
82
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.
|
|
83
|
-
"@deepseek-ai/dsh-invariants": "^0.1.7-rc.
|
|
84
|
-
"@deepseek-ai/dsh-settings": "^0.1.7-rc.
|
|
70
|
+
"@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
|
|
71
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.7-rc.2",
|
|
72
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.7-rc.2",
|
|
73
|
+
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.7-rc.2",
|
|
74
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.7-rc.2",
|
|
75
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.7-rc.2",
|
|
76
|
+
"@deepseek-ai/dsh-client-store": "^0.1.7-rc.2",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.7-rc.2",
|
|
78
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.2",
|
|
79
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.7-rc.2",
|
|
80
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.7-rc.2",
|
|
81
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.7-rc.2",
|
|
82
|
+
"@deepseek-ai/dsh-commands": "^0.1.7-rc.2",
|
|
83
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2",
|
|
84
|
+
"@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
|
|
85
|
+
"@deepseek-ai/dsh-settings": "^0.1.7-rc.2",
|
|
85
86
|
"@testing-library/dom": "^10.4.1",
|
|
86
87
|
"@testing-library/react": "^16.3.2",
|
|
87
88
|
"@types/node": "^22.20.0",
|
|
@@ -128,7 +128,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
128
128
|
// unreachable origins are fenced out of the events endpoint, so opening
|
|
129
129
|
// it there would just start a doomed reconnect loop.
|
|
130
130
|
if (next.kind !== 'ready' && next.kind !== 'lan-required') return
|
|
131
|
-
const source = new EventSource('
|
|
131
|
+
const source = new EventSource('api/pair/events')
|
|
132
132
|
eventSource.current = source
|
|
133
133
|
source.onmessage = (event) => {
|
|
134
134
|
try {
|
package/src/client/index.ts
CHANGED
|
@@ -31,11 +31,12 @@ import { readPairGatePolicy, sendHeartbeat, shouldStopHeartbeat } from './pair-a
|
|
|
31
31
|
import {
|
|
32
32
|
channelTransition,
|
|
33
33
|
installRemoteChannel,
|
|
34
|
-
|
|
34
|
+
pageOriginFacts,
|
|
35
35
|
remoteChannelRequired,
|
|
36
36
|
REMOTE_CHANNEL_BOOT_GLOBAL,
|
|
37
37
|
type RemoteChannelBootSeat,
|
|
38
38
|
} from './remote-channel.ts'
|
|
39
|
+
import { isLocalPage } from '../remote-channel-rules.ts'
|
|
39
40
|
import { FenceNotice } from './FenceNotice.tsx'
|
|
40
41
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
41
42
|
import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
|
|
@@ -333,7 +334,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
333
334
|
showFenceNotice()
|
|
334
335
|
}
|
|
335
336
|
const channelActive = (): boolean => remoteChannelRequired(
|
|
336
|
-
window
|
|
337
|
+
pageOriginFacts(window),
|
|
337
338
|
settingsForm.getSnapshot(),
|
|
338
339
|
hostPairingPolicy,
|
|
339
340
|
)
|
|
@@ -387,7 +388,8 @@ export function apply(ctx: ClientContext): void {
|
|
|
387
388
|
}
|
|
388
389
|
settingsForm.subscribe(syncChannel)
|
|
389
390
|
syncChannel()
|
|
390
|
-
|
|
391
|
+
const pageOrigin = pageOriginFacts(window)
|
|
392
|
+
if (!isLocalPage(pageOrigin.hostname, pageOrigin.protocol, pageOrigin.transportOwnsHost) && settingsForm.getSnapshot().status !== 'ready') {
|
|
391
393
|
void readPairGatePolicy().then((policy) => {
|
|
392
394
|
hostPairingPolicy = policy.requirePairingForLan
|
|
393
395
|
syncChannel()
|
package/src/client/locales.ts
CHANGED
|
@@ -15,7 +15,7 @@ export const zh = {
|
|
|
15
15
|
'status.disconnected': '已配对设备离线',
|
|
16
16
|
'status.stopped': '已停止远程访问',
|
|
17
17
|
'status.lanRequired': '此功能需要局域网绑定或公网地址才能使用',
|
|
18
|
-
'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1
|
|
18
|
+
'status.lanRequiredHint': '当前服务仅绑定在 127.0.0.1 且未配置公网地址,手机无法访问。请在本插件的设置卡片中打开“局域网访问”(装在全家桶里时在「设置 → Web 插件 → 远程访问设置」,单独安装时在设置 → 插件 → 本插件行),或用 dsh web --host 0.0.0.0 重新启动,或填写内网穿透的公网地址。',
|
|
19
19
|
'status.loopbackRequired': '配对面板仅限本机使用',
|
|
20
20
|
'status.loopbackRequiredHint': '请通过 http://127.0.0.1 打开此页面后重试;手机请使用配对链接访问。',
|
|
21
21
|
'status.unreachable': '无法连接配对服务',
|
|
@@ -188,7 +188,7 @@ export const en = {
|
|
|
188
188
|
'status.disconnected': 'Paired devices offline',
|
|
189
189
|
'status.stopped': 'Remote access stopped',
|
|
190
190
|
'status.lanRequired': 'This feature needs a LAN bind or a public address',
|
|
191
|
-
'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in
|
|
191
|
+
'status.lanRequiredHint': 'The server is bound to 127.0.0.1 and no public address is configured, so a phone cannot reach it. Turn on LAN access in this plugin settings card (under Settings → Web Plugins → Remote access when the full family is installed, or on this plugin row under Settings → Plugins for a standalone install), or restart with dsh web --host 0.0.0.0, or set the tunneled public address.',
|
|
192
192
|
'status.loopbackRequired': 'The pairing panel works on this machine only',
|
|
193
193
|
'status.loopbackRequiredHint': 'Open this page at http://127.0.0.1 to mint a QR code; phones use the paired link.',
|
|
194
194
|
'status.unreachable': 'Cannot reach the pairing service',
|
package/src/client/pair-api.ts
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
* Browser-side wire helpers for the /api/pair surface. Plain fetch over
|
|
3
3
|
* same-origin /api (like the connection client); JSON bodies, string
|
|
4
4
|
* responses for the error codes.
|
|
5
|
+
*
|
|
6
|
+
* The paths are DOCUMENT-RELATIVE (issue #1707): the GUI is served with
|
|
7
|
+
* `<base href="./">`, so a sub-path deployment resolves them against its entry
|
|
8
|
+
* directory instead of escaping to the origin root.
|
|
5
9
|
*/
|
|
6
10
|
|
|
7
11
|
/** issue() response. */
|
|
@@ -43,7 +47,7 @@ export interface PairGatePolicy {
|
|
|
43
47
|
|
|
44
48
|
/** Read the host-authoritative desktop pairing policy. */
|
|
45
49
|
export async function readPairGatePolicy(): Promise<PairGatePolicy> {
|
|
46
|
-
const response = await fetch('
|
|
50
|
+
const response = await fetch('api/pair/status')
|
|
47
51
|
if (!response.ok) throw new Error(`remote-web-ui: status failed with ${String(response.status)}`)
|
|
48
52
|
const value = await response.json() as { requirePairingForLan?: unknown }
|
|
49
53
|
if (typeof value.requirePairingForLan !== 'boolean') {
|
|
@@ -116,7 +120,7 @@ export interface DeviceFrame {
|
|
|
116
120
|
* origin — the panel is a desktop control endpoint).
|
|
117
121
|
*/
|
|
118
122
|
export async function issuePair(address?: string): Promise<IssueResponse> {
|
|
119
|
-
const response = await fetch('
|
|
123
|
+
const response = await fetch('api/pair/issue', {
|
|
120
124
|
method: 'POST',
|
|
121
125
|
headers: { 'content-type': 'application/json' },
|
|
122
126
|
body: JSON.stringify({
|
|
@@ -139,7 +143,7 @@ export async function issuePair(address?: string): Promise<IssueResponse> {
|
|
|
139
143
|
* @returns the wire result.
|
|
140
144
|
*/
|
|
141
145
|
export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFailure> {
|
|
142
|
-
const response = await fetch('
|
|
146
|
+
const response = await fetch('api/pair/accept', {
|
|
143
147
|
method: 'POST',
|
|
144
148
|
headers: { 'content-type': 'application/json' },
|
|
145
149
|
body: JSON.stringify({ token }),
|
|
@@ -153,7 +157,7 @@ export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFa
|
|
|
153
157
|
|
|
154
158
|
/** Revoke mobile access (paired devices + the current token). */
|
|
155
159
|
export async function stopPair(): Promise<void> {
|
|
156
|
-
const response = await fetch('
|
|
160
|
+
const response = await fetch('api/pair/stop', { method: 'POST' })
|
|
157
161
|
if (!response.ok) throw new Error(`remote-web-ui: stop failed with ${String(response.status)}`)
|
|
158
162
|
}
|
|
159
163
|
|
|
@@ -162,7 +166,7 @@ export async function stopPair(): Promise<void> {
|
|
|
162
166
|
* @param deviceId - the session id of the row to drop.
|
|
163
167
|
*/
|
|
164
168
|
export async function revokePair(deviceId: string): Promise<void> {
|
|
165
|
-
const response = await fetch('
|
|
169
|
+
const response = await fetch('api/pair/revoke', {
|
|
166
170
|
method: 'POST',
|
|
167
171
|
headers: { 'content-type': 'application/json' },
|
|
168
172
|
body: JSON.stringify({ deviceId }),
|
|
@@ -177,7 +181,7 @@ export async function revokePair(deviceId: string): Promise<void> {
|
|
|
177
181
|
* proves this page is not paired (see {@link shouldStopHeartbeat}).
|
|
178
182
|
*/
|
|
179
183
|
export async function sendHeartbeat(): Promise<number> {
|
|
180
|
-
const response = await fetch('
|
|
184
|
+
const response = await fetch('api/pair/heartbeat', { method: 'POST' })
|
|
181
185
|
return response.status
|
|
182
186
|
}
|
|
183
187
|
|
|
@@ -242,7 +246,7 @@ export class LanBindStatusError extends Error {
|
|
|
242
246
|
|
|
243
247
|
/** Read the LAN-bind facts (loopback-only endpoint). */
|
|
244
248
|
export async function readLanBindStatus(): Promise<LanBindFrame> {
|
|
245
|
-
const response = await fetch('
|
|
249
|
+
const response = await fetch('api/pair/lan-bind')
|
|
246
250
|
if (!response.ok) throw new LanBindStatusError(response.status, `remote-web-ui: lan-bind status failed with ${String(response.status)}`)
|
|
247
251
|
return await response.json() as LanBindFrame
|
|
248
252
|
}
|
|
@@ -23,6 +23,8 @@
|
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
25
|
import {
|
|
26
|
+
isLoopbackHostname as isLoopbackName,
|
|
27
|
+
isLocalPage,
|
|
26
28
|
REMOTE_API_PREFIX,
|
|
27
29
|
REMOTE_CHANNEL_RULES,
|
|
28
30
|
REMOTE_PREFIX,
|
|
@@ -40,13 +42,41 @@ export interface RemoteChannelSettingsSnapshot {
|
|
|
40
42
|
value?: { enabled?: boolean; requirePairingForLan?: boolean }
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Facts about the live page that decide whether it is local. Defaults come
|
|
47
|
+
* from `window` so ordinary call sites stay argument-free; tests pass them
|
|
48
|
+
* explicitly.
|
|
49
|
+
*/
|
|
50
|
+
export interface PageOriginFacts {
|
|
51
|
+
hostname: string
|
|
52
|
+
protocol?: string
|
|
53
|
+
transportOwnsHost?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the page-origin facts from a window-like object: the hostname and
|
|
58
|
+
* protocol of the page itself plus the official transport's host-owner flag
|
|
59
|
+
* (`__DSH_TRANSPORT__.ownsHost`, set by the desktop shell before any boot
|
|
60
|
+
* entry, and by this plugin's own paired landing).
|
|
61
|
+
* @param win - the window to read (defaults to the real one).
|
|
62
|
+
* @returns the facts `isLocalPage`/`remoteChannelRequired` decide from.
|
|
63
|
+
*/
|
|
64
|
+
export function pageOriginFacts(win: Pick<Window, 'location'> & Partial<Pick<Window, 'navigator'>> = window): PageOriginFacts {
|
|
65
|
+
const transport = (win as unknown as { __DSH_TRANSPORT__?: { ownsHost?: unknown } }).__DSH_TRANSPORT__
|
|
66
|
+
return {
|
|
67
|
+
hostname: win.location.hostname,
|
|
68
|
+
protocol: win.location.protocol,
|
|
69
|
+
...(transport?.ownsHost === true ? { transportOwnsHost: true } : {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
43
73
|
/** Decide whether a remote desktop channel is required from local or host policy. */
|
|
44
74
|
export function remoteChannelRequired(
|
|
45
|
-
|
|
75
|
+
origin: PageOriginFacts,
|
|
46
76
|
snapshot: RemoteChannelSettingsSnapshot,
|
|
47
77
|
hostPairingPolicy: boolean | undefined,
|
|
48
78
|
): boolean {
|
|
49
|
-
if (
|
|
79
|
+
if (isLocalPage(origin.hostname, origin.protocol, origin.transportOwnsHost)) return false
|
|
50
80
|
if (snapshot.status === 'ready') {
|
|
51
81
|
return (snapshot.value?.enabled ?? true) && (snapshot.value?.requirePairingForLan ?? true)
|
|
52
82
|
}
|
|
@@ -56,20 +86,13 @@ export function remoteChannelRequired(
|
|
|
56
86
|
}
|
|
57
87
|
|
|
58
88
|
/**
|
|
59
|
-
* Browser-safe loopback classification for the page origin
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
89
|
+
* Browser-safe loopback classification for the page origin. The definition
|
|
90
|
+
* lives once in the rules module, which the inlined boot script mirrors
|
|
91
|
+
* verbatim, so the browser half and the parse-time patch can never disagree
|
|
92
|
+
* (an IPv6-loopback origin judged remote would rewrite every call onto a
|
|
93
|
+
* channel it can never pair on).
|
|
63
94
|
*/
|
|
64
|
-
export
|
|
65
|
-
// WHATWG location.hostname keeps IPv6 literals bracketed ("[::1]"); the
|
|
66
|
-
// host-side shared predicate accepts that spelling, so this browser copy must
|
|
67
|
-
// too, or an IPv6-loopback origin is judged remote and rewrites every call
|
|
68
|
-
// onto the gated channel where it can never pair.
|
|
69
|
-
if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
|
|
70
|
-
const parts = hostname.split('.')
|
|
71
|
-
return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
72
|
-
}
|
|
95
|
+
export const isLoopbackHostname = isLoopbackName
|
|
73
96
|
|
|
74
97
|
/**
|
|
75
98
|
* Whether one same-origin path must ride the gated channel (fetch, EventSource,
|
|
@@ -822,6 +822,24 @@
|
|
|
822
822
|
align-items: center;
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
+
/* ── Collapsed rail foot seat ────────────────────────────────────────────
|
|
826
|
+
|
|
827
|
+
The shell renders `sidebar.footer.action` as a centered horizontal row
|
|
828
|
+
in both column states. In the 56px rail that row is only as wide as its
|
|
829
|
+
occupants, so a second registrar overflows the icon column on both sides:
|
|
830
|
+
with two 36px circles the seat measures 78px against a 35px column, and
|
|
831
|
+
the outer buttons land at x=-11 and x=31 while every other rail row sits
|
|
832
|
+
at x=10 (issue #1678). Stack the seat vertically whenever a rail-marked
|
|
833
|
+
occupant is inside it, so each circle keeps the 36px rail column the
|
|
834
|
+
shell uses elsewhere; the wide column never matches this rule. The anchor
|
|
835
|
+
is this plugin's own `data-rail` marker, so the rule is an equal-value
|
|
836
|
+
no-op for a shell that already stacks its foot. */
|
|
837
|
+
:global([data-dsh-frame][data-sidebar-collapsed]) [class*='footerActions']:has([data-rail='rail']) {
|
|
838
|
+
flex-direction: column;
|
|
839
|
+
align-items: center;
|
|
840
|
+
gap: 4px;
|
|
841
|
+
}
|
|
842
|
+
|
|
825
843
|
/* ── update panel extras ─────────────────────────────────────────────── */
|
|
826
844
|
|
|
827
845
|
.updateStatus {
|
package/src/client/update-api.ts
CHANGED
|
@@ -28,7 +28,7 @@ export class UpdateStatusError extends Error {
|
|
|
28
28
|
export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
|
29
29
|
let response: Response
|
|
30
30
|
try {
|
|
31
|
-
response = await fetch('
|
|
31
|
+
response = await fetch('api/update/status')
|
|
32
32
|
} catch {
|
|
33
33
|
// Network-level failure: nothing came back at all.
|
|
34
34
|
throw new UpdateStatusError(0)
|
|
@@ -43,7 +43,7 @@ export async function fetchUpdateStatus(): Promise<UpdateStatus> {
|
|
|
43
43
|
* @returns the run outcome.
|
|
44
44
|
*/
|
|
45
45
|
export async function runUpdate(): Promise<UpdateRunResult> {
|
|
46
|
-
const response = await fetch('
|
|
46
|
+
const response = await fetch('api/update/run', { method: 'POST' })
|
|
47
47
|
if (!response.ok) throw new Error('update run unavailable')
|
|
48
48
|
return await response.json() as UpdateRunResult
|
|
49
49
|
}
|
package/src/lan-bind.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
23
23
|
import { dirname, join, resolve, sep } from 'node:path'
|
|
24
|
+
import { parseDocument } from 'yaml'
|
|
24
25
|
import { dshHome } from './dsh-home.ts'
|
|
25
26
|
|
|
26
27
|
/** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
|
|
@@ -69,6 +70,62 @@ export function stripManagedBlock(content: string): string {
|
|
|
69
70
|
return content.replace(pattern, '\n')
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Render the managed block for one bind state. The values are static: the
|
|
75
|
+
* user patch layer has no reliable lazy service evaluation, and the plugin
|
|
76
|
+
* re-asserts the block at every boot so CLI flags (--port, --host) win by
|
|
77
|
+
* rewriting it before the next start.
|
|
78
|
+
*/
|
|
79
|
+
/**
|
|
80
|
+
* Whether the base opens a flow collection at the root. That is the shape the
|
|
81
|
+
* plugin manager's YAML document round-trip leaves behind when it appends rows
|
|
82
|
+
* to the profile's placeholder `[]`: `[ { id: … }, … ]`. Every other base
|
|
83
|
+
* (empty, block sequence, comment-only prefix) keeps the historical string
|
|
84
|
+
* concatenation, so the common paths stay byte-identical.
|
|
85
|
+
*/
|
|
86
|
+
function isFlowRoot(base: string): boolean {
|
|
87
|
+
return base.trimStart().startsWith('[')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Force one parsed collection (and its item collections) into block style. */
|
|
91
|
+
function toBlockStyle(node: unknown): void {
|
|
92
|
+
if (typeof node !== 'object' || node === null) return
|
|
93
|
+
const collection = node as { flow?: boolean; items?: unknown[] }
|
|
94
|
+
if (!Array.isArray(collection.items) && typeof collection.flow !== 'boolean') return
|
|
95
|
+
collection.flow = false
|
|
96
|
+
for (const item of collection.items ?? []) toBlockStyle(item)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Plain-value view of one parsed node (YAML nodes keep their own accessors). */
|
|
100
|
+
function plainOf(node: unknown): unknown {
|
|
101
|
+
if (typeof node !== 'object' || node === null) return node
|
|
102
|
+
const json = (node as { toJSON?: () => unknown }).toJSON
|
|
103
|
+
return typeof json === 'function' ? json.call(node) : node
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Produce the file text for one bind state. A flow-style base is re-emitted as
|
|
108
|
+
* a block sequence before the managed block is appended, so the file stays
|
|
109
|
+
* exactly one valid YAML document; concatenating the block onto a non-empty
|
|
110
|
+
* flow array produced two root documents and the profile failed to parse at
|
|
111
|
+
* `dsh web` startup (#1675). A base that cannot be parsed is refused with the
|
|
112
|
+
* file path rather than written half-valid.
|
|
113
|
+
*/
|
|
114
|
+
function mergePatchContent(base: string, block: string, file: string): string {
|
|
115
|
+
if (base.length === 0) return block
|
|
116
|
+
if (!isFlowRoot(base)) return `${base}\n\n${block}`
|
|
117
|
+
const doc = parseDocument(base, { uniqueKeys: false })
|
|
118
|
+
if (doc.errors.length > 0 || doc.contents === null) {
|
|
119
|
+
throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: ${doc.errors[0]?.message ?? 'the patch file is not a YAML document'}`)
|
|
120
|
+
}
|
|
121
|
+
if (!('items' in (doc.contents as { items?: unknown[] }))) {
|
|
122
|
+
throw new Error(`remote-web-ui: cannot update the lan-bind block in ${file}: the patch file is not a top-level patch list`)
|
|
123
|
+
}
|
|
124
|
+
toBlockStyle(doc.contents)
|
|
125
|
+
const rendered = doc.toString({ lineWidth: 0 }).trimEnd()
|
|
126
|
+
return `${rendered}\n\n${block}`
|
|
127
|
+
}
|
|
128
|
+
|
|
72
129
|
/**
|
|
73
130
|
* Render the managed block for one bind state. The values are static: the
|
|
74
131
|
* user patch layer has no reliable lazy service evaluation, and the plugin
|
|
@@ -130,6 +187,10 @@ export function lanBindState(profile: string, home: string = dshHome()): { block
|
|
|
130
187
|
* unterminated block (BEGIN marker without END, which stripManagedBlock
|
|
131
188
|
* cannot match) is truncated at its BEGIN marker first so the rewrite can
|
|
132
189
|
* never stack a second webserver row onto the orphan.
|
|
190
|
+
*
|
|
191
|
+
* The written file is always exactly one valid YAML document: a base the
|
|
192
|
+
* plugin manager left in flow style is re-emitted as a block sequence before
|
|
193
|
+
* the managed block is appended (see mergePatchContent).
|
|
133
194
|
*/
|
|
134
195
|
export function writeLanBind(host: LanBindHost, port: number, profile: string, home: string = dshHome()): void {
|
|
135
196
|
const file = profilePatchFile(profile, home)
|
|
@@ -138,7 +199,7 @@ export function writeLanBind(host: LanBindHost, port: number, profile: string, h
|
|
|
138
199
|
const rawBase = (orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()
|
|
139
200
|
const base = rawBase.replace(/\[\s*\]\s*$/, '').trimEnd()
|
|
140
201
|
const block = managedBlock(host, port)
|
|
141
|
-
const content = base
|
|
202
|
+
const content = mergePatchContent(base, block, file)
|
|
142
203
|
const mode = existsSync(file) ? statSync(file).mode & 0o777 : 0o600
|
|
143
204
|
mkdirSync(dirname(file), { recursive: true })
|
|
144
205
|
const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
@@ -96,17 +96,61 @@ describe('PairingService device persistence', () => {
|
|
|
96
96
|
expect(restarted.hasDevice(deviceId)).toBe(true)
|
|
97
97
|
})
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
// #1696: the constructor runs before the saved settings row is applied, so
|
|
100
|
+
// its cap can still be the schema default. Restoring must not enforce it —
|
|
101
|
+
// trimming there silently revokes authorizations the user never revoked,
|
|
102
|
+
// and the trimmed table reaches disk on the next heartbeat/sweep write.
|
|
103
|
+
it('operator: every valid persisted session survives a restart under the startup cap', () => {
|
|
100
104
|
const file = join(dir, 'devices.json')
|
|
105
|
+
// Given a store holding three devices while the startup cap reads 4
|
|
106
|
+
// (the schema default) and the saved settings value is 12.
|
|
101
107
|
writeFileSync(file, JSON.stringify({
|
|
102
108
|
dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
|
|
103
109
|
dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
|
|
104
110
|
dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
|
|
105
111
|
}))
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
// When a new process restores the store.
|
|
113
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock())
|
|
114
|
+
// Then no paired device lost its authorization to the transient cap.
|
|
115
|
+
expect(service.hasDevice('dev1')).toBe(true)
|
|
108
116
|
expect(service.hasDevice('dev2')).toBe(true)
|
|
117
|
+
expect(service.hasDevice('dev3')).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('operator: a restored over-cap table is not trimmed by a heartbeat and sweep', () => {
|
|
121
|
+
const file = join(dir, 'devices.json')
|
|
122
|
+
// Given twelve persisted devices restored under the default cap of 4.
|
|
123
|
+
const rows = Object.fromEntries(
|
|
124
|
+
Array.from({ length: 12 }, (_, i) => [`dev${i}`, { createdAt: 1_000 + i, lastSeenAt: 1_000 + i }]),
|
|
125
|
+
)
|
|
126
|
+
writeFileSync(file, JSON.stringify(rows))
|
|
127
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 4, devicesFile: file }, makeClock())
|
|
128
|
+
// When the saved cap of 12 arrives and normal traffic writes the store.
|
|
129
|
+
service.config = { ...service.config, maxDevices: 12 }
|
|
130
|
+
service.heartbeat('dev0')
|
|
131
|
+
service.sweep()
|
|
132
|
+
// Then the file still names all twelve devices.
|
|
133
|
+
const persisted = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
134
|
+
expect(Object.keys(persisted)).toHaveLength(12)
|
|
135
|
+
expect(service.deviceCount()).toBe(12)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('operator: the cap is enforced when a device is admitted, not before', () => {
|
|
139
|
+
const file = join(dir, 'devices.json')
|
|
140
|
+
// Given three persisted devices restored under a cap of 2.
|
|
141
|
+
writeFileSync(file, JSON.stringify({
|
|
142
|
+
dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
|
|
143
|
+
dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
|
|
144
|
+
dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
|
|
145
|
+
}))
|
|
146
|
+
const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
|
|
147
|
+
expect(service.deviceCount()).toBe(3)
|
|
148
|
+
// When one more device pairs.
|
|
149
|
+
const paired = pairDevice(service)
|
|
150
|
+
// Then the table is trimmed to the cap, oldest first, keeping the newcomer.
|
|
151
|
+
expect(service.deviceCount()).toBe(2)
|
|
109
152
|
expect(service.hasDevice('dev1')).toBe(false)
|
|
153
|
+
expect(service.hasDevice(paired)).toBe(true)
|
|
110
154
|
})
|
|
111
155
|
|
|
112
156
|
it('keeps sessions memory-only when devicesFile is unset', () => {
|
package/src/pairing.ts
CHANGED
|
@@ -248,6 +248,14 @@ export class PairingService {
|
|
|
248
248
|
* Restore device sessions persisted by a previous process run. A corrupt
|
|
249
249
|
* or missing file is tolerated (an empty device table, never a throw) —
|
|
250
250
|
* persistence is an availability convenience, not a security boundary.
|
|
251
|
+
*
|
|
252
|
+
* Every still-valid session is restored, deliberately without applying
|
|
253
|
+
* `maxDevices`: the constructor runs with whatever cap the configuration
|
|
254
|
+
* layer has delivered so far (the schema default until the saved settings
|
|
255
|
+
* row is applied), and trimming here would permanently drop authorizations
|
|
256
|
+
* the user never revoked — the trimmed table reaches disk on the next
|
|
257
|
+
* heartbeat/sweep write. The cap is enforced where a device is admitted
|
|
258
|
+
* (see `accept`), against the configuration in force at that moment.
|
|
251
259
|
*/
|
|
252
260
|
private loadPersisted(): void {
|
|
253
261
|
const file = this.config.devicesFile
|
|
@@ -271,21 +279,12 @@ export class PairingService {
|
|
|
271
279
|
...(label !== undefined ? { userAgent: label } : {}),
|
|
272
280
|
})
|
|
273
281
|
}
|
|
274
|
-
this.clampToMaxDevices()
|
|
275
282
|
if (this.evictIdle()) this.persistRevocation()
|
|
276
283
|
} catch {
|
|
277
284
|
// Unreadable/corrupt: start empty rather than refusing to boot.
|
|
278
285
|
}
|
|
279
286
|
}
|
|
280
287
|
|
|
281
|
-
/** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
|
|
282
|
-
private clampToMaxDevices(): void {
|
|
283
|
-
if (this.devices.size <= this.config.maxDevices) return
|
|
284
|
-
const overflow = this.devices.size - this.config.maxDevices
|
|
285
|
-
const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
|
|
286
|
-
for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
288
|
/** Drop sessions whose lastSeenAt is older than idleExpireMs. */
|
|
290
289
|
private evictIdle(): boolean {
|
|
291
290
|
const now = this.clock.now()
|
|
@@ -457,13 +456,18 @@ export class PairingService {
|
|
|
457
456
|
}
|
|
458
457
|
const deviceId = this.clock.randomToken()
|
|
459
458
|
const now = this.clock.now()
|
|
460
|
-
|
|
461
|
-
|
|
459
|
+
// FIFO-evict until a slot is free. The loop (not a single delete) covers a
|
|
460
|
+
// table that is already over the cap: restore keeps every persisted session
|
|
461
|
+
// (see loadPersisted), so the excess is collected here, once the store is
|
|
462
|
+
// actually asked to admit a new device, rather than at startup on a cap the
|
|
463
|
+
// configuration may not have delivered yet.
|
|
464
|
+
while (this.devices.size >= this.config.maxDevices) {
|
|
462
465
|
let oldest: { id: string; createdAt: number } | undefined
|
|
463
466
|
for (const [id, session] of this.devices) {
|
|
464
467
|
if (oldest === undefined || session.createdAt < oldest.createdAt) oldest = { id, createdAt: session.createdAt }
|
|
465
468
|
}
|
|
466
|
-
if (oldest
|
|
469
|
+
if (oldest === undefined) break
|
|
470
|
+
this.devices.delete(oldest.id)
|
|
467
471
|
}
|
|
468
472
|
const label = sanitizeUserAgent(userAgent)
|
|
469
473
|
this.devices.set(deviceId, {
|
|
@@ -570,6 +574,16 @@ export class PairingService {
|
|
|
570
574
|
return this.liveSession(deviceId) !== undefined
|
|
571
575
|
}
|
|
572
576
|
|
|
577
|
+
/**
|
|
578
|
+
* Number of device sessions in the table, including idle ones whose idle
|
|
579
|
+
* window has not been swept yet. Restore keeps every persisted session
|
|
580
|
+
* (see `loadPersisted`), so this can exceed the configured cap until the
|
|
581
|
+
* next `accept` trims the excess.
|
|
582
|
+
*/
|
|
583
|
+
deviceCount(): number {
|
|
584
|
+
return this.devices.size
|
|
585
|
+
}
|
|
586
|
+
|
|
573
587
|
/** Subscribe to snapshot changes (each emit passes a fresh snapshot). */
|
|
574
588
|
onState(listener: (snapshot: PairingSnapshot) => void): () => void {
|
|
575
589
|
this.listeners.add(listener)
|
|
@@ -45,10 +45,20 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
45
45
|
return '(function(){' +
|
|
46
46
|
'try{' +
|
|
47
47
|
'var w=window,loc=w.location,h=loc.hostname;' +
|
|
48
|
-
// Loopback origins (including the bracketed IPv6 literal WHATWG returns)
|
|
49
|
-
// keep the original paths (mirrors isLoopbackHostname).
|
|
50
|
-
"if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
51
48
|
'var R=' + json + ';' +
|
|
49
|
+
// A page the machine itself serves keeps the original paths and no
|
|
50
|
+
// channel: loopback names (including the bracketed IPv6 literal WHATWG
|
|
51
|
+
// returns), any scheme no web transport carries (an application on this
|
|
52
|
+
// machine delivered it - the desktop shell's own scheme, a file page, a
|
|
53
|
+
// scheme a newer shell introduces), or a shell the official transport
|
|
54
|
+
// already declared the host owner. This mirrors isLocalPage in
|
|
55
|
+
// remote-channel-rules.ts, which owns the decision.
|
|
56
|
+
"if(h==='localhost'||h==='::1'||h==='[::1]'||/^127(\\.\\d{1,3}){3}$/.test(h))return;" +
|
|
57
|
+
'var pr=loc.protocol;' +
|
|
58
|
+
'if(typeof pr==="string"&&pr!==""&&R.webProtocols.indexOf(pr)===-1)return;' +
|
|
59
|
+
// ownsHost alone does not lift the fence: the plugin's own device-gated
|
|
60
|
+
// landing grants it to a paired LAN/tunnel page, which must stay gated.
|
|
61
|
+
'try{if(w.__DSH_TRANSPORT__&&w.__DSH_TRANSPORT__.ownsHost===true&&h.indexOf(".")===-1&&h.indexOf(":")===-1)return}catch(e){}' +
|
|
52
62
|
// Host mode is server-granted: only the device-gated app landing
|
|
53
63
|
// publishes the grant marker (in a capture script that runs ahead of
|
|
54
64
|
// this one), so a shell that merely sits on a non-loopback origin - an
|