@linxin666/dsh-remote-web-ui 0.3.23 → 0.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/README.i18n.yaml +2 -2
- package/README.md +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
package/src/client/index.ts
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
import { createElement } from 'react'
|
|
11
11
|
import { createRoot } from 'react-dom/client'
|
|
12
12
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
13
|
-
import type {
|
|
13
|
+
import type { ConfigForm, ConfigForms } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
14
14
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale) and the
|
|
15
15
|
// ui-sidebar SlotMap merge (the 'sidebar.footer.action' hole).
|
|
16
16
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
17
17
|
// Type-only: pulls the settings-surface SlotMap merge (the 'settings.section'
|
|
18
|
-
// entry) and the ctx.
|
|
18
|
+
// entry) and the ctx.configForms Context merge.
|
|
19
19
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
|
21
21
|
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
@@ -27,7 +27,7 @@ import { PairFailedNotice } from './PairFailedNotice.tsx'
|
|
|
27
27
|
import { RemoteSettingsCard, RemoteSettingsCardController, type RemoteSettings } from './RemoteSettingsCard.tsx'
|
|
28
28
|
import { en, zh, type RemoteKey } from './locales.ts'
|
|
29
29
|
import { PAIR_FAILED_MARKER, runPairBootFlow } from './deep-link.ts'
|
|
30
|
-
import { readPairGatePolicy, sendHeartbeat } from './pair-api.ts'
|
|
30
|
+
import { readPairGatePolicy, sendHeartbeat, shouldStopHeartbeat } from './pair-api.ts'
|
|
31
31
|
import {
|
|
32
32
|
channelTransition,
|
|
33
33
|
installRemoteChannel,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
import { FenceNotice } from './FenceNotice.tsx'
|
|
40
40
|
import { reportDailyHeartbeat } from './telemetry.ts'
|
|
41
41
|
import { startMobileAdapt, type RemoteAdaptGlobal } from './mobile-adapt.ts'
|
|
42
|
+
import { installPluginCard } from './plugin-card-seat.ts'
|
|
42
43
|
|
|
43
44
|
// Portrait-touch adaptation of the official UI: installed under the plugin
|
|
44
45
|
// lifecycle (apply) so disabling the plugin in cordis patch (disabled: true)
|
|
@@ -66,9 +67,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
66
67
|
*/
|
|
67
68
|
/**
|
|
68
69
|
* The child slot the Web UI plugin group declares; this card registers
|
|
69
|
-
* into the group
|
|
70
|
-
* Spelled here with the same shape so this
|
|
71
|
-
* depending on the sibling UI package.
|
|
70
|
+
* into the group's list seat rather than the official
|
|
71
|
+
* bundle-configuration seat. Spelled here with the same shape so this
|
|
72
|
+
* package can register without depending on the sibling UI package.
|
|
72
73
|
*/
|
|
73
74
|
'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
|
|
74
75
|
}
|
|
@@ -89,11 +90,18 @@ export interface SettingsPluginItemOwnerProps {
|
|
|
89
90
|
declare module '@deepseek-ai/cordis' {
|
|
90
91
|
interface Context {
|
|
91
92
|
/**
|
|
92
|
-
* Optional
|
|
93
|
-
*
|
|
94
|
-
*
|
|
93
|
+
* Optional family settings binder provided by dsh-web-settings; absent
|
|
94
|
+
* when that group plugin is not installed, so callers fall back to the
|
|
95
|
+
* official `ctx.configForms` service.
|
|
96
|
+
*
|
|
97
|
+
* The 0.1.7 client addresses one form per active profile entry id and
|
|
98
|
+
* carries no package identity, so the family binder resolves a family
|
|
99
|
+
* settings namespace to the entry that owns it and hands back that entry's
|
|
100
|
+
* shared form; the official service is addressed by the entry id directly.
|
|
95
101
|
*/
|
|
96
|
-
webUiSettings?: {
|
|
102
|
+
webUiSettings?: {
|
|
103
|
+
bind<S>(spec: { namespace: string; decode?: (section: unknown) => S | undefined }): ConfigForm<S>
|
|
104
|
+
}
|
|
97
105
|
}
|
|
98
106
|
}
|
|
99
107
|
|
|
@@ -103,12 +111,25 @@ const NS = 'remote'
|
|
|
103
111
|
|
|
104
112
|
/** Settings namespace the remote-control card edits (the Host plugin registers it). */
|
|
105
113
|
const REMOTE_WEB_UI_NS = 'remote-web-ui'
|
|
114
|
+
const AGGREGATE_ENTRY_ID = 'web-ui-remote-web-ui'
|
|
115
|
+
const REMOTE_WEB_UI_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'ui-remote-web-ui', REMOTE_WEB_UI_NS]
|
|
116
|
+
|
|
117
|
+
function servedEntryId(forms: ConfigForms): string {
|
|
118
|
+
let served: readonly string[] | undefined
|
|
119
|
+
try {
|
|
120
|
+
served = forms.describe().getSnapshot().view?.namespaces.map(view => view.ns)
|
|
121
|
+
} catch {
|
|
122
|
+
served = undefined
|
|
123
|
+
}
|
|
124
|
+
if (!served || served.length === 0) return REMOTE_WEB_UI_NS
|
|
125
|
+
return REMOTE_WEB_UI_ENTRY_IDS.find(id => served.includes(id)) ?? REMOTE_WEB_UI_NS
|
|
126
|
+
}
|
|
106
127
|
|
|
107
128
|
/** Heartbeat cadence from a paired phone (presence + revocation liveness). */
|
|
108
129
|
const HEARTBEAT_INTERVAL_MS = 10_000
|
|
109
130
|
|
|
110
131
|
/** Services required by this plugin. */
|
|
111
|
-
export const inject = ['slots', 'locale', 'connection', '
|
|
132
|
+
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
112
133
|
|
|
113
134
|
/**
|
|
114
135
|
* Register the remote-control surface.
|
|
@@ -177,10 +198,17 @@ export function apply(ctx: ClientContext): void {
|
|
|
177
198
|
// before any dictionary existed; the wiring plus the layer's own sync tick
|
|
178
199
|
// re-render them in the active locale.
|
|
179
200
|
if (adapt !== undefined) adapt.translate = t
|
|
180
|
-
|
|
181
|
-
|
|
201
|
+
// The settings form this card edits. The family group's binder resolves a
|
|
202
|
+
// family namespace to the profile entry that owns it and binds that entry's
|
|
203
|
+
// shared form (natively, or over its loopback bridge); without that group the
|
|
204
|
+
// namespace IS the owning entry id, so the official service is addressed
|
|
205
|
+
// directly.
|
|
206
|
+
const family = ctx.get('webUiSettings')
|
|
207
|
+
const settingsForm: ConfigForm<RemoteSettings> = family !== undefined && typeof family.bind === 'function'
|
|
208
|
+
? family.bind<RemoteSettings>({ namespace: REMOTE_WEB_UI_NS })
|
|
209
|
+
: ctx.configForms.get<RemoteSettings>(servedEntryId(ctx.configForms))
|
|
182
210
|
const enabled = (): boolean => {
|
|
183
|
-
const snapshot =
|
|
211
|
+
const snapshot = settingsForm.getSnapshot()
|
|
184
212
|
return snapshot.status === 'ready'
|
|
185
213
|
? snapshot.value?.enabled ?? true
|
|
186
214
|
: snapshot.status === 'unavailable'
|
|
@@ -195,7 +223,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
195
223
|
const syncAdaptEnabled = (): void => {
|
|
196
224
|
;(window as unknown as { __dshRemoteAdapt?: RemoteAdaptGlobal }).__dshRemoteAdapt?.setEnabled?.(enabled())
|
|
197
225
|
}
|
|
198
|
-
|
|
226
|
+
settingsForm.subscribe(syncAdaptEnabled)
|
|
199
227
|
syncAdaptEnabled()
|
|
200
228
|
// The replay closes a details panel restored before the wiring; the
|
|
201
229
|
// layout face throws while the root entry has not mounted yet (a real
|
|
@@ -223,7 +251,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
223
251
|
disposeEntry = undefined
|
|
224
252
|
}
|
|
225
253
|
}
|
|
226
|
-
const unsubscribe =
|
|
254
|
+
const unsubscribe = settingsForm.subscribe(syncEntry)
|
|
227
255
|
syncEntry()
|
|
228
256
|
return () => {
|
|
229
257
|
unsubscribe()
|
|
@@ -232,25 +260,20 @@ export function apply(ctx: ClientContext): void {
|
|
|
232
260
|
})
|
|
233
261
|
|
|
234
262
|
// Plugin configuration card: one staged form over the `remote-web-ui`
|
|
235
|
-
// settings namespace, contributed to
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
remoteSettings.dispose()
|
|
248
|
-
unregister()
|
|
249
|
-
}
|
|
250
|
-
} catch {
|
|
251
|
-
return () => {}
|
|
252
|
-
}
|
|
263
|
+
// settings namespace, contributed to whichever plugin-card seat the running
|
|
264
|
+
// host declares (the family group's list seat, or the official
|
|
265
|
+
// bundle-configuration seat on the plugin manager page when dsh-web-settings
|
|
266
|
+
// is not installed).
|
|
267
|
+
const remoteSettings = new RemoteSettingsCardController(settingsForm)
|
|
268
|
+
installPluginCard(ctx, {
|
|
269
|
+
bundle: '@linxin666/dsh-remote-web-ui',
|
|
270
|
+
id: 'remote-web-ui',
|
|
271
|
+
order: 90,
|
|
272
|
+
locale: NS,
|
|
273
|
+
inject: () => remoteSettings.inject(),
|
|
274
|
+
component: RemoteSettingsCard,
|
|
253
275
|
})
|
|
276
|
+
ctx.effect(() => () => { remoteSettings.dispose() }, 'remote-web-ui: settings card')
|
|
254
277
|
|
|
255
278
|
// Phone-side boot flow + heartbeats. Loopback pages (the desktop) never
|
|
256
279
|
// heartbeat; the server ignores unpaired heartbeats anyway. Both run only
|
|
@@ -263,7 +286,14 @@ export function apply(ctx: ClientContext): void {
|
|
|
263
286
|
const loopback = connection?.isLoopback ?? true
|
|
264
287
|
runPairBootFlow(ctx, window.location.search)
|
|
265
288
|
if (loopback) return () => {}
|
|
266
|
-
const timer = window.setInterval(() => {
|
|
289
|
+
const timer = window.setInterval(() => {
|
|
290
|
+
void sendHeartbeat().then((status) => {
|
|
291
|
+
// A revoked (401) or fenced-off (403) page can never be accepted
|
|
292
|
+
// again without re-pairing, so the 10 s wake source stops instead of
|
|
293
|
+
// polling a refusal for the lifetime of the tab.
|
|
294
|
+
if (shouldStopHeartbeat(status)) window.clearInterval(timer)
|
|
295
|
+
}).catch(() => {})
|
|
296
|
+
}, HEARTBEAT_INTERVAL_MS)
|
|
267
297
|
return () => { window.clearInterval(timer) }
|
|
268
298
|
}, 'remote-web-ui: pair flow + heartbeats')
|
|
269
299
|
} else if (!enabled() && disposeRuntime !== undefined) {
|
|
@@ -271,7 +301,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
271
301
|
disposeRuntime = undefined
|
|
272
302
|
}
|
|
273
303
|
}
|
|
274
|
-
|
|
304
|
+
settingsForm.subscribe(syncRuntime)
|
|
275
305
|
syncRuntime()
|
|
276
306
|
|
|
277
307
|
// Remote desktop channel: on a non-loopback origin (LAN address or public
|
|
@@ -296,7 +326,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
296
326
|
fenceNotice = undefined
|
|
297
327
|
}
|
|
298
328
|
const handleUnpaired = (): void => {
|
|
299
|
-
if (
|
|
329
|
+
if (settingsForm.getSnapshot().status !== 'ready' && hostPairingPolicy === undefined) {
|
|
300
330
|
unpairedWhilePolicyPending = true
|
|
301
331
|
return
|
|
302
332
|
}
|
|
@@ -304,7 +334,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
304
334
|
}
|
|
305
335
|
const channelActive = (): boolean => remoteChannelRequired(
|
|
306
336
|
window.location.hostname,
|
|
307
|
-
|
|
337
|
+
settingsForm.getSnapshot(),
|
|
308
338
|
hostPairingPolicy,
|
|
309
339
|
)
|
|
310
340
|
// The parse-time boot patch (issue #987), when the served index carried
|
|
@@ -355,9 +385,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
355
385
|
bootSeat()?.restore()
|
|
356
386
|
}
|
|
357
387
|
}
|
|
358
|
-
|
|
388
|
+
settingsForm.subscribe(syncChannel)
|
|
359
389
|
syncChannel()
|
|
360
|
-
if (!isLoopbackHostname(window.location.hostname) &&
|
|
390
|
+
if (!isLoopbackHostname(window.location.hostname) && settingsForm.getSnapshot().status !== 'ready') {
|
|
361
391
|
void readPairGatePolicy().then((policy) => {
|
|
362
392
|
hostPairingPolicy = policy.requirePairingForLan
|
|
363
393
|
syncChannel()
|
package/src/client/locales.ts
CHANGED
|
@@ -31,9 +31,9 @@ export const zh = {
|
|
|
31
31
|
'pair.linkLabel': '配对链接',
|
|
32
32
|
'pair.tokenLabel': '配对令牌',
|
|
33
33
|
'pair.dockerHint': 'Docker 或反向代理环境下,可直接复制此令牌并在目标设备配对页面输入。',
|
|
34
|
-
'pair.oneTimeHint': '
|
|
34
|
+
'pair.oneTimeHint': '链接含限时令牌;在令牌过期或刷新二维码之前,同一链接可为多台设备完成配对(每次配对各自生成独立设备会话)。',
|
|
35
35
|
'pair.failed.title': '配对失败',
|
|
36
|
-
'pair.failed.detail': '
|
|
36
|
+
'pair.failed.detail': '链接无效或已过期,请回到电脑端刷新二维码后重新扫码。',
|
|
37
37
|
'fence.unpaired.title': '此设备未配对,无法访问工作区数据',
|
|
38
38
|
'fence.unpaired.eyebrow': '需要设备配对',
|
|
39
39
|
'fence.unpaired.hint': '为保护工作区、会话与插件数据,远程电脑必须先通过主电脑授权。',
|
|
@@ -45,7 +45,6 @@ export const zh = {
|
|
|
45
45
|
'fence.unpaired.pairAction': '立即配对',
|
|
46
46
|
'fence.unpaired.pairing': '配对中…',
|
|
47
47
|
'fence.unpaired.tokenInvalid': '配对链接或 Token 无效已过期',
|
|
48
|
-
'fence.unpaired.tokenUsed': '该配对链接已被使用',
|
|
49
48
|
'fence.unpaired.tokenFailed': '配对失败,请检查网络或重新获取链接',
|
|
50
49
|
'fence.unpaired.footnote': '请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。',
|
|
51
50
|
'posture.exposed': '/api 通道对未配对设备敞开',
|
|
@@ -72,7 +71,7 @@ export const zh = {
|
|
|
72
71
|
'relay.registering': '正在把临时隧道同步到固定域名(<id>.dsh-market.com)…',
|
|
73
72
|
'relay.failed': '固定域名同步失败:{error}。正在自动重试,期间二维码可能使用临时地址。',
|
|
74
73
|
'close.label': '关闭远程访问面板',
|
|
75
|
-
//
|
|
74
|
+
// 插件设置卡片(家族 list 席位,或官方 plugins.bundle.config 席位)。
|
|
76
75
|
'settings.title': '远程访问设置',
|
|
77
76
|
'settings.description': '配对安全与设备限额。',
|
|
78
77
|
'settings.enabled': '启用远程访问',
|
|
@@ -205,9 +204,9 @@ export const en = {
|
|
|
205
204
|
'pair.linkLabel': 'Pairing link',
|
|
206
205
|
'pair.tokenLabel': 'Pairing token',
|
|
207
206
|
'pair.dockerHint': 'In Docker or reverse proxy environments, copy this token to pair directly on the target device.',
|
|
208
|
-
'pair.oneTimeHint': 'The link carries
|
|
207
|
+
'pair.oneTimeHint': 'The link carries a time-limited token: until it expires or the QR is refreshed, the same link can pair several devices, each with its own device session.',
|
|
209
208
|
'pair.failed.title': 'Pairing failed',
|
|
210
|
-
'pair.failed.detail': 'The link is invalid or
|
|
209
|
+
'pair.failed.detail': 'The link is invalid or has expired. Refresh the QR code on your computer and scan again.',
|
|
211
210
|
'fence.unpaired.title': 'This device is not paired and cannot reach workspace data',
|
|
212
211
|
'fence.unpaired.eyebrow': 'Device pairing required',
|
|
213
212
|
'fence.unpaired.hint': 'To protect workspace, session, and plugin data, a remote computer must first be authorized by the primary computer.',
|
|
@@ -219,7 +218,6 @@ export const en = {
|
|
|
219
218
|
'fence.unpaired.pairAction': 'Pair Now',
|
|
220
219
|
'fence.unpaired.pairing': 'Pairing…',
|
|
221
220
|
'fence.unpaired.tokenInvalid': 'Pairing link or token is invalid or expired',
|
|
222
|
-
'fence.unpaired.tokenUsed': 'This pairing link has already been used',
|
|
223
221
|
'fence.unpaired.tokenFailed': 'Pairing failed, please check network or issue a new link',
|
|
224
222
|
'fence.unpaired.footnote': 'Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.',
|
|
225
223
|
'posture.exposed': 'The /api channel is open to unpaired devices',
|
|
@@ -246,7 +244,7 @@ export const en = {
|
|
|
246
244
|
'relay.registering': 'Syncing the ephemeral tunnel to the stable hostname (<id>.dsh-market.com)…',
|
|
247
245
|
'relay.failed': 'Stable-hostname sync failed: {error}. Retrying automatically; the QR may use the ephemeral address meanwhile.',
|
|
248
246
|
'close.label': 'Close remote access panel',
|
|
249
|
-
// Plugin settings card (the
|
|
247
|
+
// Plugin settings card (the family list seat, or the official bundle-config seat).
|
|
250
248
|
'settings.title': 'Remote access settings',
|
|
251
249
|
'settings.description': 'Pairing security and device limits.',
|
|
252
250
|
'settings.enabled': 'Enable remote access',
|
|
@@ -64,6 +64,12 @@ const EFFORT_BTN_ID = 'dshRemoteEffortPick'
|
|
|
64
64
|
const COMPACT_CLASS = 'dsh-remote-compact-picker'
|
|
65
65
|
/** Body class while the header actions are seated in the tabs row. */
|
|
66
66
|
const HEADER_SEATED_CLASS = 'dsh-remote-header-seated'
|
|
67
|
+
/**
|
|
68
|
+
* Width the seated header actions paint over the tabs row, as a CSS variable
|
|
69
|
+
* on <html>. The row caps its own width with it (see the v80 rules) instead of
|
|
70
|
+
* reserving the space with padding.
|
|
71
|
+
*/
|
|
72
|
+
const HEADER_RESERVE_VAR = '--dsh-remote-header-actions-reserve'
|
|
67
73
|
/** The two composer picker entries the compact buttons drill into. */
|
|
68
74
|
type PickerKind = 'model' | 'effort'
|
|
69
75
|
/** Locale-dependent fast path for the official picker cells (zh/en). */
|
|
@@ -254,6 +260,20 @@ const ADAPT_CSS: readonly string[] = [
|
|
|
254
260
|
// [class$="_tab"] misses the active tab (its class ends in "_tabActive")
|
|
255
261
|
// — containment so BOTH tabs match.
|
|
256
262
|
'[class$="_header"] [class$="_tabs"] [class*="_tab"]{font-size:12px;white-space:nowrap}',
|
|
263
|
+
// v80: the tabs row must give the painted actions their width up on its own.
|
|
264
|
+
// The v67 padding reservation could not do it: the row is a nowrap flex
|
|
265
|
+
// container, and once the tab labels plus their gaps overflow the reduced
|
|
266
|
+
// content box the flex items spill over the padding box, so the trailing
|
|
267
|
+
// tabs render underneath the actions (reported on a phone: a long agent
|
|
268
|
+
// preset label covered the last tabs). Cap the row against the reserve and
|
|
269
|
+
// let the labels scroll instead. The value rides a CSS variable on <html>
|
|
270
|
+
// because the update lands before React re-creates the row node, and an
|
|
271
|
+
// inline reservation on that node is dropped until the next sync tick.
|
|
272
|
+
// The 2px bottom padding keeps the active-tab underline inside the clipping
|
|
273
|
+
// box; the matching negative margin keeps the header height official.
|
|
274
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]{max-width:max(0px,calc(100% - var(${HEADER_RESERVE_VAR},0px)));overflow-x:auto;overflow-y:hidden;scrollbar-width:none;-webkit-overflow-scrolling:touch;padding-bottom:2px;margin-bottom:-2px}`,
|
|
275
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"]::-webkit-scrollbar{display:none}`,
|
|
276
|
+
`body.${HEADER_SEATED_CLASS} [class$="_header"] [class$="_tabs"] > [class*="_tab"]{flex:0 0 auto}`,
|
|
257
277
|
// Mobile scope: hide the plugin surfaces that do not fit a phone — the
|
|
258
278
|
// right-hand details column and every desktop-oriented tool surface. The
|
|
259
279
|
// list keys on the L2 semantic roots (data-dsh-plugin, ownership stays
|
|
@@ -333,7 +353,7 @@ export function startMobileAdapt(): void {
|
|
|
333
353
|
let whaleSuppressClick = false
|
|
334
354
|
let whaleShown = false
|
|
335
355
|
let drag: { x: number; y: number; left: number; top: number; moved: boolean } | null = null
|
|
336
|
-
let swipeTouch: { x: number; y: number; id: number } | null = null
|
|
356
|
+
let swipeTouch: { x: number; y: number; id: number; el: Element | null } | null = null
|
|
337
357
|
let lastComposerTap = 0
|
|
338
358
|
// Plugin master switch: the module-scope layer installs before any config
|
|
339
359
|
// is readable, so the plugin apply() flips this through setEnabled() once
|
|
@@ -348,7 +368,7 @@ export function startMobileAdapt(): void {
|
|
|
348
368
|
* rail compaction) while the body class stays.
|
|
349
369
|
*/
|
|
350
370
|
function ensureAdaptStyle(): void {
|
|
351
|
-
if (
|
|
371
|
+
if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return
|
|
352
372
|
const tag = document.createElement('style')
|
|
353
373
|
tag.dataset.plugin = 'remote-web-ui'
|
|
354
374
|
tag.dataset.pluginCss = ADAPT_CSS_ID
|
|
@@ -401,6 +421,9 @@ export function startMobileAdapt(): void {
|
|
|
401
421
|
savedViewportContent = null
|
|
402
422
|
}
|
|
403
423
|
if (whaleEl !== null) whaleEl.style.display = 'none'
|
|
424
|
+
// The next apply() is a hidden-to-shown transition again: without this the
|
|
425
|
+
// whale would keep its pre-revert position state and skip the clamp.
|
|
426
|
+
whaleShown = false
|
|
404
427
|
setWhaleTimer(false)
|
|
405
428
|
if (whaleObserver !== null) {
|
|
406
429
|
whaleObserver.disconnect()
|
|
@@ -472,7 +495,7 @@ export function startMobileAdapt(): void {
|
|
|
472
495
|
|
|
473
496
|
function syncCompactPicker(): void {
|
|
474
497
|
if (!active) return
|
|
475
|
-
const tools =
|
|
498
|
+
const tools = nodeOf('[class$="_composerSeat"] [class$="_tools"]')
|
|
476
499
|
const trigger = tools?.parentElement?.querySelector('[class$="_triggerEffort"]')?.parentElement
|
|
477
500
|
if (tools === null || trigger === null) {
|
|
478
501
|
removeCompactPicker()
|
|
@@ -631,18 +654,25 @@ export function startMobileAdapt(): void {
|
|
|
631
654
|
document.body.appendChild(whale)
|
|
632
655
|
}
|
|
633
656
|
|
|
657
|
+
/**
|
|
658
|
+
* Write one row's recorded official draggable state back onto it.
|
|
659
|
+
* @param row - the tracked row element.
|
|
660
|
+
* @param original - the recorded attribute value, or null when it was absent.
|
|
661
|
+
*/
|
|
662
|
+
function restoreRowDragState(row: Element, original: string | null): void {
|
|
663
|
+
if (original === null) row.removeAttribute('draggable')
|
|
664
|
+
else row.setAttribute('draggable', original)
|
|
665
|
+
}
|
|
666
|
+
|
|
634
667
|
/**
|
|
635
668
|
* Restore the official draggable state this layer overrode while active.
|
|
636
669
|
* Rows are React-owned and may have been re-created meanwhile, so only the
|
|
637
|
-
* tracked elements are touched; a detached element is
|
|
670
|
+
* tracked elements are touched; a detached element is written back too
|
|
671
|
+
* (harmless, and it keeps the entry prunable at every tick).
|
|
638
672
|
*/
|
|
639
673
|
function restoreRowDrag(): void {
|
|
640
674
|
if (dragOverridden.size === 0) return
|
|
641
|
-
for (const [row, original] of dragOverridden)
|
|
642
|
-
if (!row.isConnected) continue
|
|
643
|
-
if (original === null) row.removeAttribute('draggable')
|
|
644
|
-
else row.setAttribute('draggable', original)
|
|
645
|
-
}
|
|
675
|
+
for (const [row, original] of dragOverridden) restoreRowDragState(row, original)
|
|
646
676
|
dragOverridden.clear()
|
|
647
677
|
}
|
|
648
678
|
|
|
@@ -679,6 +709,41 @@ export function startMobileAdapt(): void {
|
|
|
679
709
|
row.setAttribute('draggable', 'false')
|
|
680
710
|
}
|
|
681
711
|
}
|
|
712
|
+
// React replaces rows as the session list re-renders; a detached row can
|
|
713
|
+
// never be restored by a later revert, so keeping it here would retain the
|
|
714
|
+
// whole detached subtree (and its children) for the page lifetime. Its
|
|
715
|
+
// official state goes back on the node itself and the entry is dropped:
|
|
716
|
+
// a node React re-attaches is recorded again on the next tick.
|
|
717
|
+
if (dragOverridden.size === 0) return
|
|
718
|
+
for (const [row, original] of dragOverridden) {
|
|
719
|
+
if (row.isConnected) continue
|
|
720
|
+
restoreRowDragState(row, original)
|
|
721
|
+
dragOverridden.delete(row)
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Official nodes resolved through the document and cached while they stay
|
|
727
|
+
* connected. The sync tick runs every 600ms for the page lifetime, and these
|
|
728
|
+
* selectors (suffix class matches, :has()) cannot use Blink's fast paths, so
|
|
729
|
+
* every miss walks the whole mounted DOM — the official chat keeps the whole
|
|
730
|
+
* conversation mounted, i.e. tens of thousands of elements. React replaces a
|
|
731
|
+
* node on a major re-render, which the isConnected guard detects.
|
|
732
|
+
*/
|
|
733
|
+
const nodeCache = new Map<string, Element>()
|
|
734
|
+
function nodeOf(selector: string): Element | null {
|
|
735
|
+
const cached = nodeCache.get(selector)
|
|
736
|
+
if (cached !== undefined && cached.isConnected) return cached
|
|
737
|
+
const found = document.querySelector(selector)
|
|
738
|
+
if (found === null) nodeCache.delete(selector)
|
|
739
|
+
else nodeCache.set(selector, found)
|
|
740
|
+
return found
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** The official application frame, through the same cached lookup. */
|
|
744
|
+
function frameEl(): HTMLElement | null {
|
|
745
|
+
const frame = nodeOf(APP_FRAME_SELECTOR)
|
|
746
|
+
return frame instanceof HTMLElement ? frame : null
|
|
682
747
|
}
|
|
683
748
|
|
|
684
749
|
function syncWhale(): void {
|
|
@@ -690,8 +755,8 @@ export function startMobileAdapt(): void {
|
|
|
690
755
|
whaleEl.style.display = 'none'
|
|
691
756
|
return
|
|
692
757
|
}
|
|
693
|
-
const collapsed =
|
|
694
|
-
const overlayUp =
|
|
758
|
+
const collapsed = frameEl()?.hasAttribute('data-sidebar-collapsed') === true
|
|
759
|
+
const overlayUp = nodeOf('[class$="_overlay"]') !== null
|
|
695
760
|
const show = collapsed && !overlayUp
|
|
696
761
|
// Restore on every hidden-to-shown transition — including the very
|
|
697
762
|
// first show on a fresh page load, where the inline display is still
|
|
@@ -718,7 +783,7 @@ export function startMobileAdapt(): void {
|
|
|
718
783
|
// sessions, and hiding the actions without a seat target made them vanish.
|
|
719
784
|
function seatHeaderActions(): void {
|
|
720
785
|
if (!active) return
|
|
721
|
-
const header =
|
|
786
|
+
const header = nodeOf('[class$="_header"]')
|
|
722
787
|
const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
|
|
723
788
|
const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
|
|
724
789
|
// The actions node is NOT moved: it is a React-owned host node whose badge
|
|
@@ -740,7 +805,7 @@ export function startMobileAdapt(): void {
|
|
|
740
805
|
// and converged each time the header geometry is dirty.
|
|
741
806
|
function alignActionsText(): void {
|
|
742
807
|
if (!active) return
|
|
743
|
-
const header =
|
|
808
|
+
const header = nodeOf('[class$="_header"]')
|
|
744
809
|
if (header === null) return
|
|
745
810
|
const tabs = header.querySelector('[class$="_tabs"]')
|
|
746
811
|
const actions = header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]')
|
|
@@ -764,9 +829,14 @@ export function startMobileAdapt(): void {
|
|
|
764
829
|
let dx = translate !== null ? parseFloat(translate[1] ?? '0') : 0
|
|
765
830
|
let dy = translate !== null ? parseFloat(translate[2] ?? '0') : 0
|
|
766
831
|
const actionsRect = actions.getBoundingClientRect()
|
|
767
|
-
|
|
768
|
-
//
|
|
769
|
-
|
|
832
|
+
// Horizontal: the actions' right edge lands on the header's content edge,
|
|
833
|
+
// NOT on the tabs row's right edge. The row caps its own width against the
|
|
834
|
+
// reserve below (v80), so anchoring to the row would let the pair walk
|
|
835
|
+
// leftwards on every measurement.
|
|
836
|
+
const headerRect = header.getBoundingClientRect()
|
|
837
|
+
const headerStyle = getComputedStyle(header)
|
|
838
|
+
const contentRight = headerRect.right - (parseFloat(headerStyle.paddingRight) || 0) - (parseFloat(headerStyle.borderRightWidth) || 0)
|
|
839
|
+
const rightDiff = contentRight - actionsRect.right
|
|
770
840
|
if (Math.abs(rightDiff) >= 0.5) dx = Math.round((dx + rightDiff) * 10) / 10
|
|
771
841
|
// Vertical: the actions' text bottom lands on the tab text bottom.
|
|
772
842
|
const tabBottom = textBottom(tabBtn)
|
|
@@ -783,17 +853,26 @@ export function startMobileAdapt(): void {
|
|
|
783
853
|
}
|
|
784
854
|
const next = `translate(${dx}px, ${dy}px)`
|
|
785
855
|
if (actions.style.transform !== next) actions.style.transform = next
|
|
786
|
-
// Reserve the painted width so the tab labels never
|
|
856
|
+
// Reserve the painted width on the row itself, so the tab labels never
|
|
857
|
+
// slide under it. The value rides a CSS variable on <html>: React
|
|
858
|
+
// recreates the row node on re-render, which would drop an inline
|
|
859
|
+
// reservation until the next sync tick, and until then the labels render
|
|
860
|
+
// underneath the painted actions.
|
|
787
861
|
const reserve = `${Math.ceil(actionsRect.width) + 8}px`
|
|
788
|
-
|
|
862
|
+
const rootStyle = document.documentElement.style
|
|
863
|
+
if (rootStyle.getPropertyValue(HEADER_RESERVE_VAR) !== reserve) rootStyle.setProperty(HEADER_RESERVE_VAR, reserve)
|
|
789
864
|
}
|
|
790
865
|
|
|
791
866
|
function unseatHeaderActions(): void {
|
|
792
867
|
document.body.classList.remove(HEADER_SEATED_CLASS)
|
|
868
|
+
document.documentElement.style.removeProperty(HEADER_RESERVE_VAR)
|
|
793
869
|
const header = document.querySelector('[class$="_header"]')
|
|
794
870
|
const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
|
|
795
871
|
const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
|
|
796
872
|
if (actions instanceof HTMLElement) actions.style.transform = ''
|
|
873
|
+
// The v67 padding reservation is gone with the v80 cap; clearing it keeps a
|
|
874
|
+
// profile that hot-updated from an older build from keeping a stale inline
|
|
875
|
+
// width on the row.
|
|
797
876
|
if (tabs instanceof HTMLElement) tabs.style.paddingRight = ''
|
|
798
877
|
if (headerObserver !== null) {
|
|
799
878
|
headerObserver.disconnect()
|
|
@@ -838,6 +917,11 @@ export function startMobileAdapt(): void {
|
|
|
838
917
|
if (isMobilePortrait()) {
|
|
839
918
|
apply()
|
|
840
919
|
ensureWhaleObserver()
|
|
920
|
+
// A viewport shrink (split-screen, foldable, rotation) can leave the
|
|
921
|
+
// whale — the only portrait sidebar entry — parked beyond the new right
|
|
922
|
+
// edge while it stays visible; re-clamp it here. Skipped mid-drag so a
|
|
923
|
+
// resize cannot yank the button away from the finger.
|
|
924
|
+
if (active && drag === null) applyWhalePos()
|
|
841
925
|
} else {
|
|
842
926
|
revert()
|
|
843
927
|
}
|
|
@@ -936,13 +1020,17 @@ export function startMobileAdapt(): void {
|
|
|
936
1020
|
}
|
|
937
1021
|
const t = e.target
|
|
938
1022
|
const editable = t instanceof HTMLElement && t.isContentEditable
|
|
939
|
-
if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || editable || t.closest(`#${WHALE_ID}`) !== null ||
|
|
1023
|
+
if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT' || editable || t.closest(`#${WHALE_ID}`) !== null || t.closest('table, [class$="_table"], [class$="_tablePane"]') !== null)) {
|
|
940
1024
|
swipeTouch = null
|
|
941
1025
|
return
|
|
942
1026
|
}
|
|
943
1027
|
const ct = e.changedTouches[0]
|
|
944
1028
|
if (ct === undefined) return
|
|
945
|
-
|
|
1029
|
+
// The horizontally-scrollable veto is judged at release (see touchend):
|
|
1030
|
+
// evaluating it here forced a style recalc plus a synchronous layout on
|
|
1031
|
+
// every tap and scroll-start while the page was dirty, for a gesture that
|
|
1032
|
+
// mostly never becomes a swipe.
|
|
1033
|
+
swipeTouch = { x: ct.clientX, y: ct.clientY, id: ct.identifier, el: t instanceof Element ? t : null }
|
|
946
1034
|
}, { capture: true, passive: true })
|
|
947
1035
|
document.addEventListener('touchend', (e) => {
|
|
948
1036
|
if (swipeTouch === null) return
|
|
@@ -952,12 +1040,17 @@ export function startMobileAdapt(): void {
|
|
|
952
1040
|
if (ct === undefined || ct.identifier !== swipeTouch.id) return
|
|
953
1041
|
const dx = ct.clientX - swipeTouch.x
|
|
954
1042
|
const dy = ct.clientY - swipeTouch.y
|
|
1043
|
+
const start = swipeTouch
|
|
955
1044
|
swipeTouch = null
|
|
956
1045
|
if (!active) return
|
|
957
1046
|
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return
|
|
958
|
-
|
|
1047
|
+
// A gesture that started inside a horizontally scrollable container belongs
|
|
1048
|
+
// to that container, not to the sidebar. Judged now, only for a gesture
|
|
1049
|
+
// that already qualified as a swipe.
|
|
1050
|
+
if (start.el !== null && insideHScrollable(start.el)) return
|
|
1051
|
+
const frame = frameEl()
|
|
959
1052
|
if (frame === null) return
|
|
960
|
-
if (
|
|
1053
|
+
if (nodeOf('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"]') !== null) return
|
|
961
1054
|
const collapsed = frame.hasAttribute('data-sidebar-collapsed')
|
|
962
1055
|
if (dx < 0 && !collapsed) collapseSidebar()
|
|
963
1056
|
else if (dx > 0 && collapsed) toggleSidebarVerified()
|
|
@@ -1098,10 +1191,23 @@ export function startMobileAdapt(): void {
|
|
|
1098
1191
|
return isComposerField(el) ? el : null
|
|
1099
1192
|
}
|
|
1100
1193
|
const lanOrigFocus = HTMLElement.prototype.focus
|
|
1101
|
-
|
|
1194
|
+
const patchedFocus = function (this: HTMLElement, options?: FocusOptions): void {
|
|
1102
1195
|
if (active && isComposerField(this) && Date.now() - lastComposerTap >= 800) return
|
|
1103
1196
|
lanOrigFocus.call(this, options)
|
|
1104
1197
|
}
|
|
1198
|
+
/** (Re-)install the composer-focus guard (see setEnabled). */
|
|
1199
|
+
const installFocusPatch = (): void => {
|
|
1200
|
+
HTMLElement.prototype.focus = patchedFocus
|
|
1201
|
+
}
|
|
1202
|
+
/**
|
|
1203
|
+
* Remove the guard when the layer is disabled. A patch another plugin
|
|
1204
|
+
* installed after ours is left alone (identity check), so disabling this
|
|
1205
|
+
* layer never removes someone else's behavior.
|
|
1206
|
+
*/
|
|
1207
|
+
const restoreFocusPatch = (): void => {
|
|
1208
|
+
if (HTMLElement.prototype.focus === patchedFocus) HTMLElement.prototype.focus = lanOrigFocus
|
|
1209
|
+
}
|
|
1210
|
+
installFocusPatch()
|
|
1105
1211
|
document.addEventListener('pointerdown', (e) => {
|
|
1106
1212
|
if (!active) return
|
|
1107
1213
|
if (composerFieldOf(e.target) !== null) lastComposerTap = Date.now()
|
|
@@ -1117,8 +1223,13 @@ export function startMobileAdapt(): void {
|
|
|
1117
1223
|
translate: null,
|
|
1118
1224
|
setEnabled(on: boolean): void {
|
|
1119
1225
|
adaptEnabled = on
|
|
1120
|
-
if (on)
|
|
1121
|
-
|
|
1226
|
+
if (on) {
|
|
1227
|
+
installFocusPatch()
|
|
1228
|
+
evaluate()
|
|
1229
|
+
} else {
|
|
1230
|
+
revert()
|
|
1231
|
+
restoreFocusPatch()
|
|
1232
|
+
}
|
|
1122
1233
|
},
|
|
1123
1234
|
flushCloseDetails(): void {
|
|
1124
1235
|
// The first apply() ran before any wiring existed, so its
|