@linxin666/dsh-remote-web-ui 0.3.24 → 0.4.1
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 +263 -129
- package/lib/client.js.map +1 -1
- package/lib/index.js +565 -256
- 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 +47 -18
- 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 +103 -22
- 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 +23 -16
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +16 -14
- 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/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 -26
- 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 +61 -27
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +97 -20
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +24 -17
- 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/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 -24
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,
|
|
@@ -67,9 +67,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
67
67
|
*/
|
|
68
68
|
/**
|
|
69
69
|
* The child slot the Web UI plugin group declares; this card registers
|
|
70
|
-
* into the group
|
|
71
|
-
* Spelled here with the same shape so this
|
|
72
|
-
* 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.
|
|
73
73
|
*/
|
|
74
74
|
'web-ui.plugin.item': { kind: 'list'; scope: 'root'; owner: SettingsPluginItemOwnerProps }
|
|
75
75
|
}
|
|
@@ -90,11 +90,18 @@ export interface SettingsPluginItemOwnerProps {
|
|
|
90
90
|
declare module '@deepseek-ai/cordis' {
|
|
91
91
|
interface Context {
|
|
92
92
|
/**
|
|
93
|
-
* Optional
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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.
|
|
96
101
|
*/
|
|
97
|
-
webUiSettings?: {
|
|
102
|
+
webUiSettings?: {
|
|
103
|
+
bind<S>(spec: { namespace: string; decode?: (section: unknown) => S | undefined }): ConfigForm<S>
|
|
104
|
+
}
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
107
|
|
|
@@ -104,12 +111,25 @@ const NS = 'remote'
|
|
|
104
111
|
|
|
105
112
|
/** Settings namespace the remote-control card edits (the Host plugin registers it). */
|
|
106
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
|
+
}
|
|
107
127
|
|
|
108
128
|
/** Heartbeat cadence from a paired phone (presence + revocation liveness). */
|
|
109
129
|
const HEARTBEAT_INTERVAL_MS = 10_000
|
|
110
130
|
|
|
111
131
|
/** Services required by this plugin. */
|
|
112
|
-
export const inject = ['slots', 'locale', 'connection', '
|
|
132
|
+
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
113
133
|
|
|
114
134
|
/**
|
|
115
135
|
* Register the remote-control surface.
|
|
@@ -178,10 +198,17 @@ export function apply(ctx: ClientContext): void {
|
|
|
178
198
|
// before any dictionary existed; the wiring plus the layer's own sync tick
|
|
179
199
|
// re-render them in the active locale.
|
|
180
200
|
if (adapt !== undefined) adapt.translate = t
|
|
181
|
-
|
|
182
|
-
|
|
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))
|
|
183
210
|
const enabled = (): boolean => {
|
|
184
|
-
const snapshot =
|
|
211
|
+
const snapshot = settingsForm.getSnapshot()
|
|
185
212
|
return snapshot.status === 'ready'
|
|
186
213
|
? snapshot.value?.enabled ?? true
|
|
187
214
|
: snapshot.status === 'unavailable'
|
|
@@ -196,7 +223,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
196
223
|
const syncAdaptEnabled = (): void => {
|
|
197
224
|
;(window as unknown as { __dshRemoteAdapt?: RemoteAdaptGlobal }).__dshRemoteAdapt?.setEnabled?.(enabled())
|
|
198
225
|
}
|
|
199
|
-
|
|
226
|
+
settingsForm.subscribe(syncAdaptEnabled)
|
|
200
227
|
syncAdaptEnabled()
|
|
201
228
|
// The replay closes a details panel restored before the wiring; the
|
|
202
229
|
// layout face throws while the root entry has not mounted yet (a real
|
|
@@ -224,7 +251,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
224
251
|
disposeEntry = undefined
|
|
225
252
|
}
|
|
226
253
|
}
|
|
227
|
-
const unsubscribe =
|
|
254
|
+
const unsubscribe = settingsForm.subscribe(syncEntry)
|
|
228
255
|
syncEntry()
|
|
229
256
|
return () => {
|
|
230
257
|
unsubscribe()
|
|
@@ -234,12 +261,12 @@ export function apply(ctx: ClientContext): void {
|
|
|
234
261
|
|
|
235
262
|
// Plugin configuration card: one staged form over the `remote-web-ui`
|
|
236
263
|
// settings namespace, contributed to whichever plugin-card seat the running
|
|
237
|
-
// host declares (the family group's list seat, or the official
|
|
238
|
-
// the plugin
|
|
239
|
-
//
|
|
240
|
-
const remoteSettings = new RemoteSettingsCardController(
|
|
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)
|
|
241
268
|
installPluginCard(ctx, {
|
|
242
|
-
|
|
269
|
+
bundle: '@linxin666/dsh-remote-web-ui',
|
|
243
270
|
id: 'remote-web-ui',
|
|
244
271
|
order: 90,
|
|
245
272
|
locale: NS,
|
|
@@ -259,7 +286,14 @@ export function apply(ctx: ClientContext): void {
|
|
|
259
286
|
const loopback = connection?.isLoopback ?? true
|
|
260
287
|
runPairBootFlow(ctx, window.location.search)
|
|
261
288
|
if (loopback) return () => {}
|
|
262
|
-
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)
|
|
263
297
|
return () => { window.clearInterval(timer) }
|
|
264
298
|
}, 'remote-web-ui: pair flow + heartbeats')
|
|
265
299
|
} else if (!enabled() && disposeRuntime !== undefined) {
|
|
@@ -267,7 +301,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
267
301
|
disposeRuntime = undefined
|
|
268
302
|
}
|
|
269
303
|
}
|
|
270
|
-
|
|
304
|
+
settingsForm.subscribe(syncRuntime)
|
|
271
305
|
syncRuntime()
|
|
272
306
|
|
|
273
307
|
// Remote desktop channel: on a non-loopback origin (LAN address or public
|
|
@@ -292,7 +326,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
292
326
|
fenceNotice = undefined
|
|
293
327
|
}
|
|
294
328
|
const handleUnpaired = (): void => {
|
|
295
|
-
if (
|
|
329
|
+
if (settingsForm.getSnapshot().status !== 'ready' && hostPairingPolicy === undefined) {
|
|
296
330
|
unpairedWhilePolicyPending = true
|
|
297
331
|
return
|
|
298
332
|
}
|
|
@@ -300,7 +334,7 @@ export function apply(ctx: ClientContext): void {
|
|
|
300
334
|
}
|
|
301
335
|
const channelActive = (): boolean => remoteChannelRequired(
|
|
302
336
|
window.location.hostname,
|
|
303
|
-
|
|
337
|
+
settingsForm.getSnapshot(),
|
|
304
338
|
hostPairingPolicy,
|
|
305
339
|
)
|
|
306
340
|
// The parse-time boot patch (issue #987), when the served index carried
|
|
@@ -351,9 +385,9 @@ export function apply(ctx: ClientContext): void {
|
|
|
351
385
|
bootSeat()?.restore()
|
|
352
386
|
}
|
|
353
387
|
}
|
|
354
|
-
|
|
388
|
+
settingsForm.subscribe(syncChannel)
|
|
355
389
|
syncChannel()
|
|
356
|
-
if (!isLoopbackHostname(window.location.hostname) &&
|
|
390
|
+
if (!isLoopbackHostname(window.location.hostname) && settingsForm.getSnapshot().status !== 'ready') {
|
|
357
391
|
void readPairGatePolicy().then((policy) => {
|
|
358
392
|
hostPairingPolicy = policy.requirePairingForLan
|
|
359
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',
|
|
@@ -353,7 +353,7 @@ export function startMobileAdapt(): void {
|
|
|
353
353
|
let whaleSuppressClick = false
|
|
354
354
|
let whaleShown = false
|
|
355
355
|
let drag: { x: number; y: number; left: number; top: number; moved: boolean } | null = null
|
|
356
|
-
let swipeTouch: { x: number; y: number; id: number } | null = null
|
|
356
|
+
let swipeTouch: { x: number; y: number; id: number; el: Element | null } | null = null
|
|
357
357
|
let lastComposerTap = 0
|
|
358
358
|
// Plugin master switch: the module-scope layer installs before any config
|
|
359
359
|
// is readable, so the plugin apply() flips this through setEnabled() once
|
|
@@ -368,7 +368,7 @@ export function startMobileAdapt(): void {
|
|
|
368
368
|
* rail compaction) while the body class stays.
|
|
369
369
|
*/
|
|
370
370
|
function ensureAdaptStyle(): void {
|
|
371
|
-
if (
|
|
371
|
+
if (nodeOf(`style[data-plugin-css="${ADAPT_CSS_ID}"]`) !== null) return
|
|
372
372
|
const tag = document.createElement('style')
|
|
373
373
|
tag.dataset.plugin = 'remote-web-ui'
|
|
374
374
|
tag.dataset.pluginCss = ADAPT_CSS_ID
|
|
@@ -421,6 +421,9 @@ export function startMobileAdapt(): void {
|
|
|
421
421
|
savedViewportContent = null
|
|
422
422
|
}
|
|
423
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
|
|
424
427
|
setWhaleTimer(false)
|
|
425
428
|
if (whaleObserver !== null) {
|
|
426
429
|
whaleObserver.disconnect()
|
|
@@ -492,7 +495,7 @@ export function startMobileAdapt(): void {
|
|
|
492
495
|
|
|
493
496
|
function syncCompactPicker(): void {
|
|
494
497
|
if (!active) return
|
|
495
|
-
const tools =
|
|
498
|
+
const tools = nodeOf('[class$="_composerSeat"] [class$="_tools"]')
|
|
496
499
|
const trigger = tools?.parentElement?.querySelector('[class$="_triggerEffort"]')?.parentElement
|
|
497
500
|
if (tools === null || trigger === null) {
|
|
498
501
|
removeCompactPicker()
|
|
@@ -651,18 +654,25 @@ export function startMobileAdapt(): void {
|
|
|
651
654
|
document.body.appendChild(whale)
|
|
652
655
|
}
|
|
653
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
|
+
|
|
654
667
|
/**
|
|
655
668
|
* Restore the official draggable state this layer overrode while active.
|
|
656
669
|
* Rows are React-owned and may have been re-created meanwhile, so only the
|
|
657
|
-
* 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).
|
|
658
672
|
*/
|
|
659
673
|
function restoreRowDrag(): void {
|
|
660
674
|
if (dragOverridden.size === 0) return
|
|
661
|
-
for (const [row, original] of dragOverridden)
|
|
662
|
-
if (!row.isConnected) continue
|
|
663
|
-
if (original === null) row.removeAttribute('draggable')
|
|
664
|
-
else row.setAttribute('draggable', original)
|
|
665
|
-
}
|
|
675
|
+
for (const [row, original] of dragOverridden) restoreRowDragState(row, original)
|
|
666
676
|
dragOverridden.clear()
|
|
667
677
|
}
|
|
668
678
|
|
|
@@ -699,6 +709,41 @@ export function startMobileAdapt(): void {
|
|
|
699
709
|
row.setAttribute('draggable', 'false')
|
|
700
710
|
}
|
|
701
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
|
|
702
747
|
}
|
|
703
748
|
|
|
704
749
|
function syncWhale(): void {
|
|
@@ -710,8 +755,8 @@ export function startMobileAdapt(): void {
|
|
|
710
755
|
whaleEl.style.display = 'none'
|
|
711
756
|
return
|
|
712
757
|
}
|
|
713
|
-
const collapsed =
|
|
714
|
-
const overlayUp =
|
|
758
|
+
const collapsed = frameEl()?.hasAttribute('data-sidebar-collapsed') === true
|
|
759
|
+
const overlayUp = nodeOf('[class$="_overlay"]') !== null
|
|
715
760
|
const show = collapsed && !overlayUp
|
|
716
761
|
// Restore on every hidden-to-shown transition — including the very
|
|
717
762
|
// first show on a fresh page load, where the inline display is still
|
|
@@ -738,7 +783,7 @@ export function startMobileAdapt(): void {
|
|
|
738
783
|
// sessions, and hiding the actions without a seat target made them vanish.
|
|
739
784
|
function seatHeaderActions(): void {
|
|
740
785
|
if (!active) return
|
|
741
|
-
const header =
|
|
786
|
+
const header = nodeOf('[class$="_header"]')
|
|
742
787
|
const tabs = header !== null ? header.querySelector('[class$="_tabs"]') : null
|
|
743
788
|
const actions = header !== null ? header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]') : null
|
|
744
789
|
// The actions node is NOT moved: it is a React-owned host node whose badge
|
|
@@ -760,7 +805,7 @@ export function startMobileAdapt(): void {
|
|
|
760
805
|
// and converged each time the header geometry is dirty.
|
|
761
806
|
function alignActionsText(): void {
|
|
762
807
|
if (!active) return
|
|
763
|
-
const header =
|
|
808
|
+
const header = nodeOf('[class$="_header"]')
|
|
764
809
|
if (header === null) return
|
|
765
810
|
const tabs = header.querySelector('[class$="_tabs"]')
|
|
766
811
|
const actions = header.querySelector('[class$="_titleCluster"] [class$="_headerActions"]')
|
|
@@ -872,6 +917,11 @@ export function startMobileAdapt(): void {
|
|
|
872
917
|
if (isMobilePortrait()) {
|
|
873
918
|
apply()
|
|
874
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()
|
|
875
925
|
} else {
|
|
876
926
|
revert()
|
|
877
927
|
}
|
|
@@ -970,13 +1020,17 @@ export function startMobileAdapt(): void {
|
|
|
970
1020
|
}
|
|
971
1021
|
const t = e.target
|
|
972
1022
|
const editable = t instanceof HTMLElement && t.isContentEditable
|
|
973
|
-
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)) {
|
|
974
1024
|
swipeTouch = null
|
|
975
1025
|
return
|
|
976
1026
|
}
|
|
977
1027
|
const ct = e.changedTouches[0]
|
|
978
1028
|
if (ct === undefined) return
|
|
979
|
-
|
|
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 }
|
|
980
1034
|
}, { capture: true, passive: true })
|
|
981
1035
|
document.addEventListener('touchend', (e) => {
|
|
982
1036
|
if (swipeTouch === null) return
|
|
@@ -986,12 +1040,17 @@ export function startMobileAdapt(): void {
|
|
|
986
1040
|
if (ct === undefined || ct.identifier !== swipeTouch.id) return
|
|
987
1041
|
const dx = ct.clientX - swipeTouch.x
|
|
988
1042
|
const dy = ct.clientY - swipeTouch.y
|
|
1043
|
+
const start = swipeTouch
|
|
989
1044
|
swipeTouch = null
|
|
990
1045
|
if (!active) return
|
|
991
1046
|
if (Math.abs(dx) < 60 || Math.abs(dx) < Math.abs(dy) * 1.5) return
|
|
992
|
-
|
|
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()
|
|
993
1052
|
if (frame === null) return
|
|
994
|
-
if (
|
|
1053
|
+
if (nodeOf('[class$="_overlay"], [class$="_dialog"], [class$="_menu"], [class*="_portal"]') !== null) return
|
|
995
1054
|
const collapsed = frame.hasAttribute('data-sidebar-collapsed')
|
|
996
1055
|
if (dx < 0 && !collapsed) collapseSidebar()
|
|
997
1056
|
else if (dx > 0 && collapsed) toggleSidebarVerified()
|
|
@@ -1132,10 +1191,23 @@ export function startMobileAdapt(): void {
|
|
|
1132
1191
|
return isComposerField(el) ? el : null
|
|
1133
1192
|
}
|
|
1134
1193
|
const lanOrigFocus = HTMLElement.prototype.focus
|
|
1135
|
-
|
|
1194
|
+
const patchedFocus = function (this: HTMLElement, options?: FocusOptions): void {
|
|
1136
1195
|
if (active && isComposerField(this) && Date.now() - lastComposerTap >= 800) return
|
|
1137
1196
|
lanOrigFocus.call(this, options)
|
|
1138
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()
|
|
1139
1211
|
document.addEventListener('pointerdown', (e) => {
|
|
1140
1212
|
if (!active) return
|
|
1141
1213
|
if (composerFieldOf(e.target) !== null) lastComposerTap = Date.now()
|
|
@@ -1151,8 +1223,13 @@ export function startMobileAdapt(): void {
|
|
|
1151
1223
|
translate: null,
|
|
1152
1224
|
setEnabled(on: boolean): void {
|
|
1153
1225
|
adaptEnabled = on
|
|
1154
|
-
if (on)
|
|
1155
|
-
|
|
1226
|
+
if (on) {
|
|
1227
|
+
installFocusPatch()
|
|
1228
|
+
evaluate()
|
|
1229
|
+
} else {
|
|
1230
|
+
revert()
|
|
1231
|
+
restoreFocusPatch()
|
|
1232
|
+
}
|
|
1156
1233
|
},
|
|
1157
1234
|
flushCloseDetails(): void {
|
|
1158
1235
|
// The first apply() ran before any wiring existed, so its
|
package/src/client/pair-api.ts
CHANGED
|
@@ -53,7 +53,7 @@ export async function readPairGatePolicy(): Promise<PairGatePolicy> {
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/** accept() refusal codes. */
|
|
56
|
-
export type AcceptFailure = { ok: false; code: 'invalid' | '
|
|
56
|
+
export type AcceptFailure = { ok: false; code: 'invalid' | 'forbidden' }
|
|
57
57
|
|
|
58
58
|
/** One auto-tunnel status frame (absent while the feature is off). */
|
|
59
59
|
export interface TunnelStatusFrame {
|
|
@@ -145,8 +145,9 @@ export async function acceptPair(token: string): Promise<{ ok: true } | AcceptFa
|
|
|
145
145
|
body: JSON.stringify({ token }),
|
|
146
146
|
})
|
|
147
147
|
if (response.ok) return { ok: true }
|
|
148
|
-
|
|
149
|
-
|
|
148
|
+
// 409 is the legacy "already used" answer a pre-bearer-token host could send;
|
|
149
|
+
// it means the same thing to the caller as a 404.
|
|
150
|
+
if (response.status === 404 || response.status === 409) return { ok: false, code: 'invalid' }
|
|
150
151
|
return { ok: false, code: 'forbidden' }
|
|
151
152
|
}
|
|
152
153
|
|
|
@@ -170,9 +171,25 @@ export async function revokePair(deviceId: string): Promise<void> {
|
|
|
170
171
|
if (!response.ok) throw new Error(`remote-web-ui: revoke failed with ${String(response.status)}`)
|
|
171
172
|
}
|
|
172
173
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Presence heartbeat from a paired phone.
|
|
176
|
+
* @returns the response status, so the caller can stop polling once the server
|
|
177
|
+
* proves this page is not paired (see {@link shouldStopHeartbeat}).
|
|
178
|
+
*/
|
|
179
|
+
export async function sendHeartbeat(): Promise<number> {
|
|
180
|
+
const response = await fetch('/api/pair/heartbeat', { method: 'POST' })
|
|
181
|
+
return response.status
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether a heartbeat answer means "this page can never be accepted again" and
|
|
186
|
+
* the 10 s wake source should stop: 401 (unpaired, or the device was revoked)
|
|
187
|
+
* and 403 (the fence refused it) are permanent for this page, while a network
|
|
188
|
+
* error or a 5xx is transient and keeps the cadence.
|
|
189
|
+
* @param status - the heartbeat response status.
|
|
190
|
+
*/
|
|
191
|
+
export function shouldStopHeartbeat(status: number): boolean {
|
|
192
|
+
return status === 401 || status === 403
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
/** Whether the current page URL carries a pairing token. */
|
|
@@ -210,13 +227,36 @@ export interface LanBindFrame {
|
|
|
210
227
|
pendingRestart?: boolean
|
|
211
228
|
}
|
|
212
229
|
|
|
230
|
+
/** A failed LAN-bind read, carrying the HTTP status when the server answered. */
|
|
231
|
+
export class LanBindStatusError extends Error {
|
|
232
|
+
/**
|
|
233
|
+
* @param status - the response status, or undefined when the request failed
|
|
234
|
+
* before a response (network error).
|
|
235
|
+
* @param message - the diagnostic message.
|
|
236
|
+
*/
|
|
237
|
+
constructor(public readonly status: number | undefined, message: string) {
|
|
238
|
+
super(message)
|
|
239
|
+
this.name = 'LanBindStatusError'
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
213
243
|
/** Read the LAN-bind facts (loopback-only endpoint). */
|
|
214
244
|
export async function readLanBindStatus(): Promise<LanBindFrame> {
|
|
215
245
|
const response = await fetch('/api/pair/lan-bind')
|
|
216
|
-
if (!response.ok) throw new
|
|
246
|
+
if (!response.ok) throw new LanBindStatusError(response.status, `remote-web-ui: lan-bind status failed with ${String(response.status)}`)
|
|
217
247
|
return await response.json() as LanBindFrame
|
|
218
248
|
}
|
|
219
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Whether a LAN-bind status failure means this origin can never read the
|
|
252
|
+
* endpoint (401/403 from the loopback-only fence), so a poll should stop
|
|
253
|
+
* instead of retrying a known refusal; a transient error keeps the cadence.
|
|
254
|
+
* @param status - the response status, or undefined for a network failure.
|
|
255
|
+
*/
|
|
256
|
+
export function shouldStopLanBindPoll(status: number | undefined): boolean {
|
|
257
|
+
return status === 401 || status === 403
|
|
258
|
+
}
|
|
259
|
+
|
|
220
260
|
/** Human-readable expiry clock, e.g. "10:35". */
|
|
221
261
|
export function formatClock(epochMs: number): string {
|
|
222
262
|
const date = new Date(epochMs)
|
|
@@ -7,22 +7,24 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - `web-ui.plugin.item` — the list seat declared by the dsh-web-settings
|
|
9
9
|
* group section (this family's own first-level "Web UI plugins" section);
|
|
10
|
-
* - `
|
|
11
|
-
*
|
|
10
|
+
* - `plugins.bundle.config` — the official keyed seat of the harness's plugin
|
|
11
|
+
* manager page, keyed by the bundle's package name and rendered on that
|
|
12
|
+
* bundle's page. alpha.2 removed the `settings.plugin.item` keyed seat of the
|
|
13
|
+
* `ui-settings-plugins` tab that this helper used before, so a card keyed by
|
|
14
|
+
* its settings namespace has no seat to land in any more.
|
|
12
15
|
*
|
|
13
|
-
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* heading and zero cards.
|
|
16
|
+
* SEAT SELECTION IS NOT A DECLARATION PROBE. The official plugin surface
|
|
17
|
+
* belongs to the harness bundle and declares its seats before any external
|
|
18
|
+
* plugin's `apply()` runs, so "is the official seat declared?" answers yes even
|
|
19
|
+
* in the deployment whose whole point is the family group. Choosing on that
|
|
20
|
+
* probe sends every family card to the official page and leaves the group's own
|
|
21
|
+
* section permanently empty — the family of reports where the section renders
|
|
22
|
+
* its heading and zero cards.
|
|
21
23
|
*
|
|
22
24
|
* The signal that actually distinguishes the two deployments is whether
|
|
23
25
|
* dsh-web-settings is loaded: it is the package that owns the group section and
|
|
24
26
|
* it publishes the `webUiSettings` service during `apply()`, which every
|
|
25
|
-
* family plugin already reads for its settings
|
|
27
|
+
* family plugin already reads for its settings form. Group loaded -> the family
|
|
26
28
|
* seat; group absent -> the official seat.
|
|
27
29
|
*
|
|
28
30
|
* The decision is re-evaluated on every `slots/changed` because the group may
|
|
@@ -40,8 +42,8 @@
|
|
|
40
42
|
/** The family list seat key. */
|
|
41
43
|
export const FAMILY_PLUGIN_CARD_SEAT = 'web-ui.plugin.item'
|
|
42
44
|
|
|
43
|
-
/** The official keyed plugin-card seat key. */
|
|
44
|
-
export const OFFICIAL_PLUGIN_CARD_SEAT = '
|
|
45
|
+
/** The official keyed plugin-card seat key (the alpha.2 bundle-configuration seat). */
|
|
46
|
+
export const OFFICIAL_PLUGIN_CARD_SEAT = 'plugins.bundle.config'
|
|
45
47
|
|
|
46
48
|
/** The service dsh-web-settings publishes while it is loaded. */
|
|
47
49
|
export const FAMILY_GROUP_SERVICE = 'webUiSettings'
|
|
@@ -61,7 +63,7 @@ export interface PluginCardContext {
|
|
|
61
63
|
on?(event: string, listener: (...args: never[]) => void): unknown
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
/** Owner share of a
|
|
66
|
+
/** Owner share of a family list-seat card (that seat supplies nothing). */
|
|
65
67
|
export interface SettingsPluginItemOwnerProps {
|
|
66
68
|
/** Marker field: card owner props are intentionally empty. */
|
|
67
69
|
children?: never
|
|
@@ -69,8 +71,13 @@ export interface SettingsPluginItemOwnerProps {
|
|
|
69
71
|
|
|
70
72
|
/** One family plugin's card contribution. */
|
|
71
73
|
export interface PluginCardSeat {
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Npm package name of the bundle this card configures; the official keyed
|
|
76
|
+
* seat dispatches on it. Every family package ships its own bundle patch, so
|
|
77
|
+
* a standalone install lists that package as the bundle whose page renders
|
|
78
|
+
* this card.
|
|
79
|
+
*/
|
|
80
|
+
bundle: string
|
|
74
81
|
/** Family list-seat entry id. */
|
|
75
82
|
id: string
|
|
76
83
|
/** Family list-seat sort order. */
|
|
@@ -159,7 +166,7 @@ export function installPluginCard(ctx: PluginCardContext, seat: PluginCardSeat):
|
|
|
159
166
|
}
|
|
160
167
|
: {
|
|
161
168
|
name: OFFICIAL_PLUGIN_CARD_SEAT,
|
|
162
|
-
key: seat.
|
|
169
|
+
key: seat.bundle,
|
|
163
170
|
locale: seat.locale,
|
|
164
171
|
...(seat.inject === undefined ? {} : { inject }),
|
|
165
172
|
}) as never, component) as () => void
|