@linxin666/dsh-remote-web-ui 0.2.4 → 0.2.5

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.
Files changed (89) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +148 -77
  3. package/README.zh.md +32 -26
  4. package/lib/client.js +602 -67
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +667 -32
  7. package/lib/mobile.js +3 -1
  8. package/lib/mobile.js.map +1 -1
  9. package/lib/types/client/FenceNotice.d.ts +21 -0
  10. package/lib/types/client/FenceNotice.d.ts.map +1 -0
  11. package/lib/types/client/FenceNotice.js +10 -0
  12. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  13. package/lib/types/client/RemoteEntry.js +18 -11
  14. package/lib/types/client/RemotePanel.d.ts +10 -4
  15. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  16. package/lib/types/client/RemotePanel.js +7 -6
  17. package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +3 -1
  20. package/lib/types/client/device-name.d.ts +3 -0
  21. package/lib/types/client/device-name.d.ts.map +1 -0
  22. package/lib/types/client/device-name.js +30 -0
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +48 -0
  25. package/lib/types/client/locales.d.ts +51 -1
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +79 -29
  28. package/lib/types/client/pair-api.d.ts +35 -0
  29. package/lib/types/client/pair-api.d.ts.map +1 -1
  30. package/lib/types/client/pair-api.js +39 -0
  31. package/lib/types/client/remote-channel.d.ts +93 -0
  32. package/lib/types/client/remote-channel.d.ts.map +1 -0
  33. package/lib/types/client/remote-channel.js +246 -0
  34. package/lib/types/dsh-home.d.ts +17 -0
  35. package/lib/types/dsh-home.d.ts.map +1 -0
  36. package/lib/types/dsh-home.js +34 -0
  37. package/lib/types/gate.d.ts.map +1 -1
  38. package/lib/types/gate.js +5 -2
  39. package/lib/types/index.d.ts +19 -12
  40. package/lib/types/index.d.ts.map +1 -1
  41. package/lib/types/index.js +73 -5
  42. package/lib/types/loopback-proxy.d.ts +28 -0
  43. package/lib/types/loopback-proxy.d.ts.map +1 -0
  44. package/lib/types/loopback-proxy.js +112 -0
  45. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  46. package/lib/types/mobile/views/SessionListView.js +6 -1
  47. package/lib/types/mobile-api.d.ts.map +1 -1
  48. package/lib/types/mobile-api.js +6 -2
  49. package/lib/types/mobile-routes.js +1 -1
  50. package/lib/types/pairing.d.ts +63 -11
  51. package/lib/types/pairing.d.ts.map +1 -1
  52. package/lib/types/pairing.js +137 -20
  53. package/lib/types/pairing.persist.test.js +60 -5
  54. package/lib/types/posture.d.ts +75 -0
  55. package/lib/types/posture.d.ts.map +1 -0
  56. package/lib/types/posture.js +116 -0
  57. package/lib/types/remote-api.d.ts +60 -0
  58. package/lib/types/remote-api.d.ts.map +1 -0
  59. package/lib/types/remote-api.js +181 -0
  60. package/lib/types/remote-methods.d.ts +33 -0
  61. package/lib/types/remote-methods.d.ts.map +1 -0
  62. package/lib/types/remote-methods.js +54 -0
  63. package/lib/types/routes.d.ts +4 -0
  64. package/lib/types/routes.d.ts.map +1 -1
  65. package/lib/types/routes.js +33 -4
  66. package/package.json +2 -2
  67. package/src/client/FenceNotice.tsx +43 -0
  68. package/src/client/RemoteEntry.tsx +19 -9
  69. package/src/client/RemotePanel.tsx +83 -10
  70. package/src/client/RemoteSettingsCard.tsx +16 -0
  71. package/src/client/device-name.ts +31 -0
  72. package/src/client/index.ts +46 -0
  73. package/src/client/locales.ts +79 -29
  74. package/src/client/pair-api.ts +60 -0
  75. package/src/client/remote-channel.ts +269 -0
  76. package/src/client/remote.module.css +240 -16
  77. package/src/dsh-home.ts +36 -0
  78. package/src/gate.ts +5 -2
  79. package/src/index.ts +89 -17
  80. package/src/loopback-proxy.ts +126 -0
  81. package/src/mobile/views/SessionListView.tsx +6 -1
  82. package/src/mobile-api.ts +6 -2
  83. package/src/mobile-routes.ts +1 -1
  84. package/src/pairing.persist.test.ts +64 -5
  85. package/src/pairing.ts +167 -20
  86. package/src/posture.ts +154 -0
  87. package/src/remote-api.ts +217 -0
  88. package/src/remote-methods.ts +62 -0
  89. package/src/routes.ts +33 -4
@@ -0,0 +1,181 @@
1
+ /**
2
+ * The remote desktop data channel: `/remote` is this plugin's own prefix, so
3
+ * the paired-device cookie is the access control (exactly like `/m/api`).
4
+ * After that gate, every fenced same-origin path the browser rewrote here is
5
+ * re-issued to 127.0.0.1 as a loopback-shaped request so sibling plugin
6
+ * fences (and the connection plugin's `/api`) accept it — no `--trusted-host`
7
+ * and no per-plugin pairing consult.
8
+ *
9
+ * Security model:
10
+ * - Every request must carry a live paired-device cookie, enforced before
11
+ * any bytes are forwarded and before any host call.
12
+ * - The SDK's loopback-only privileged methods (native dialogs, the settings
13
+ * plane, credentials — the `PRIVILEGED_METHODS` set of client-connection)
14
+ * are denied here. The set is pinned by tests/remote-contract.spec.ts.
15
+ * - `/api/pair/*`, `/api/update/*`, `/api/plugin-manager/*`,
16
+ * `/api/dsh-desktop-launcher/*` and `/api/dsh-web-ui-settings/*` stay physically local.
17
+ * - Everything else is HTTP- or WebSocket-proxied to the local port with
18
+ * Host rewritten, Origin and cookies dropped, and a synthetic same-origin
19
+ * browser marker added after authentication. Plugin loopback fences then
20
+ * pass. The pairing cookie never leaves this process.
21
+ */
22
+ import { readCookie } from './gate.js';
23
+ import { writeJson } from './http.js';
24
+ import { proxyLoopbackHttp, proxyLoopbackUpgrade } from './loopback-proxy.js';
25
+ import { DESKTOP_LAUNCHER_PATH, LOOPBACK_ONLY_METHODS, PLUGIN_MANAGER_PATH, REMOTE_PREFIX, REMOTE_UPGRADE_PATHS, WEB_UI_SETTINGS_BRIDGE_PATH, } from './remote-methods.js';
26
+ export { DESKTOP_LAUNCHER_PATH, LOOPBACK_ONLY_METHODS, PLUGIN_MANAGER_PATH, REMOTE_API_PATHS, REMOTE_PREFIX, REMOTE_UPGRADE_PATHS, WEB_UI_SETTINGS_BRIDGE_PATH, } from './remote-methods.js';
27
+ export { REMOTE_API_PREFIX } from './remote-methods.js';
28
+ const ALLOWED_METHODS = new Set(['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE']);
29
+ /** Reject traversal and empty segments; allow plugin file-path characters. */
30
+ function isSafeSegment(segment) {
31
+ if (segment === '')
32
+ return false;
33
+ let decoded;
34
+ try {
35
+ decoded = decodeURIComponent(segment);
36
+ }
37
+ catch {
38
+ return false;
39
+ }
40
+ return decoded !== '.' && decoded !== '..' && !decoded.includes('/') && !decoded.includes('\\') && !decoded.includes('\0');
41
+ }
42
+ /** One SDK-shaped error envelope (keeps the desktop client's parse path intact). */
43
+ function envelopeError(res, status, rpcId, code, message) {
44
+ writeJson(res, status, {
45
+ type: 'server-response',
46
+ rpcId,
47
+ result: { ok: false, error: { code, message, details: { issues: [] } } },
48
+ });
49
+ }
50
+ /**
51
+ * Map `/remote/...` to the inner path, or undefined when the outer path is
52
+ * not a safe rewrite target.
53
+ */
54
+ export function innerPathOf(pathname) {
55
+ if (pathname === REMOTE_PREFIX || pathname === `${REMOTE_PREFIX}/`)
56
+ return undefined;
57
+ if (!pathname.startsWith(`${REMOTE_PREFIX}/`))
58
+ return undefined;
59
+ const rest = pathname.slice(REMOTE_PREFIX.length);
60
+ if (!rest.startsWith('/'))
61
+ return undefined;
62
+ const segments = rest.slice(1).split('/');
63
+ if (segments.length === 0 || segments.some(segment => !isSafeSegment(segment))) {
64
+ return undefined;
65
+ }
66
+ return rest;
67
+ }
68
+ /**
69
+ * Whether a paired inner path must stay physically local.
70
+ * @returns a denial message, or undefined when the path may be proxied.
71
+ */
72
+ export function loopbackOnlyDenial(innerPath) {
73
+ if (innerPath === '/api/pair' || innerPath.startsWith('/api/pair/')) {
74
+ return 'pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop';
75
+ }
76
+ if (innerPath === '/api/update' || innerPath.startsWith('/api/update/')) {
77
+ return 'update endpoints stay loopback-only and stay unreachable from a paired remote desktop';
78
+ }
79
+ if (innerPath === PLUGIN_MANAGER_PATH || innerPath.startsWith(`${PLUGIN_MANAGER_PATH}/`)) {
80
+ return 'plugin-manager stays loopback-only and stays unreachable from a paired remote desktop';
81
+ }
82
+ if (innerPath === DESKTOP_LAUNCHER_PATH || innerPath.startsWith(`${DESKTOP_LAUNCHER_PATH}/`)) {
83
+ return 'desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop';
84
+ }
85
+ if (innerPath === WEB_UI_SETTINGS_BRIDGE_PATH || innerPath.startsWith(`${WEB_UI_SETTINGS_BRIDGE_PATH}/`)) {
86
+ return 'settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop';
87
+ }
88
+ if (!innerPath.startsWith('/api/'))
89
+ return undefined;
90
+ const method = innerPath.slice('/api/'.length);
91
+ if (method !== '' && !method.includes('/') && LOOPBACK_ONLY_METHODS.has(method)) {
92
+ return `${method} is loopback-only and stays unreachable from a paired remote desktop`;
93
+ }
94
+ return undefined;
95
+ }
96
+ /**
97
+ * Build the remote desktop channel HTTP routes.
98
+ * @param deps - pairing service + local port.
99
+ * @returns the routes to register on webServer.
100
+ */
101
+ export function makeRemoteApiRoutes(deps) {
102
+ const { service, port } = deps;
103
+ const handler = (req, res) => {
104
+ // Cookie gate first — same order as /m/api. Do not buffer an unpaired body.
105
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
106
+ const paired = deviceId !== undefined && service.touchDevice(deviceId);
107
+ if (!paired) {
108
+ req.resume();
109
+ envelopeError(res, 403, 'invalid-request', 'unpaired', 'this device is not paired with the desktop');
110
+ return;
111
+ }
112
+ const method = req.method ?? 'GET';
113
+ if (!ALLOWED_METHODS.has(method)) {
114
+ req.resume();
115
+ res.writeHead(405).end();
116
+ return;
117
+ }
118
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
119
+ const inner = innerPathOf(url.pathname);
120
+ if (inner === undefined) {
121
+ req.resume();
122
+ res.writeHead(404).end();
123
+ return;
124
+ }
125
+ const denied = loopbackOnlyDenial(inner);
126
+ if (denied !== undefined) {
127
+ req.resume();
128
+ envelopeError(res, 403, 'invalid-request', 'forbidden', denied);
129
+ return;
130
+ }
131
+ proxyLoopbackHttp(req, res, port, `${inner}${url.search}`);
132
+ };
133
+ return [{ kind: 'prefix', path: REMOTE_PREFIX, handler }];
134
+ }
135
+ /**
136
+ * Map one outer upgrade URL onto the loopback path (query string included).
137
+ */
138
+ export function upgradeInnerPath(reqUrl, fallbackPath) {
139
+ if (reqUrl === undefined || reqUrl === '')
140
+ return fallbackPath;
141
+ let url;
142
+ try {
143
+ url = new URL(reqUrl, 'http://127.0.0.1');
144
+ }
145
+ catch {
146
+ return fallbackPath;
147
+ }
148
+ const inner = innerPathOf(url.pathname);
149
+ if (inner === undefined)
150
+ return fallbackPath;
151
+ return `${inner}${url.search}`;
152
+ }
153
+ /**
154
+ * Build the WebSocket upgrade routes for the event streams and known plugin
155
+ * sockets. webServer matches upgrades by exact path.
156
+ * @param deps - pairing service + local port.
157
+ * @returns the upgrade routes to register on webServer.
158
+ */
159
+ export function makeRemoteApiUpgradeRoutes(deps) {
160
+ const { service, port } = deps;
161
+ const handlerFor = (fallbackPath) => (req, socket, head) => {
162
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
163
+ if (deviceId === undefined || !service.touchDevice(deviceId)) {
164
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
165
+ socket.destroy();
166
+ return;
167
+ }
168
+ const inner = upgradeInnerPath(req.url, fallbackPath);
169
+ const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner);
170
+ if (denied !== undefined) {
171
+ socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
172
+ socket.destroy();
173
+ return;
174
+ }
175
+ proxyLoopbackUpgrade(req, socket, head, port, inner);
176
+ };
177
+ return REMOTE_UPGRADE_PATHS.map((path) => ({
178
+ path,
179
+ handler: handlerFor(path.slice(REMOTE_PREFIX.length)),
180
+ }));
181
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Remote desktop channel constants — SDK-independent so tests and the
3
+ * client half can pin them without importing the host SDK graph.
4
+ */
5
+ /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
6
+ export declare const REMOTE_PREFIX = "/remote";
7
+ /** Connection-plugin method prefix under the gated channel. */
8
+ export declare const REMOTE_API_PREFIX = "/remote/api";
9
+ /** WebSocket event-stream paths served by the channel (client rewrites to these). */
10
+ export declare const REMOTE_API_PATHS: {
11
+ readonly mux: "/remote/api/events.mux";
12
+ readonly host: "/remote/api/events.host";
13
+ };
14
+ /**
15
+ * Exact upgrade paths registered on webServer (the SDK matches upgrades by
16
+ * exact path, not prefix). Query strings ride on the request URL.
17
+ */
18
+ export declare const REMOTE_UPGRADE_PATHS: readonly ["/remote/api/events.mux", "/remote/api/events.host", "/remote/sidebar/ws/terminal", "/remote/sidebar/ws/agent-terminals", "/remote/api/dsh-ssh/terminal"];
19
+ /** Plugin-manager HTTP prefix: install/remove stay physically local. */
20
+ export declare const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
21
+ /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
22
+ export declare const DESKTOP_LAUNCHER_PATH = "/api/dsh-desktop-launcher";
23
+ /** Family settings-bridge HTTP prefix: describe/mutate stay physically local. */
24
+ export declare const WEB_UI_SETTINGS_BRIDGE_PATH = "/api/dsh-web-ui-settings";
25
+ /**
26
+ * Loopback-only methods of the host API surface, mirrored from
27
+ * client-connection's `PRIVILEGED_METHODS` (pinned by
28
+ * tests/remote-contract.spec.ts against the installed SDK). They stay
29
+ * unreachable from a paired remote desktop, matching the SDK's own stance
30
+ * that the configuration plane is loopback-same-origin only.
31
+ */
32
+ export declare const LOOPBACK_ONLY_METHODS: Set<string>;
33
+ //# sourceMappingURL=remote-methods.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remote-methods.d.ts","sourceRoot":"","sources":["../../src/remote-methods.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,gFAAgF;AAChF,eAAO,MAAM,aAAa,YAAY,CAAA;AAEtC,+DAA+D;AAC/D,eAAO,MAAM,iBAAiB,gBAAyB,CAAA;AAEvD,qFAAqF;AACrF,eAAO,MAAM,gBAAgB;aAC3B,GAAG;aACH,IAAI;CACI,CAAA;AAEV;;;GAGG;AACH,eAAO,MAAM,oBAAoB,qKAMvB,CAAA;AAEV,wEAAwE;AACxE,eAAO,MAAM,mBAAmB,wBAAwB,CAAA;AAExD,6FAA6F;AAC7F,eAAO,MAAM,qBAAqB,8BAA8B,CAAA;AAEhE,iFAAiF;AACjF,eAAO,MAAM,2BAA2B,6BAA6B,CAAA;AAErE;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,aAgBhC,CAAA"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Remote desktop channel constants — SDK-independent so tests and the
3
+ * client half can pin them without importing the host SDK graph.
4
+ */
5
+ /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
6
+ export const REMOTE_PREFIX = '/remote';
7
+ /** Connection-plugin method prefix under the gated channel. */
8
+ export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`;
9
+ /** WebSocket event-stream paths served by the channel (client rewrites to these). */
10
+ export const REMOTE_API_PATHS = {
11
+ mux: `${REMOTE_API_PREFIX}/events.mux`,
12
+ host: `${REMOTE_API_PREFIX}/events.host`,
13
+ };
14
+ /**
15
+ * Exact upgrade paths registered on webServer (the SDK matches upgrades by
16
+ * exact path, not prefix). Query strings ride on the request URL.
17
+ */
18
+ export const REMOTE_UPGRADE_PATHS = [
19
+ REMOTE_API_PATHS.mux,
20
+ REMOTE_API_PATHS.host,
21
+ `${REMOTE_PREFIX}/sidebar/ws/terminal`,
22
+ `${REMOTE_PREFIX}/sidebar/ws/agent-terminals`,
23
+ `${REMOTE_API_PREFIX}/dsh-ssh/terminal`,
24
+ ];
25
+ /** Plugin-manager HTTP prefix: install/remove stay physically local. */
26
+ export const PLUGIN_MANAGER_PATH = '/api/plugin-manager';
27
+ /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
28
+ export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher';
29
+ /** Family settings-bridge HTTP prefix: describe/mutate stay physically local. */
30
+ export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings';
31
+ /**
32
+ * Loopback-only methods of the host API surface, mirrored from
33
+ * client-connection's `PRIVILEGED_METHODS` (pinned by
34
+ * tests/remote-contract.spec.ts against the installed SDK). They stay
35
+ * unreachable from a paired remote desktop, matching the SDK's own stance
36
+ * that the configuration plane is loopback-same-origin only.
37
+ */
38
+ export const LOOPBACK_ONLY_METHODS = new Set([
39
+ 'agentPreset.read',
40
+ 'agentPreset.copy',
41
+ 'agentPreset.openDocument',
42
+ 'agentPreset.remove',
43
+ 'host.pickDirectory',
44
+ 'host.openPath',
45
+ 'settings.describe',
46
+ 'settings.openDocument',
47
+ 'settings.update',
48
+ 'settings.replace',
49
+ 'settings.mutate',
50
+ 'credentials.describe',
51
+ 'credentials.set',
52
+ 'credentials.unset',
53
+ 'llm.discoverModels',
54
+ ]);
@@ -40,6 +40,7 @@ export declare const PAIR_PATHS: {
40
40
  readonly issue: '/api/pair/issue';
41
41
  readonly accept: '/api/pair/accept';
42
42
  readonly stop: '/api/pair/stop';
43
+ readonly revoke: '/api/pair/revoke';
43
44
  readonly heartbeat: '/api/pair/heartbeat';
44
45
  readonly status: '/api/pair/status';
45
46
  readonly events: '/api/pair/events';
@@ -59,6 +60,9 @@ export declare const issuePayloadSchema: z.ZodObject<{
59
60
  export declare const acceptPayloadSchema: z.ZodObject<{
60
61
  token: z.ZodDefault<z.ZodString>;
61
62
  }, z.core.$strip>;
63
+ export declare const revokePayloadSchema: z.ZodObject<{
64
+ deviceId: z.ZodString;
65
+ }, z.core.$strip>;
62
66
  export declare const pairActionPayloadSchema: z.ZodObject<{}, z.core.$loose>;
63
67
  /** The SSE fan-out for desktop panel status. */
64
68
  export declare class PairingEventsStream {
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAwM3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,MAAM,EAAE,kBAAkB;aAC1B,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAiO3D"}
@@ -88,6 +88,7 @@ export const PAIR_PATHS = {
88
88
  issue: '/api/pair/issue',
89
89
  accept: '/api/pair/accept',
90
90
  stop: '/api/pair/stop',
91
+ revoke: '/api/pair/revoke',
91
92
  heartbeat: '/api/pair/heartbeat',
92
93
  status: '/api/pair/status',
93
94
  events: '/api/pair/events',
@@ -107,6 +108,9 @@ export const issuePayloadSchema = z.object({
107
108
  export const acceptPayloadSchema = z.object({
108
109
  token: z.string().default(''),
109
110
  });
111
+ export const revokePayloadSchema = z.object({
112
+ deviceId: z.string().min(1),
113
+ });
110
114
  export const pairActionPayloadSchema = z.object({}).passthrough();
111
115
  /**
112
116
  * Parse a pair request body through schema. A missing/empty or non-object
@@ -291,7 +295,8 @@ export function makeRoutes(deps) {
291
295
  writeJson(res, 400, { ok: false, code: 'bad-payload' });
292
296
  return;
293
297
  }
294
- const result = service.accept(payload.token);
298
+ const ua = req.headers['user-agent'];
299
+ const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined);
295
300
  if (!result.ok) {
296
301
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
297
302
  return;
@@ -322,6 +327,26 @@ export function makeRoutes(deps) {
322
327
  service.stop();
323
328
  writeJson(res, 200, { ok: true });
324
329
  };
330
+ const handleRevoke = async (req, res) => {
331
+ if (!requireMethod(req, res, 'POST'))
332
+ return;
333
+ if (!loopbackFence(req)) {
334
+ writeJson(res, 403, { ok: false, code: 'forbidden' });
335
+ return;
336
+ }
337
+ const body = await readJsonBody(req);
338
+ const payload = parsePairPayload(revokePayloadSchema, body);
339
+ if (payload === undefined) {
340
+ writeJson(res, 400, { ok: false, code: 'bad-payload' });
341
+ return;
342
+ }
343
+ const revoked = service.revoke(payload.deviceId);
344
+ if (!revoked) {
345
+ writeJson(res, 404, { ok: false, code: 'unknown-device' });
346
+ return;
347
+ }
348
+ writeJson(res, 200, { ok: true });
349
+ };
325
350
  const handleHeartbeat = async (req, res) => {
326
351
  if (!requireMethod(req, res, 'POST'))
327
352
  return;
@@ -352,10 +377,13 @@ export function makeRoutes(deps) {
352
377
  const paired = deviceId !== undefined && service.hasDevice(deviceId);
353
378
  const snapshot = service.snapshot();
354
379
  // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
355
- // token expiry, device roster, and public tunnel URL are an oracle for
356
- // targeting and timing and stay behind a live device cookie.
380
+ // token expiry, public tunnel URL, and counts are an oracle for targeting
381
+ // and stay behind a live device cookie. The per-device roster (ids are
382
+ // session credentials) is never returned here — only the loopback events
383
+ // stream carries it to the desktop panel.
384
+ const { devices: _devices, ...rest } = snapshot;
357
385
  const visible = paired
358
- ? snapshot
386
+ ? rest
359
387
  : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses };
360
388
  writeJson(res, 200, { ok: true, paired, ...visible });
361
389
  };
@@ -374,6 +402,7 @@ export function makeRoutes(deps) {
374
402
  { kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
375
403
  { kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
376
404
  { kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
405
+ { kind: 'exact', path: PAIR_PATHS.revoke, handler: handleRevoke },
377
406
  { kind: 'exact', path: PAIR_PATHS.heartbeat, handler: handleHeartbeat },
378
407
  { kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
379
408
  { kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
- "description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
4
- "version": "0.2.4",
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.2.5",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -0,0 +1,43 @@
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), so it never outlives the
6
+ * unpaired state it describes.
7
+ */
8
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
9
+ import css from './remote.module.css'
10
+
11
+ /** Notice props: localized copy. */
12
+ export interface FenceNoticeProps {
13
+ t: TranslateNS<'remote'>
14
+ /** Retry after the user has opened a freshly issued computer pairing link. */
15
+ onRetry: () => void
16
+ }
17
+
18
+ /**
19
+ * Render the unpaired blocking page.
20
+ * @param props - localized copy.
21
+ * @returns the notice element.
22
+ */
23
+ export function FenceNotice({ t, onRetry }: FenceNoticeProps) {
24
+ return (
25
+ <div className={css.fencePage} role="dialog" aria-modal="true" aria-labelledby="remote-fence-title">
26
+ <main className={css.fenceCard} data-dsh-plugin="remote-web-ui">
27
+ <div className={css.fenceMark} aria-hidden="true">×</div>
28
+ <p className={css.fenceEyebrow}>{t('fence.unpaired.eyebrow')}</p>
29
+ <h1 id="remote-fence-title" className={css.fenceTitle}>{t('fence.unpaired.title')}</h1>
30
+ <p className={css.fenceDetail}>{t('fence.unpaired.hint')}</p>
31
+ <ol className={css.fenceSteps}>
32
+ <li>{t('fence.unpaired.stepDesktop')}</li>
33
+ <li>{t('fence.unpaired.stepLink')}</li>
34
+ <li>{t('fence.unpaired.stepOpen')}</li>
35
+ </ol>
36
+ <button className={css.fenceRetry} type="button" onClick={onRetry}>
37
+ {t('fence.unpaired.retry')}
38
+ </button>
39
+ <p className={css.fenceFootnote}>{t('fence.unpaired.footnote')}</p>
40
+ </main>
41
+ </div>
42
+ )
43
+ }
@@ -12,7 +12,7 @@ import { createPortal } from 'react-dom'
12
12
  import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
13
13
  import type { PairingPhase } from '../pairing.ts'
14
14
  import { RemotePanel, type PanelState } from './RemotePanel.tsx'
15
- import { copyText, issuePair, stopPair, type IssueResponse, type PairStateFrame, type TunnelStatusFrame } from './pair-api.ts'
15
+ import { copyText, issuePair, revokePair, stopPair, type DeviceFrame, type IssueResponse, type PairStateFrame, type TunnelStatusFrame } from './pair-api.ts'
16
16
  import { PhoneIcon } from './PhoneIcon.tsx'
17
17
  import { UpdateEntry } from './UpdateEntry.tsx'
18
18
  import css from './remote.module.css'
@@ -38,7 +38,9 @@ function mergeFrame(state: PanelState, frame: PairStateFrame): PanelState {
38
38
  phase: frame.phase,
39
39
  deviceCount: frame.deviceCount,
40
40
  onlineCount: frame.onlineCount,
41
+ devices: frame.devices ?? [],
41
42
  ...(frame.tunnel !== undefined ? { tunnel: frame.tunnel as TunnelStatusFrame } : {}),
43
+ ...(frame.posture !== undefined ? { posture: frame.posture } : {}),
42
44
  }
43
45
  }
44
46
 
@@ -55,7 +57,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
55
57
  // pure), so mint decisions read this ref instead.
56
58
  const stateRef = useRef(state)
57
59
  useEffect(() => { stateRef.current = state }, [state])
58
- const [copied, setCopied] = useState(false)
60
+ const [copied, setCopied] = useState<'phone' | 'desktop' | undefined>(undefined)
59
61
  const eventSource = useRef<EventSource | undefined>(undefined)
60
62
  // Generation counter for the open flow: closing (or re-opening) the panel
61
63
  // bumps it, so an in-flight issue() that resolves after a close does not
@@ -97,6 +99,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
97
99
  phase: 'waiting',
98
100
  deviceCount: 0,
99
101
  onlineCount: 0,
102
+ devices: [] as DeviceFrame[],
100
103
  // Whether this QR is built on the configured public (tunneled) base.
101
104
  public: publicBaseUrl !== undefined && result.url.startsWith(publicBaseUrl),
102
105
  ...(publicBaseUrl !== undefined ? { publicBaseUrl } : {}),
@@ -180,7 +183,14 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
180
183
  // keeps the UI honest, and the status stream confirms the stopped phase.
181
184
  void stopPair().catch(() => {})
182
185
  // Optimistic fallback; the status stream confirms with the stopped phase.
183
- setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' as PairingPhase } : previous)
186
+ setState(previous => previous.kind === 'ready' ? { ...previous, phase: 'stopped' as PairingPhase, devices: [] } : previous)
187
+ }, [])
188
+
189
+ const handleRevoke = useCallback((deviceId: string) => {
190
+ void revokePair(deviceId).catch(() => {})
191
+ setState(previous => previous.kind === 'ready'
192
+ ? { ...previous, devices: previous.devices.filter(device => device.id !== deviceId) }
193
+ : previous)
184
194
  }, [])
185
195
 
186
196
  const handleRefresh = useCallback(() => {
@@ -197,14 +207,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
197
207
  void mint().then(setState)
198
208
  }, [mint])
199
209
 
200
- const handleCopy = useCallback(() => {
201
- if (state.kind !== 'ready') return
202
- void copyText(state.url).then((ok) => {
210
+ const handleCopy = useCallback((target: 'phone' | 'desktop', url: string) => {
211
+ void copyText(url).then((ok) => {
203
212
  if (!ok) return
204
- setCopied(true)
205
- window.setTimeout(() => { setCopied(false) }, 1500)
213
+ setCopied(target)
214
+ window.setTimeout(() => { setCopied(undefined) }, 1500)
206
215
  })
207
- }, [state])
216
+ }, [])
208
217
 
209
218
  return (
210
219
  <>
@@ -225,6 +234,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
225
234
  onCopy={handleCopy}
226
235
  onPickAddress={handlePickAddress}
227
236
  onPickPublic={handlePickPublic}
237
+ onRevoke={handleRevoke}
228
238
  />
229
239
  </div>
230
240
  ), document.body)}