@linxin666/dsh-remote-web-ui 0.3.4 → 0.3.6

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.
@@ -1,22 +1,25 @@
1
- /**
2
- * Unpaired-desktop notice: a full-page blocking surface rendered when the remote channel
3
- * (see remote-channel.ts) refuses a call because this desktop browser has no
4
- * live paired-device cookie. Retires automatically once a gated call
5
- * succeeds (the channel reports pairing) or when the channel itself is
6
- * torn down (requirePairingForLan off / plugin disabled), so it never
7
- * outlives the unpaired state it describes.
8
- */
9
1
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
10
2
  /** Notice props: localized copy. */
11
3
  export interface FenceNoticeProps {
12
4
  t: TranslateNS<'remote'>;
13
5
  /** Retry after the user has opened a freshly issued computer pairing link. */
14
6
  onRetry: () => void;
7
+ /** Optional custom pairing acceptor for testing / dependency injection. */
8
+ onAccept?: (token: string) => Promise<{
9
+ ok: true;
10
+ } | {
11
+ ok: false;
12
+ code: 'invalid' | 'used' | 'forbidden';
13
+ }>;
15
14
  }
15
+ /**
16
+ * Extract a pairing token from either a raw token string or a copied pairing link URL.
17
+ */
18
+ export declare function extractPairToken(input: string): string | undefined;
16
19
  /**
17
20
  * Render the unpaired blocking page.
18
21
  * @param props - localized copy.
19
22
  * @returns the notice element.
20
23
  */
21
- export declare function FenceNotice({ t, onRetry }: FenceNoticeProps): import("react").JSX.Element;
24
+ export declare function FenceNotice({ t, onRetry, onAccept }: FenceNoticeProps): import("react").JSX.Element;
22
25
  //# sourceMappingURL=FenceNotice.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FenceNotice.d.ts","sourceRoot":"","sources":["../../../src/client/FenceNotice.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAGnE,oCAAoC;AACpC,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,EAAE,gBAAgB,+BAoB3D"}
1
+ {"version":3,"file":"FenceNotice.d.ts","sourceRoot":"","sources":["../../../src/client/FenceNotice.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAInE,oCAAoC;AACpC,MAAM,WAAW,gBAAgB;IAC/B,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;IACxB,8EAA8E;IAC9E,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;QAAE,EAAE,EAAE,IAAI,CAAA;KAAE,GAAG;QAAE,EAAE,EAAE,KAAK,CAAC;QAAC,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,WAAW,CAAA;KAAE,CAAC,CAAA;CAC5G;AAED;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAWlE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,QAAqB,EAAE,EAAE,gBAAgB,+BAgFlF"}
@@ -1,10 +1,75 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Unpaired-desktop notice: a full-page blocking surface rendered when the remote channel
4
+ * (see remote-channel.ts) refuses a call because this desktop browser has no
5
+ * live paired-device cookie. Retires automatically once a gated call
6
+ * succeeds (the channel reports pairing) or when the channel itself is
7
+ * torn down (requirePairingForLan off / plugin disabled), so it never
8
+ * outlives the unpaired state it describes.
9
+ */
10
+ import { useState } from 'react';
11
+ import { acceptPair } from "./pair-api.js";
2
12
  import css from './remote.module.css';
13
+ /**
14
+ * Extract a pairing token from either a raw token string or a copied pairing link URL.
15
+ */
16
+ export function extractPairToken(input) {
17
+ const trimmed = input.trim();
18
+ if (trimmed === '')
19
+ return undefined;
20
+ try {
21
+ const url = new URL(trimmed);
22
+ const token = url.searchParams.get('pair');
23
+ if (token !== null && token !== '')
24
+ return token;
25
+ }
26
+ catch {
27
+ // Plain token string
28
+ }
29
+ return trimmed;
30
+ }
3
31
  /**
4
32
  * Render the unpaired blocking page.
5
33
  * @param props - localized copy.
6
34
  * @returns the notice element.
7
35
  */
8
- export function FenceNotice({ t, onRetry }) {
9
- return (_jsx("div", { className: css.fencePage, role: "dialog", "aria-modal": "true", "aria-labelledby": "remote-fence-title", children: _jsxs("main", { className: css.fenceCard, "data-dsh-plugin": "remote-web-ui", children: [_jsx("div", { className: css.fenceMark, "aria-hidden": "true", children: "\u00D7" }), _jsx("p", { className: css.fenceEyebrow, children: t('fence.unpaired.eyebrow') }), _jsx("h1", { id: "remote-fence-title", className: css.fenceTitle, children: t('fence.unpaired.title') }), _jsx("p", { className: css.fenceDetail, children: t('fence.unpaired.hint') }), _jsxs("ol", { className: css.fenceSteps, children: [_jsx("li", { children: t('fence.unpaired.stepDesktop') }), _jsx("li", { children: t('fence.unpaired.stepLink') }), _jsx("li", { children: t('fence.unpaired.stepOpen') })] }), _jsx("button", { className: css.fenceRetry, type: "button", onClick: onRetry, children: t('fence.unpaired.retry') }), _jsx("p", { className: css.fenceFootnote, children: t('fence.unpaired.footnote') })] }) }));
36
+ export function FenceNotice({ t, onRetry, onAccept = acceptPair }) {
37
+ const [tokenInput, setTokenInput] = useState('');
38
+ const [submitting, setSubmitting] = useState(false);
39
+ const [errorMsg, setErrorMsg] = useState(undefined);
40
+ const handleSubmit = async (event) => {
41
+ event.preventDefault();
42
+ const token = extractPairToken(tokenInput);
43
+ if (!token)
44
+ return;
45
+ setSubmitting(true);
46
+ setErrorMsg(undefined);
47
+ try {
48
+ const result = await onAccept(token);
49
+ if (result.ok) {
50
+ onRetry();
51
+ return;
52
+ }
53
+ if (result.code === 'invalid') {
54
+ setErrorMsg(t('fence.unpaired.tokenInvalid'));
55
+ }
56
+ else if (result.code === 'used') {
57
+ setErrorMsg(t('fence.unpaired.tokenUsed'));
58
+ }
59
+ else {
60
+ setErrorMsg(t('fence.unpaired.tokenFailed'));
61
+ }
62
+ }
63
+ catch {
64
+ setErrorMsg(t('fence.unpaired.tokenFailed'));
65
+ }
66
+ finally {
67
+ setSubmitting(false);
68
+ }
69
+ };
70
+ return (_jsx("div", { className: css.fencePage, role: "dialog", "aria-modal": "true", "aria-labelledby": "remote-fence-title", children: _jsxs("main", { className: css.fenceCard, "data-dsh-plugin": "remote-web-ui", children: [_jsx("div", { className: css.fenceMark, "aria-hidden": "true", children: "\u00D7" }), _jsx("p", { className: css.fenceEyebrow, children: t('fence.unpaired.eyebrow') }), _jsx("h1", { id: "remote-fence-title", className: css.fenceTitle, children: t('fence.unpaired.title') }), _jsx("p", { className: css.fenceDetail, children: t('fence.unpaired.hint') }), _jsxs("ol", { className: css.fenceSteps, children: [_jsx("li", { children: t('fence.unpaired.stepDesktop') }), _jsx("li", { children: t('fence.unpaired.stepLink') }), _jsx("li", { children: t('fence.unpaired.stepOpen') })] }), _jsxs("form", { className: css.fenceForm, onSubmit: handleSubmit, children: [_jsxs("div", { className: css.fenceInputRow, children: [_jsx("input", { type: "text", className: css.fenceInput, placeholder: t('fence.unpaired.tokenPlaceholder'), value: tokenInput, onChange: event => {
71
+ setTokenInput(event.target.value);
72
+ if (errorMsg !== undefined)
73
+ setErrorMsg(undefined);
74
+ }, disabled: submitting, "aria-label": t('fence.unpaired.tokenPlaceholder') }), _jsx("button", { className: css.fencePairButton, type: "submit", disabled: submitting || tokenInput.trim() === '', children: submitting ? t('fence.unpaired.pairing') : t('fence.unpaired.pairAction') })] }), errorMsg !== undefined && (_jsx("p", { className: css.fenceError, role: "alert", children: errorMsg }))] }), _jsx("button", { className: css.fenceRetry, type: "button", onClick: onRetry, children: t('fence.unpaired.retry') }), _jsx("p", { className: css.fenceFootnote, children: t('fence.unpaired.footnote') })] }) }));
10
75
  }
@@ -36,6 +36,12 @@ export declare const zh: {
36
36
  'fence.unpaired.stepLink': string;
37
37
  'fence.unpaired.stepOpen': string;
38
38
  'fence.unpaired.retry': string;
39
+ 'fence.unpaired.tokenPlaceholder': string;
40
+ 'fence.unpaired.pairAction': string;
41
+ 'fence.unpaired.pairing': string;
42
+ 'fence.unpaired.tokenInvalid': string;
43
+ 'fence.unpaired.tokenUsed': string;
44
+ 'fence.unpaired.tokenFailed': string;
39
45
  'fence.unpaired.footnote': string;
40
46
  'posture.exposed': string;
41
47
  'posture.exposedHint': string;
@@ -178,6 +184,12 @@ export declare const en: {
178
184
  'fence.unpaired.stepLink': string;
179
185
  'fence.unpaired.stepOpen': string;
180
186
  'fence.unpaired.retry': string;
187
+ 'fence.unpaired.tokenPlaceholder': string;
188
+ 'fence.unpaired.pairAction': string;
189
+ 'fence.unpaired.pairing': string;
190
+ 'fence.unpaired.tokenInvalid': string;
191
+ 'fence.unpaired.tokenUsed': string;
192
+ 'fence.unpaired.tokenFailed': string;
181
193
  'fence.unpaired.footnote': string;
182
194
  'posture.exposed': string;
183
195
  'posture.exposedHint': string;
@@ -1 +1 @@
1
- {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,uEAAuE;AAEvE,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0ImB,CAAA;AAElC,sCAAsC;AACtC,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,EAAE,CAAA;AAEvC,wDAAwD;AACxD,MAAM,MAAM,eAAe,GAAG,SAAS,CAAA;AAEvC,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0IsB,CAAA"}
1
+ {"version":3,"file":"locales.d.ts","sourceRoot":"","sources":["../../../src/client/locales.ts"],"names":[],"mappings":"AAAA,uEAAuE;AAEvE,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgJmB,CAAA;AAElC,sCAAsC;AACtC,MAAM,MAAM,SAAS,GAAG,MAAM,OAAO,EAAE,CAAA;AAEvC,wDAAwD;AACxD,MAAM,MAAM,eAAe,GAAG,SAAS,CAAA;AAEvC,mEAAmE;AACnE,eAAO,MAAM,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgJsB,CAAA"}
@@ -36,6 +36,12 @@ export const zh = {
36
36
  'fence.unpaired.stepLink': '在“设备配对”中复制电脑配对链接。',
37
37
  'fence.unpaired.stepOpen': '在当前浏览器打开该链接,完成授权后即可进入。',
38
38
  'fence.unpaired.retry': '重新检测',
39
+ 'fence.unpaired.tokenPlaceholder': '或在此直接粘贴配对链接 / Token',
40
+ 'fence.unpaired.pairAction': '立即配对',
41
+ 'fence.unpaired.pairing': '配对中…',
42
+ 'fence.unpaired.tokenInvalid': '配对链接或 Token 无效已过期',
43
+ 'fence.unpaired.tokenUsed': '该配对链接已被使用',
44
+ 'fence.unpaired.tokenFailed': '配对失败,请检查网络或重新获取链接',
39
45
  'fence.unpaired.footnote': '请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。',
40
46
  'posture.exposed': '/api 通道对未配对设备敞开',
41
47
  'posture.exposedHint': '以下来源的请求未经配对即可访问完整桌面 API:{hosts}。请移除对应来源的 --trusted-host(配对机制已覆盖远程访问),或改为仅绑定 127.0.0.1 并使用隧道。',
@@ -176,6 +182,12 @@ export const en = {
176
182
  'fence.unpaired.stepLink': 'Copy the computer pairing link under Pair a device.',
177
183
  'fence.unpaired.stepOpen': 'Open that link in this browser. After authorization, the workspace will become available.',
178
184
  'fence.unpaired.retry': 'Check again',
185
+ 'fence.unpaired.tokenPlaceholder': 'Or paste pairing link / token here',
186
+ 'fence.unpaired.pairAction': 'Pair Now',
187
+ 'fence.unpaired.pairing': 'Pairing…',
188
+ 'fence.unpaired.tokenInvalid': 'Pairing link or token is invalid or expired',
189
+ 'fence.unpaired.tokenUsed': 'This pairing link has already been used',
190
+ 'fence.unpaired.tokenFailed': 'Pairing failed, please check network or issue a new link',
179
191
  'fence.unpaired.footnote': 'Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.',
180
192
  'posture.exposed': 'The /api channel is open to unpaired devices',
181
193
  'posture.exposedHint': 'Requests from {hosts} reach the full desktop API without pairing. Remove --trusted-host for them (pairing already covers remote access), or bind loopback only and use the tunnel.',
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAI3B,OAAO,EAA0C,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AA6BzF,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAAkD,CAAA;AAErE;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAY3B,CAAA;AAKF;;;;GAIG;AACH,KAAK,cAAc,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,GAAG;IAC9E,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,IAAI,CAC5C,cAAc,EACd,YAAY,GAAG,gBAAgB,GAAG,YAAY,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAC/F,GAAG,aAAa,CAShB;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,IAAI,GAAE,MAAkB,GAAG,MAAM,CAEnE;AAiBD;;;;GAIG;AACH,eAAO,MAAM,KAAK,kBAAuD,CAAA;AAEzE,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAkVtD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAElD,OAAO,CAAC,MAAM,aAAa,CAAA;AAI3B,OAAO,EAA0C,KAAK,aAAa,EAAE,MAAM,cAAc,CAAA;AA6BzF,OAAO,QAAQ,qBAAqB,CAAC;IACnC,UAAU,MAAM;QACd;;;;;WAKG;QACH,UAAU,CACR,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,eAAe,EACxB,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,IAAI,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GACrC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;KAC9B;CACF;AAED,iCAAiC;AACjC,eAAO,MAAM,IAAI,kBAAkB,CAAA;AAEnC,+DAA+D;AAC/D,eAAO,MAAM,MAAM,UAAkD,CAAA;AAErE;;;;GAIG;AACH,eAAO,MAAM,gCAAgC,uDAAqC,CAAA;AAElF,qEAAqE;AACrE,MAAM,WAAW,MAAM;IACrB,yDAAyD;IACzD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,yEAAyE;IACzE,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,iDAAiD;IACjD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;;;OAMG;IACH,oBAAoB,CAAC,EAAE,OAAO,CAAA;IAC9B;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAY3B,CAAA;AAKF;;;;GAIG;AACH,KAAK,cAAc,GAAG,QAAQ,CAAC,IAAI,CAAC,MAAM,EAAE,eAAe,GAAG,aAAa,CAAC,CAAC,GAAG;IAC9E,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IACjC,WAAW,EAAE,MAAM,CAAA;CACpB,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,IAAI,CAC5C,cAAc,EACd,YAAY,GAAG,gBAAgB,GAAG,YAAY,GAAG,cAAc,GAAG,YAAY,GAAG,aAAa,CAC/F,GAAG,aAAa,CAShB;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,IAAI,GAAE,MAAkB,GAAG,MAAM,CAEnE;AAiBD;;;;GAIG;AACH,eAAO,MAAM,KAAK,kBAAuD,CAAA;AAEzE,iBAAS,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAwVtD"}
@@ -357,6 +357,10 @@ function applyImpl(ctx, config) {
357
357
  return false;
358
358
  return isPairedDeviceRequest(service, request);
359
359
  });
360
+ if (lanAddresses.length > 0) {
361
+ const urls = lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}/m/`).join(' , ');
362
+ console.log(`remote-web-ui: mobile UI reachable on LAN at ${urls}`);
363
+ }
360
364
  const sync = () => {
361
365
  const value = resolve();
362
366
  service.config = pairingConfigOf(value);
@@ -118,11 +118,19 @@ export declare function foldEvents(events: readonly WireEvent[], existing?: read
118
118
  * Incremental folder for one message stream. Live chat folds one event at a
119
119
  * time; rebuilding the five index maps by scanning every message per event
120
120
  * made that path O(n) per event (O(n * events) per turn). A folder keeps the
121
- * indexes alive across folds, applies each event in O(1) map operations, and
122
- * returns the previous snapshot identity unchanged when nothing applied, so
123
- * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
124
- * watermark advanced by the first application skips them, which also makes a
125
- * double-invoked React state updater harmless.
121
+ * indexes alive across folds, applies each event in (near-)O(1) map
122
+ * operations, and returns the previous snapshot identity unchanged when
123
+ * nothing applied, so React skips the re-render entirely. Replayed events
124
+ * are no-ops: the maxSeq watermark advanced by the first application skips
125
+ * them, which also makes a double-invoked React state updater harmless.
126
+ *
127
+ * Cost contract: every fold that applies at least one event rebuilds the
128
+ * snapshot as one fresh O(messages) array copy (React needs the new
129
+ * identity), and replacement paths locate rows by an O(messages) identity
130
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
131
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
132
+ * id-to-index map was measured and deliberately rejected: at realistic
133
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
126
134
  */
127
135
  export declare class EventFolder {
128
136
  private state;
@@ -1 +1 @@
1
- {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;GASG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
1
+ {"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
@@ -490,11 +490,19 @@ export function foldEvents(events, existing) {
490
490
  * Incremental folder for one message stream. Live chat folds one event at a
491
491
  * time; rebuilding the five index maps by scanning every message per event
492
492
  * made that path O(n) per event (O(n * events) per turn). A folder keeps the
493
- * indexes alive across folds, applies each event in O(1) map operations, and
494
- * returns the previous snapshot identity unchanged when nothing applied, so
495
- * React skips the re-render entirely. Replayed events are no-ops: the maxSeq
496
- * watermark advanced by the first application skips them, which also makes a
497
- * double-invoked React state updater harmless.
493
+ * indexes alive across folds, applies each event in (near-)O(1) map
494
+ * operations, and returns the previous snapshot identity unchanged when
495
+ * nothing applied, so React skips the re-render entirely. Replayed events
496
+ * are no-ops: the maxSeq watermark advanced by the first application skips
497
+ * them, which also makes a double-invoked React state updater harmless.
498
+ *
499
+ * Cost contract: every fold that applies at least one event rebuilds the
500
+ * snapshot as one fresh O(messages) array copy (React needs the new
501
+ * identity), and replacement paths locate rows by an O(messages) identity
502
+ * scan. Both are amortized by the consumer folding bursts as ONE batch on a
503
+ * fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
504
+ * id-to-index map was measured and deliberately rejected: at realistic
505
+ * message counts the Map maintenance cost exceeds the identity-scan cost.
498
506
  */
499
507
  export class EventFolder {
500
508
  state;
@@ -25,6 +25,13 @@ export interface ChatViewProps {
25
25
  * history tail re-pull closes the seam.
26
26
  */
27
27
  export declare const MAX_TAIL_BUFFER_EVENTS = 500;
28
+ /**
29
+ * Cadence for folding buffered live events into the message list. Streaming
30
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
31
+ * and re-render work per second without visible lag, and keeps working in
32
+ * background tabs where requestAnimationFrame is paused.
33
+ */
34
+ export declare const FOLD_FLUSH_MS = 50;
28
35
  /** One switchable permission preset (the `permissions` projection shape). */
29
36
  export interface PermissionOption {
30
37
  value: string;
@@ -1 +1 @@
1
- {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAqCzC,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BAoe/D;AA2GD;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AA6F5C,eAAO,MAAM,eAAe,OAAO,CAAA"}
1
+ {"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,KAAK,CAAA;AAqC/B,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BA2gB/D;AA2GD;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AA6F5C,eAAO,MAAM,eAAe,OAAO,CAAA"}
@@ -24,6 +24,13 @@ import { ThemeToggle } from "../theme-toggle.js";
24
24
  * history tail re-pull closes the seam.
25
25
  */
26
26
  export const MAX_TAIL_BUFFER_EVENTS = 500;
27
+ /**
28
+ * Cadence for folding buffered live events into the message list. Streaming
29
+ * chunks can arrive at a far higher rate; flushing on this cadence caps fold
30
+ * and re-render work per second without visible lag, and keeps working in
31
+ * background tabs where requestAnimationFrame is paused.
32
+ */
33
+ export const FOLD_FLUSH_MS = 50;
27
34
  /** localStorage key for the tool-call display toggle (persisted on the /m origin). */
28
35
  const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls';
29
36
  /** localStorage key for the injected-system-message display toggle. */
@@ -121,6 +128,14 @@ export function ChatView({ session, mux, onBack }) {
121
128
  const liveBufferRef = useRef([]);
122
129
  /** Incremental folder for this session's stream (indexes stay hot across events). */
123
130
  const folderRef = useRef(undefined);
131
+ /**
132
+ * Live events buffered between fold flushes. Folding every single event
133
+ * costs one O(messages) snapshot copy per event; flushing at a fixed
134
+ * cadence folds each burst once while keeping streaming text fluid.
135
+ */
136
+ const foldBufferRef = useRef([]);
137
+ /** Timer id of the scheduled fold flush, if any. */
138
+ const foldFlushTimerRef = useRef(undefined);
124
139
  /** True once the live buffer hit its cap (oldest events were dropped). */
125
140
  const liveBufferOverflowRef = useRef(false);
126
141
  /** The session's permission select (absent = capability not composed). */
@@ -238,7 +253,22 @@ export function ChatView({ session, mux, onBack }) {
238
253
  useEffect(() => {
239
254
  if (mux === undefined)
240
255
  return;
241
- return mux.onFrame((frame) => {
256
+ /** Fold every buffered live event in one batch and publish the snapshot. */
257
+ const flushFoldBuffer = () => {
258
+ if (foldFlushTimerRef.current !== undefined) {
259
+ clearTimeout(foldFlushTimerRef.current);
260
+ foldFlushTimerRef.current = undefined;
261
+ }
262
+ const events = foldBufferRef.current;
263
+ if (events.length === 0)
264
+ return;
265
+ foldBufferRef.current = [];
266
+ setMessages(previous => {
267
+ const folder = folderRef.current;
268
+ return folder === undefined ? foldEvents(events, previous) : folder.fold(events);
269
+ });
270
+ };
271
+ const unsubscribe = mux.onFrame((frame) => {
242
272
  if (frame.type === 'session/event') {
243
273
  if (frame.sessionId !== session.sessionId)
244
274
  return;
@@ -264,10 +294,17 @@ export function ChatView({ session, mux, onBack }) {
264
294
  liveBufferRef.current.push(event);
265
295
  return;
266
296
  }
267
- setMessages(previous => {
268
- const folder = folderRef.current;
269
- return folder === undefined ? foldEvents([event], previous) : folder.fold([event]);
270
- });
297
+ foldBufferRef.current.push(event);
298
+ // Only chunk streams batch on the flush cadence; every other event
299
+ // (final message, turn close, user echo, update/delete) flushes the
300
+ // buffer synchronously so settled UI states never wait for a timer.
301
+ const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk';
302
+ if (!isChunk) {
303
+ flushFoldBuffer();
304
+ }
305
+ else if (foldFlushTimerRef.current === undefined) {
306
+ foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS);
307
+ }
271
308
  return;
272
309
  }
273
310
  // Live projection pushes keep the permission picker current.
@@ -307,6 +344,17 @@ export function ChatView({ session, mux, onBack }) {
307
344
  return;
308
345
  }
309
346
  });
347
+ return () => {
348
+ unsubscribe();
349
+ // Drop the pending flush: its events belong to the session (or
350
+ // component instance) being torn down, and the next session reseeds
351
+ // its folder from a fresh history tail.
352
+ if (foldFlushTimerRef.current !== undefined) {
353
+ clearTimeout(foldFlushTimerRef.current);
354
+ foldFlushTimerRef.current = undefined;
355
+ }
356
+ foldBufferRef.current = [];
357
+ };
310
358
  }, [mux, session.sessionId]);
311
359
  // Weak-network polling fallback: when the assistant is running, poll for
312
360
  // pending approvals/questions every 1.5 s so the phone can act even if the
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  */
10
10
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
11
  import { act, cleanup, render, screen } from '@testing-library/react';
12
- import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from "./ChatView.js";
12
+ import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from "./ChatView.js";
13
13
  import { renderMarkdown } from "../markdown.js";
14
14
  // The api module is fully mocked; App.tsx's history wrapper is overridden to
15
15
  // feed a fixed empty history page, its pure helpers stay real.
@@ -134,8 +134,13 @@ describe('ChatView streaming markdown throttle', () => {
134
134
  '第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
135
135
  ];
136
136
  const full = parts.join('');
137
- // The first chunk mounts the pending message; the mount render parses it.
138
- await act(async () => { chunk(mux, parts[0] ?? '', 6); });
137
+ // The first chunk mounts the pending message on the next fold flush
138
+ // (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
139
+ // render parses it.
140
+ await act(async () => {
141
+ chunk(mux, parts[0] ?? '', 6);
142
+ vi.advanceTimersByTime(FOLD_FLUSH_MS + 5);
143
+ });
139
144
  const afterMount = renderMarkdownMock.mock.calls.length;
140
145
  expect(afterMount).toBeGreaterThanOrEqual(1);
141
146
  // A burst of further chunks does not re-parse per chunk: at most one
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
4
- "version": "0.3.4",
4
+ "version": "0.3.6",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -6,7 +6,9 @@
6
6
  * torn down (requirePairingForLan off / plugin disabled), so it never
7
7
  * outlives the unpaired state it describes.
8
8
  */
9
+ import { useState, type FormEvent } from 'react'
9
10
  import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
11
+ import { acceptPair } from './pair-api.ts'
10
12
  import css from './remote.module.css'
11
13
 
12
14
  /** Notice props: localized copy. */
@@ -14,6 +16,24 @@ export interface FenceNoticeProps {
14
16
  t: TranslateNS<'remote'>
15
17
  /** Retry after the user has opened a freshly issued computer pairing link. */
16
18
  onRetry: () => void
19
+ /** Optional custom pairing acceptor for testing / dependency injection. */
20
+ onAccept?: (token: string) => Promise<{ ok: true } | { ok: false; code: 'invalid' | 'used' | 'forbidden' }>
21
+ }
22
+
23
+ /**
24
+ * Extract a pairing token from either a raw token string or a copied pairing link URL.
25
+ */
26
+ export function extractPairToken(input: string): string | undefined {
27
+ const trimmed = input.trim()
28
+ if (trimmed === '') return undefined
29
+ try {
30
+ const url = new URL(trimmed)
31
+ const token = url.searchParams.get('pair')
32
+ if (token !== null && token !== '') return token
33
+ } catch {
34
+ // Plain token string
35
+ }
36
+ return trimmed
17
37
  }
18
38
 
19
39
  /**
@@ -21,7 +41,39 @@ export interface FenceNoticeProps {
21
41
  * @param props - localized copy.
22
42
  * @returns the notice element.
23
43
  */
24
- export function FenceNotice({ t, onRetry }: FenceNoticeProps) {
44
+ export function FenceNotice({ t, onRetry, onAccept = acceptPair }: FenceNoticeProps) {
45
+ const [tokenInput, setTokenInput] = useState('')
46
+ const [submitting, setSubmitting] = useState(false)
47
+ const [errorMsg, setErrorMsg] = useState<string | undefined>(undefined)
48
+
49
+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
50
+ event.preventDefault()
51
+ const token = extractPairToken(tokenInput)
52
+ if (!token) return
53
+
54
+ setSubmitting(true)
55
+ setErrorMsg(undefined)
56
+
57
+ try {
58
+ const result = await onAccept(token)
59
+ if (result.ok) {
60
+ onRetry()
61
+ return
62
+ }
63
+ if (result.code === 'invalid') {
64
+ setErrorMsg(t('fence.unpaired.tokenInvalid'))
65
+ } else if (result.code === 'used') {
66
+ setErrorMsg(t('fence.unpaired.tokenUsed'))
67
+ } else {
68
+ setErrorMsg(t('fence.unpaired.tokenFailed'))
69
+ }
70
+ } catch {
71
+ setErrorMsg(t('fence.unpaired.tokenFailed'))
72
+ } finally {
73
+ setSubmitting(false)
74
+ }
75
+ }
76
+
25
77
  return (
26
78
  <div className={css.fencePage} role="dialog" aria-modal="true" aria-labelledby="remote-fence-title">
27
79
  <main className={css.fenceCard} data-dsh-plugin="remote-web-ui">
@@ -34,6 +86,34 @@ export function FenceNotice({ t, onRetry }: FenceNoticeProps) {
34
86
  <li>{t('fence.unpaired.stepLink')}</li>
35
87
  <li>{t('fence.unpaired.stepOpen')}</li>
36
88
  </ol>
89
+
90
+ <form className={css.fenceForm} onSubmit={handleSubmit}>
91
+ <div className={css.fenceInputRow}>
92
+ <input
93
+ type="text"
94
+ className={css.fenceInput}
95
+ placeholder={t('fence.unpaired.tokenPlaceholder')}
96
+ value={tokenInput}
97
+ onChange={event => {
98
+ setTokenInput(event.target.value)
99
+ if (errorMsg !== undefined) setErrorMsg(undefined)
100
+ }}
101
+ disabled={submitting}
102
+ aria-label={t('fence.unpaired.tokenPlaceholder')}
103
+ />
104
+ <button
105
+ className={css.fencePairButton}
106
+ type="submit"
107
+ disabled={submitting || tokenInput.trim() === ''}
108
+ >
109
+ {submitting ? t('fence.unpaired.pairing') : t('fence.unpaired.pairAction')}
110
+ </button>
111
+ </div>
112
+ {errorMsg !== undefined && (
113
+ <p className={css.fenceError} role="alert">{errorMsg}</p>
114
+ )}
115
+ </form>
116
+
37
117
  <button className={css.fenceRetry} type="button" onClick={onRetry}>
38
118
  {t('fence.unpaired.retry')}
39
119
  </button>
@@ -37,6 +37,12 @@ export const zh = {
37
37
  'fence.unpaired.stepLink': '在“设备配对”中复制电脑配对链接。',
38
38
  'fence.unpaired.stepOpen': '在当前浏览器打开该链接,完成授权后即可进入。',
39
39
  'fence.unpaired.retry': '重新检测',
40
+ 'fence.unpaired.tokenPlaceholder': '或在此直接粘贴配对链接 / Token',
41
+ 'fence.unpaired.pairAction': '立即配对',
42
+ 'fence.unpaired.pairing': '配对中…',
43
+ 'fence.unpaired.tokenInvalid': '配对链接或 Token 无效已过期',
44
+ 'fence.unpaired.tokenUsed': '该配对链接已被使用',
45
+ 'fence.unpaired.tokenFailed': '配对失败,请检查网络或重新获取链接',
40
46
  'fence.unpaired.footnote': '请勿使用他人提供的配对链接;管理员可随时取消此设备的授权。',
41
47
  'posture.exposed': '/api 通道对未配对设备敞开',
42
48
  'posture.exposedHint': '以下来源的请求未经配对即可访问完整桌面 API:{hosts}。请移除对应来源的 --trusted-host(配对机制已覆盖远程访问),或改为仅绑定 127.0.0.1 并使用隧道。',
@@ -184,6 +190,12 @@ export const en = {
184
190
  'fence.unpaired.stepLink': 'Copy the computer pairing link under Pair a device.',
185
191
  'fence.unpaired.stepOpen': 'Open that link in this browser. After authorization, the workspace will become available.',
186
192
  'fence.unpaired.retry': 'Check again',
193
+ 'fence.unpaired.tokenPlaceholder': 'Or paste pairing link / token here',
194
+ 'fence.unpaired.pairAction': 'Pair Now',
195
+ 'fence.unpaired.pairing': 'Pairing…',
196
+ 'fence.unpaired.tokenInvalid': 'Pairing link or token is invalid or expired',
197
+ 'fence.unpaired.tokenUsed': 'This pairing link has already been used',
198
+ 'fence.unpaired.tokenFailed': 'Pairing failed, please check network or issue a new link',
187
199
  'fence.unpaired.footnote': 'Do not use pairing links from people you do not trust. An administrator can revoke this device at any time.',
188
200
  'posture.exposed': 'The /api channel is open to unpaired devices',
189
201
  'posture.exposedHint': 'Requests from {hosts} reach the full desktop API without pairing. Remove --trusted-host for them (pairing already covers remote access), or bind loopback only and use the tunnel.',