@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,269 @@
1
+ /**
2
+ * The remote desktop channel — browser half. On a non-loopback origin (LAN
3
+ * address or public tunnel) fenced host routes refuse the request, and
4
+ * pairing is the real access control — so same-origin traffic the desktop
5
+ * issues is rewritten onto this plugin's gated `/remote` prefix (host half
6
+ * in src/remote-api.ts). The host then re-issues the call to 127.0.0.1 so
7
+ * plugin loopback fences pass.
8
+ *
9
+ * The rewrite is deliberately narrow:
10
+ * - loopback origins are untouched (the desktop at 127.0.0.1 keeps original paths);
11
+ * - the pairing routes (`/api/pair/*`) stay where they are — accept must
12
+ * work BEFORE a device is paired;
13
+ * - the update endpoints (`/api/update/*`) stay loopback-only;
14
+ * - desktop-launcher create/shutdown (`/api/dsh-desktop-launcher/*`) stay
15
+ * loopback-only (host-local files and process exit);
16
+ * - the family settings bridge (`/api/dsh-web-ui-settings/*`) stays
17
+ * loopback-only (same plane as SDK settings.*);
18
+ * - `/api/*` (SDK methods and `/api/<plugin>/...` plugin namespaces),
19
+ * `/sidebar/*`, `/git/*`, and `/pet/*` ride the channel;
20
+ * - fetch, EventSource, WebSocket, and img/script/iframe `src` are patched;
21
+ * everything else calls the original unchanged.
22
+ *
23
+ * Pure helpers are exported for unit tests; `installRemoteChannel` patches
24
+ * the given window and returns their restore.
25
+ */
26
+
27
+ /** The gated mirror prefix (must match src/remote-methods.ts). */
28
+ export const REMOTE_PREFIX = '/remote'
29
+
30
+ /** Connection-plugin method prefix under the gated channel. */
31
+ export const REMOTE_API_PREFIX = `${REMOTE_PREFIX}/api`
32
+
33
+ const API_PREFIX = '/api/'
34
+ const PAIR_PREFIX = '/api/pair/'
35
+ const UPDATE_PREFIX = '/api/update/'
36
+ const DESKTOP_LAUNCHER_PREFIX = '/api/dsh-desktop-launcher'
37
+ const SETTINGS_BRIDGE_PREFIX = '/api/dsh-web-ui-settings'
38
+ const SIDEBAR_PREFIX = '/sidebar/'
39
+ const GIT_PREFIX = '/git/'
40
+ const PET_PREFIX = '/pet/'
41
+ const WS_PATHS = new Set([
42
+ '/api/events.mux',
43
+ '/api/events.host',
44
+ '/sidebar/ws/terminal',
45
+ '/sidebar/ws/agent-terminals',
46
+ '/api/dsh-ssh/terminal',
47
+ ])
48
+
49
+ /**
50
+ * Browser-safe loopback classification for the page origin (the SDK client
51
+ * exports its own; this copy keeps the module dependency-free).
52
+ * @param hostname - a location hostname (IPv6 without brackets).
53
+ * @returns true for localhost, IPv6 loopback, or any 127/8 literal.
54
+ */
55
+ export function isLoopbackHostname(hostname: string): boolean {
56
+ if (hostname === 'localhost' || hostname === '::1') return true
57
+ const parts = hostname.split('.')
58
+ return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
59
+ }
60
+
61
+ /**
62
+ * Whether one same-origin path must ride the gated channel (fetch, EventSource,
63
+ * img/script/iframe src).
64
+ * @param pathname - the request URL pathname.
65
+ */
66
+ export function shouldRewriteFetchPath(pathname: string): boolean {
67
+ if (pathname.startsWith(PAIR_PREFIX)) return false
68
+ if (pathname.startsWith(UPDATE_PREFIX)) return false
69
+ if (pathname === DESKTOP_LAUNCHER_PREFIX || pathname.startsWith(`${DESKTOP_LAUNCHER_PREFIX}/`)) return false
70
+ if (pathname === SETTINGS_BRIDGE_PREFIX || pathname.startsWith(`${SETTINGS_BRIDGE_PREFIX}/`)) return false
71
+ if (pathname.startsWith(API_PREFIX)) return true
72
+ if (pathname.startsWith(SIDEBAR_PREFIX) || pathname === '/sidebar') return true
73
+ if (pathname.startsWith(GIT_PREFIX) || pathname === '/git') return true
74
+ if (pathname.startsWith(PET_PREFIX) || pathname === '/pet') return true
75
+ return false
76
+ }
77
+
78
+ /**
79
+ * Whether one WebSocket path must ride the gated channel.
80
+ * @param pathname - the WebSocket URL pathname.
81
+ */
82
+ export function shouldRewriteWsPath(pathname: string): boolean {
83
+ return WS_PATHS.has(pathname)
84
+ }
85
+
86
+ /** The gated twin of one fenced path (`/remote` + original pathname). */
87
+ export function rewritePath(pathname: string): string {
88
+ return `${REMOTE_PREFIX}${pathname}`
89
+ }
90
+
91
+ /**
92
+ * Rewrite one raw URL string when it is same-origin and fenced. Relative
93
+ * inputs stay relative so resource loaders do not unexpectedly absolutize.
94
+ */
95
+ export function rewriteRawUrl(raw: string, baseHref: string, origin: string): string {
96
+ let url: URL
97
+ try {
98
+ url = new URL(raw, baseHref)
99
+ } catch {
100
+ return raw
101
+ }
102
+ if (url.origin !== origin) return raw
103
+ if (!shouldRewriteFetchPath(url.pathname)) return raw
104
+ url.pathname = rewritePath(url.pathname)
105
+ if (raw.startsWith('/') && !raw.startsWith('//')) {
106
+ return `${url.pathname}${url.search}${url.hash}`
107
+ }
108
+ return url.href
109
+ }
110
+
111
+ /** A constructor that exposes a configurable `src` on its prototype. */
112
+ interface SrcConstructor {
113
+ prototype: object
114
+ }
115
+
116
+ /** The subset of window the channel needs (injectable for tests). */
117
+ export interface ChannelWindow {
118
+ fetch: typeof globalThis.fetch
119
+ WebSocket: typeof WebSocket
120
+ EventSource?: typeof EventSource
121
+ HTMLImageElement?: SrcConstructor
122
+ HTMLScriptElement?: SrcConstructor
123
+ HTMLIFrameElement?: SrcConstructor
124
+ location: { origin: string; href: string }
125
+ }
126
+
127
+ /** Options for {@link installRemoteChannel}. */
128
+ export interface RemoteChannelOptions {
129
+ /** Called when a gated call came back unpaired (code `unpaired`). */
130
+ onUnpaired?: () => void
131
+ /** Called when a gated call succeeded (an unpaired banner can retire). */
132
+ onPaired?: () => void
133
+ }
134
+
135
+ /** Read an unpaired code from either the SDK envelope or a plugin JSON body. */
136
+ function unpairedCodeOf(value: unknown): string | undefined {
137
+ if (typeof value !== 'object' || value === null) return undefined
138
+ const record = value as { result?: unknown; error?: unknown }
139
+ const nested = record.result
140
+ if (typeof nested === 'object' && nested !== null) {
141
+ const error = (nested as { error?: unknown }).error
142
+ if (typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string') {
143
+ return (error as { code: string }).code
144
+ }
145
+ }
146
+ const error = record.error
147
+ if (typeof error === 'object' && error !== null && typeof (error as { code?: unknown }).code === 'string') {
148
+ return (error as { code: string }).code
149
+ }
150
+ return undefined
151
+ }
152
+
153
+ /**
154
+ * Whether a gated 403 is the unpaired-device fence (not a loopback-only
155
+ * method denial, which uses the same status with code `forbidden`).
156
+ */
157
+ export async function isUnpairedDenied(response: Response): Promise<boolean> {
158
+ if (response.status !== 403) return false
159
+ try {
160
+ return unpairedCodeOf(await response.json()) === 'unpaired'
161
+ } catch {
162
+ return false
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Wrap a prototype `src` setter so fenced same-origin URLs ride `/remote`.
168
+ * No-ops when the constructor is missing or `src` is not configurable.
169
+ */
170
+ function patchSrcAccessor(ctor: SrcConstructor | undefined, rewrite: (value: string) => string): () => void {
171
+ if (ctor === undefined) return () => {}
172
+ const descriptor = Object.getOwnPropertyDescriptor(ctor.prototype, 'src')
173
+ if (descriptor === undefined || descriptor.configurable === false) return () => {}
174
+ if (descriptor.set === undefined) return () => {}
175
+ const originalSet = descriptor.set
176
+ const originalGet = descriptor.get
177
+ Object.defineProperty(ctor.prototype, 'src', {
178
+ configurable: true,
179
+ enumerable: descriptor.enumerable ?? true,
180
+ get: originalGet,
181
+ set(this: unknown, value: unknown) {
182
+ originalSet.call(this, rewrite(String(value)))
183
+ },
184
+ })
185
+ return () => {
186
+ Object.defineProperty(ctor.prototype, 'src', descriptor)
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Patch `fetch`, `EventSource`, `WebSocket`, and resource `src` accessors on
192
+ * one window to route fenced traffic through the gated channel.
193
+ * @param window - the browser window (or a test double).
194
+ * @param options - the unpaired callback.
195
+ * @returns a function restoring the originals.
196
+ */
197
+ export function installRemoteChannel(window: ChannelWindow, options: RemoteChannelOptions = {}): () => void {
198
+ const originalFetch = window.fetch
199
+ const OriginalWebSocket = window.WebSocket
200
+ const OriginalEventSource = window.EventSource
201
+
202
+ const sameOrigin = (url: URL): boolean => url.origin === window.location.origin
203
+ const rewrite = (raw: string): string => rewriteRawUrl(raw, window.location.href, window.location.origin)
204
+
205
+ const patchedFetch: typeof globalThis.fetch = (input, init) => {
206
+ const url = new URL(
207
+ typeof input === 'string' || input instanceof URL ? input.toString() : input.url,
208
+ window.location.href,
209
+ )
210
+ if (sameOrigin(url) && shouldRewriteFetchPath(url.pathname)) {
211
+ const rewritten = new URL(url)
212
+ rewritten.pathname = rewritePath(url.pathname)
213
+ const next: RequestInfo | URL = typeof input === 'string' || input instanceof URL
214
+ ? rewritten.toString()
215
+ : new Request(rewritten, input)
216
+ return Promise.resolve(originalFetch.call(window, next, init)).then(async (response) => {
217
+ if (await isUnpairedDenied(response.clone())) options.onUnpaired?.()
218
+ else options.onPaired?.()
219
+ return response
220
+ })
221
+ }
222
+ return originalFetch.call(window, input, init)
223
+ }
224
+
225
+ class PatchedWebSocket extends OriginalWebSocket {
226
+ constructor(url: string | URL, protocols?: string | string[]) {
227
+ const parsed = new URL(url.toString(), window.location.href)
228
+ const wsOrigin = parsed.protocol === 'wss:' ? `https://${parsed.host}` : parsed.protocol === 'ws:' ? `http://${parsed.host}` : ''
229
+ if (wsOrigin !== '' && wsOrigin === window.location.origin && shouldRewriteWsPath(parsed.pathname)) {
230
+ const rewritten = new URL(parsed)
231
+ rewritten.pathname = rewritePath(parsed.pathname)
232
+ super(rewritten, protocols)
233
+ return
234
+ }
235
+ super(url, protocols)
236
+ }
237
+ }
238
+
239
+ const restoreSrc = [
240
+ patchSrcAccessor(window.HTMLImageElement, rewrite),
241
+ patchSrcAccessor(window.HTMLScriptElement, rewrite),
242
+ patchSrcAccessor(window.HTMLIFrameElement, rewrite),
243
+ ]
244
+
245
+ window.fetch = patchedFetch
246
+ window.WebSocket = PatchedWebSocket as typeof WebSocket
247
+ if (OriginalEventSource !== undefined) {
248
+ class PatchedEventSource extends OriginalEventSource {
249
+ constructor(url: string | URL, eventSourceInitDict?: EventSourceInit) {
250
+ const parsed = new URL(url.toString(), window.location.href)
251
+ if (sameOrigin(parsed) && shouldRewriteFetchPath(parsed.pathname)) {
252
+ const rewritten = new URL(parsed)
253
+ rewritten.pathname = rewritePath(parsed.pathname)
254
+ super(rewritten, eventSourceInitDict)
255
+ return
256
+ }
257
+ super(url, eventSourceInitDict)
258
+ }
259
+ }
260
+ window.EventSource = PatchedEventSource
261
+ }
262
+
263
+ return () => {
264
+ window.fetch = originalFetch
265
+ window.WebSocket = OriginalWebSocket
266
+ if (OriginalEventSource !== undefined) window.EventSource = OriginalEventSource
267
+ for (const restore of restoreSrc) restore()
268
+ }
269
+ }
@@ -267,6 +267,7 @@
267
267
  }
268
268
 
269
269
  .link {
270
+ display: block;
270
271
  margin: 0;
271
272
  overflow: hidden;
272
273
  text-overflow: ellipsis;
@@ -276,6 +277,55 @@
276
277
  font-size: 12px;
277
278
  }
278
279
 
280
+ .pairLinks {
281
+ display: flex;
282
+ flex-direction: column;
283
+ gap: 8px;
284
+ }
285
+
286
+ .pairLinkRow {
287
+ display: flex;
288
+ align-items: center;
289
+ gap: 10px;
290
+ min-width: 0;
291
+ padding: 10px 12px;
292
+ border: 1px solid var(--dsw-alias-border-l2);
293
+ border-radius: 10px;
294
+ background: var(--dsw-alias-bg-layer-1);
295
+ }
296
+
297
+ .pairLinkText {
298
+ min-width: 0;
299
+ flex: 1;
300
+ }
301
+
302
+ .pairLinkLabel {
303
+ display: block;
304
+ margin-bottom: 3px;
305
+ color: var(--dsw-alias-label-secondary);
306
+ font-size: 12px;
307
+ }
308
+
309
+ .copyLink {
310
+ display: inline-flex;
311
+ flex: none;
312
+ align-items: center;
313
+ gap: 5px;
314
+ min-height: 30px;
315
+ padding: 0 10px;
316
+ border: 1px solid var(--dsw-alias-border-l2);
317
+ border-radius: 8px;
318
+ background: var(--dsw-alias-button-elevated-fill);
319
+ color: var(--dsw-alias-label-primary);
320
+ cursor: pointer;
321
+ }
322
+
323
+ .oneTimeHint {
324
+ margin: 0;
325
+ color: var(--dsw-alias-label-caption);
326
+ font-size: 12px;
327
+ }
328
+
279
329
  .stoppedHint {
280
330
  margin: 0;
281
331
  color: var(--dsw-alias-state-error-primary);
@@ -353,32 +403,110 @@
353
403
  font-size: 13px;
354
404
  }
355
405
 
356
- /* Failed-pair toast: fixed top-center diagnostic on the phone. */
357
- .notice {
406
+ /* Unpaired remote desktop: opaque, blocking page above the shell. */
407
+ .fencePage {
358
408
  position: fixed;
359
- top: 16px;
360
- left: 50%;
361
- transform: translateX(-50%);
409
+ inset: 0;
362
410
  z-index: 2000;
363
- max-width: min(420px, calc(100vw - 32px));
411
+ display: flex;
412
+ flex-direction: column;
413
+ align-items: center;
414
+ justify-content: center;
415
+ overflow: auto;
364
416
  box-sizing: border-box;
365
- padding: 12px 16px;
417
+ padding: 40px 24px;
418
+ background: var(--dsw-alias-bg-base);
419
+ text-align: center;
420
+ }
421
+
422
+ .fenceCard {
423
+ width: min(520px, 100%);
424
+ margin-inline: auto;
425
+ box-sizing: border-box;
426
+ padding: 36px 40px;
366
427
  border: 1px solid var(--dsw-alias-border-l2);
367
- border-radius: 12px;
368
- background: var(--dsw-alias-bg-layer-2);
428
+ border-radius: 20px;
429
+ background: var(--dsw-alias-bg-layer-1);
369
430
  box-shadow: var(--dsw-shadow-lv3);
431
+ text-align: center;
370
432
  }
371
433
 
372
- .noticeTitle {
373
- margin: 0;
434
+ .fenceMark {
435
+ display: grid;
436
+ place-items: center;
437
+ width: 44px;
438
+ height: 44px;
439
+ margin-inline: auto;
440
+ border-radius: 50%;
441
+ background: var(--dsw-alias-state-error-secondary);
374
442
  color: var(--dsw-alias-state-error-primary);
375
- font-weight: 500;
443
+ font-size: 24px;
444
+ line-height: 1;
376
445
  }
377
446
 
378
- .noticeDetail {
379
- margin: 4px 0 0;
380
- color: var(--dsw-alias-label-secondary);
447
+ .fenceEyebrow {
448
+ margin: 22px 0 8px;
449
+ color: var(--dsw-alias-state-error-primary);
381
450
  font-size: 13px;
451
+ font-weight: 600;
452
+ }
453
+
454
+ .fenceTitle {
455
+ margin: 0;
456
+ color: var(--dsw-alias-label-primary);
457
+ font-size: 24px;
458
+ line-height: 1.35;
459
+ }
460
+
461
+ .fenceDetail {
462
+ margin: 12px 0 0;
463
+ color: var(--dsw-alias-label-secondary);
464
+ font-size: 14px;
465
+ line-height: 1.65;
466
+ }
467
+
468
+ .fenceSteps {
469
+ margin: 24px auto 0;
470
+ padding: 20px 20px 20px 42px;
471
+ border-radius: 12px;
472
+ background: var(--dsw-alias-bg-layer-2);
473
+ color: var(--dsw-alias-label-primary);
474
+ font-size: 14px;
475
+ line-height: 1.65;
476
+ text-align: left;
477
+ }
478
+
479
+ .fenceSteps li + li {
480
+ margin-top: 8px;
481
+ }
482
+
483
+ .fenceRetry {
484
+ width: 100%;
485
+ margin-top: 24px;
486
+ padding: 11px 16px;
487
+ border: 1px solid var(--dsw-alias-brand-primary);
488
+ border-radius: 10px;
489
+ background: var(--dsw-alias-brand-primary);
490
+ color: #fff;
491
+ font: inherit;
492
+ font-weight: 500;
493
+ cursor: pointer;
494
+ }
495
+
496
+ .fenceRetry:hover {
497
+ filter: brightness(0.96);
498
+ }
499
+
500
+ .fenceRetry:focus-visible {
501
+ outline: 2px solid var(--dsw-alias-brand-primary);
502
+ outline-offset: 3px;
503
+ }
504
+
505
+ .fenceFootnote {
506
+ margin: 14px 0 0;
507
+ color: var(--dsw-alias-label-tertiary);
508
+ font-size: 12px;
509
+ line-height: 1.55;
382
510
  }
383
511
 
384
512
  /* Multi-homed address picker: one radio per constructible LAN literal. */
@@ -434,6 +562,101 @@
434
562
  font-size: 12px;
435
563
  }
436
564
 
565
+ .devices {
566
+ display: flex;
567
+ flex-direction: column;
568
+ gap: 8px;
569
+ padding: 12px 16px 14px;
570
+ border: 1px solid var(--dsw-alias-border-l2);
571
+ border-radius: 16px;
572
+ background: var(--dsw-alias-bg-layer-1);
573
+ }
574
+
575
+ .devicesTitle {
576
+ margin: 0;
577
+ font-size: 13px;
578
+ font-weight: 500;
579
+ line-height: 20px;
580
+ }
581
+
582
+ .devicesEmpty {
583
+ margin: 0;
584
+ color: var(--dsw-alias-label-secondary);
585
+ font-size: 13px;
586
+ }
587
+
588
+ .deviceList {
589
+ margin: 0;
590
+ padding: 0;
591
+ list-style: none;
592
+ display: flex;
593
+ flex-direction: column;
594
+ gap: 8px;
595
+ }
596
+
597
+ .deviceRow {
598
+ display: flex;
599
+ align-items: flex-start;
600
+ justify-content: space-between;
601
+ gap: 12px;
602
+ }
603
+
604
+ .deviceMeta {
605
+ min-width: 0;
606
+ display: flex;
607
+ flex-direction: column;
608
+ gap: 2px;
609
+ }
610
+
611
+ .deviceName {
612
+ overflow: hidden;
613
+ text-overflow: ellipsis;
614
+ white-space: nowrap;
615
+ font-size: 13px;
616
+ font-weight: 500;
617
+ }
618
+
619
+ .devicePresence {
620
+ font-size: 12px;
621
+ line-height: 18px;
622
+ }
623
+
624
+ .deviceOnline {
625
+ color: var(--dsw-alias-state-success-primary);
626
+ }
627
+
628
+ .deviceOffline {
629
+ color: var(--dsw-alias-label-secondary);
630
+ }
631
+
632
+ .deviceSeen {
633
+ color: var(--dsw-alias-label-tertiary);
634
+ font-size: 12px;
635
+ font-variant-numeric: tabular-nums;
636
+ }
637
+
638
+ .deviceRevoke {
639
+ flex: none;
640
+ border: none;
641
+ border-radius: 8px;
642
+ padding: 6px 10px;
643
+ background: transparent;
644
+ color: var(--dsw-alias-label-secondary);
645
+ font-size: 12px;
646
+ cursor: pointer;
647
+ transition: background-color 120ms ease, color 120ms ease;
648
+ }
649
+
650
+ .deviceRevoke:hover:not(:disabled) {
651
+ background: var(--dsw-alias-interactive-bg-hover);
652
+ color: var(--dsw-alias-label-primary);
653
+ }
654
+
655
+ .deviceRevoke:focus-visible {
656
+ outline: none;
657
+ box-shadow: 0 0 0 2px var(--dsw-alias-bg-layer-2), 0 0 0 4px var(--dsw-alias-brand-primary);
658
+ }
659
+
437
660
  /* Accessibility: collapse the interaction transitions for reduced-motion
438
661
  users. The panel chrome is already static; this just disables the
439
662
  hover/active/focus cross-fades added above. */
@@ -441,7 +664,8 @@
441
664
  .trigger,
442
665
  .close,
443
666
  .action,
444
- .address {
667
+ .address,
668
+ .deviceRevoke {
445
669
  transition: none;
446
670
  }
447
671
  }
@@ -0,0 +1,36 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/dsh-home.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * DSH_HOME resolution shared by the plugin family's Host halves: the
4
+ * environment override wins, the platform home fallback follows. Mirrors
5
+ * what dsh-pet and dsh-liangshen each used to implement locally.
6
+ */
7
+
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join } from 'node:path'
10
+
11
+ /** Expand a leading ~ (or ~user) in a path, platform-style. */
12
+ export function expandHome(path: string, home: string = homedir()): string {
13
+ if (path === '~') return home
14
+ if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2))
15
+ return path
16
+ }
17
+
18
+ /**
19
+ * Resolve the DSH home directory.
20
+ * @param env - process environment to read DSH_HOME from.
21
+ * @param home - platform home directory fallback (test seam).
22
+ * @returns the absolute DSH home path.
23
+ */
24
+ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: string = homedir()): string {
25
+ const raw = env.DSH_HOME
26
+ if (raw !== undefined && raw.trim() !== '') {
27
+ const expanded = expandHome(raw.trim(), home)
28
+ return isAbsolute(expanded) ? expanded : join(process.cwd(), expanded)
29
+ }
30
+ return join(home, '.dsh')
31
+ }
32
+
33
+ /** Resolve the DSH home directory from the live environment. */
34
+ export function dshHome(): string {
35
+ return resolveDshHome()
36
+ }
package/src/gate.ts CHANGED
@@ -84,13 +84,16 @@ export function makeGateListener(
84
84
  enabled: boolean | (() => boolean) = true,
85
85
  ): (request: IncomingMessage, method: string | undefined, next: () => boolean | Promise<boolean>) => boolean | Promise<boolean> {
86
86
  return (request, _method, next) => {
87
+ // Loopback clients (desktop at 127.0.0.1) always pass without cookie check.
87
88
  if (isLoopbackClient(request)) return next()
88
89
  const active = typeof enabled === 'function' ? enabled() : enabled
89
90
  if (!active) return false
90
91
  const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
92
+ // When pairing is not required for LAN, non-loopback requests pass too.
91
93
  if (!require) return next()
92
- // The paired-device cookie is a full host-API credential: it passes this
93
- // gate for the whole ApiProxy surface, not just the /m/api allowlist.
94
+ // Non-loopback origin: verify paired-device cookie. Sibling plugins that
95
+ // own their own prefixes (/pet, /git, /sidebar, …) consult the same cookie
96
+ // through the remoteWebUiPairing service — this listener only covers /api.
94
97
  return isPairedDeviceRequest(service, request) ? next() : false
95
98
  }
96
99
  }