@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +41 -19
  3. package/README.zh.md +17 -12
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +37 -10
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +290 -53
  10. package/lib/mobile.js +278 -17
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +6 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +5 -3
  15. package/lib/types/client/RemoteEntry.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  17. package/lib/types/client/RemoteSettingsCard.js +1 -1
  18. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  19. package/lib/types/client/UpdateEntry.js +36 -3
  20. package/lib/types/client/deep-link.js +1 -1
  21. package/lib/types/client/locales.d.ts +2 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +2 -0
  24. package/lib/types/gate.d.ts +9 -0
  25. package/lib/types/gate.d.ts.map +1 -1
  26. package/lib/types/gate.js +15 -4
  27. package/lib/types/index.d.ts +25 -0
  28. package/lib/types/index.d.ts.map +1 -1
  29. package/lib/types/index.js +30 -13
  30. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  31. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  32. package/lib/types/mobile/PairRequiredView.js +28 -0
  33. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  34. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  35. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  36. package/lib/types/mobile/index.js +14 -1
  37. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  38. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  39. package/lib/types/mobile/mobile-styles.js +48 -0
  40. package/lib/types/mobile/pairing.d.ts +31 -0
  41. package/lib/types/mobile/pairing.d.ts.map +1 -0
  42. package/lib/types/mobile/pairing.js +62 -0
  43. package/lib/types/mobile/views/App.d.ts +12 -5
  44. package/lib/types/mobile/views/App.d.ts.map +1 -1
  45. package/lib/types/mobile/views/App.js +34 -7
  46. package/lib/types/mobile/views/App.test.d.ts +2 -0
  47. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  48. package/lib/types/mobile/views/App.test.js +52 -0
  49. package/lib/types/mobile-pwa.d.ts +6 -0
  50. package/lib/types/mobile-pwa.d.ts.map +1 -0
  51. package/lib/types/mobile-pwa.js +17 -0
  52. package/lib/types/mobile-routes.d.ts +5 -8
  53. package/lib/types/mobile-routes.d.ts.map +1 -1
  54. package/lib/types/mobile-routes.js +115 -30
  55. package/lib/types/pairing-access.d.ts +28 -0
  56. package/lib/types/pairing-access.d.ts.map +1 -0
  57. package/lib/types/pairing-access.js +26 -0
  58. package/lib/types/pairing.d.ts +27 -0
  59. package/lib/types/pairing.d.ts.map +1 -1
  60. package/lib/types/pairing.js +68 -0
  61. package/lib/types/pairing.persist.test.d.ts +2 -0
  62. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  63. package/lib/types/pairing.persist.test.js +128 -0
  64. package/lib/types/routes.js +1 -1
  65. package/lib/types/update.d.ts +3 -3
  66. package/lib/types/update.d.ts.map +1 -1
  67. package/lib/types/update.js +34 -11
  68. package/package.json +12 -12
  69. package/src/client/PluginSettingsCard.tsx +10 -0
  70. package/src/client/RemoteEntry.tsx +2 -2
  71. package/src/client/RemoteSettingsCard.tsx +1 -0
  72. package/src/client/UpdateEntry.tsx +36 -4
  73. package/src/client/deep-link.ts +1 -1
  74. package/src/client/locales.ts +2 -0
  75. package/src/client/remote.module.css +26 -0
  76. package/src/gate.ts +15 -3
  77. package/src/index.ts +46 -15
  78. package/src/mobile/PairRequiredView.test.tsx +28 -0
  79. package/src/mobile/PairRequiredView.tsx +58 -0
  80. package/src/mobile/index.tsx +15 -1
  81. package/src/mobile/mobile-styles.ts +48 -0
  82. package/src/mobile/pairing.ts +77 -0
  83. package/src/mobile/views/App.test.tsx +62 -0
  84. package/src/mobile/views/App.tsx +53 -6
  85. package/src/mobile-pwa.ts +19 -0
  86. package/src/mobile-routes.ts +123 -30
  87. package/src/pairing-access.ts +36 -0
  88. package/src/pairing.persist.test.ts +145 -0
  89. package/src/pairing.ts +70 -1
  90. package/src/routes.ts +1 -1
  91. package/src/update.ts +40 -12
@@ -1,24 +1,26 @@
1
1
  /**
2
- * The mobile surface's page routes: `/m` serves the standalone phone UI
3
- * (an independent bundle, built to lib/mobile.js by the mobile tsdown
4
- * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
5
- * host exclusively through the shared /api transport (paired-device cookie
6
- * already crosses the api/gate fence), so no host-side data plumbing is
7
- * needed here — only static serving, loopback+paired-fence via the normal
8
- * webserver route registration.
2
+ * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
3
+ * standalone phone UI. The page and worker live in the /m/ scope so the PWA
4
+ * can cache only its static shell; the paired-device data channel remains at
5
+ * /m/api and is never handled by the worker.
9
6
  */
10
7
  import { readFile } from 'node:fs/promises';
11
8
  import { existsSync } from 'node:fs';
12
9
  import { fileURLToPath } from 'node:url';
10
+ const MOBILE_ROOT = '/m/';
11
+ const MOBILE_BUNDLE_URL = '/m/mobile.js';
12
+ const MOBILE_MANIFEST_URL = '/m/manifest.webmanifest';
13
+ const MOBILE_WORKER_URL = '/m/service-worker.js';
14
+ const MOBILE_OFFLINE_URL = '/m/offline.html';
13
15
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
14
16
  function mobileBundlePath() {
15
17
  return fileURLToPath(new URL('../lib/mobile.js', import.meta.url));
16
18
  }
17
- /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
18
- function appleTouchIconPath() {
19
- return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url));
19
+ /** A package asset available to the host after a registry or git install. */
20
+ function mobileAssetPath(name) {
21
+ return fileURLToPath(new URL('../assets/' + name, import.meta.url));
20
22
  }
21
- /** The mobile page shell: minimal, offline-safe, no external assets. */
23
+ /** The mobile page shell: self-contained, with no document-level user data. */
22
24
  function pageHtml(bundleUrl) {
23
25
  return [
24
26
  '<!doctype html>',
@@ -28,29 +30,73 @@ function pageHtml(bundleUrl) {
28
30
  '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
29
31
  '<meta name="theme-color" content="#f3f5f9">',
30
32
  '<meta name="referrer" content="no-referrer">',
31
- // iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
32
- // the PNG ships with the package (opaque background — iOS composites
33
- // transparency onto black, so the icon avoids transparent regions).
33
+ '<meta name="mobile-web-app-capable" content="yes">',
34
+ '<meta name="apple-mobile-web-app-capable" content="yes">',
35
+ '<meta name="apple-mobile-web-app-title" content="DSH Remote">',
36
+ '<link rel="manifest" href="/m/manifest.webmanifest">',
34
37
  '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
35
38
  '<title>移动端远程控制</title>',
36
39
  '</head>',
37
40
  '<body>',
38
41
  '<div id="root"></div>',
39
- `<script type="module" src="${bundleUrl}"></script>`,
42
+ '<script type="module" src="' + bundleUrl + '"></script>',
40
43
  '</body>',
41
44
  '</html>',
42
45
  ].join('');
43
46
  }
44
- /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
47
+ /** The installable app identity and icon declarations for the /m scope. */
48
+ function manifestJson() {
49
+ return JSON.stringify({
50
+ id: MOBILE_ROOT,
51
+ name: 'DSH Remote',
52
+ short_name: 'DSH Remote',
53
+ start_url: MOBILE_ROOT,
54
+ scope: MOBILE_ROOT,
55
+ display: 'standalone',
56
+ background_color: '#151424',
57
+ theme_color: '#f3f5f9',
58
+ icons: [
59
+ { src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
60
+ { src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
61
+ ],
62
+ }, null, 2);
63
+ }
64
+ /** A static fallback that contains no paired-device, session, or workspace data. */
65
+ function offlineHtml() {
66
+ return [
67
+ '<!doctype html>',
68
+ '<html lang="zh-CN">',
69
+ '<head>',
70
+ '<meta charset="utf-8">',
71
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
72
+ '<meta name="theme-color" content="#151424">',
73
+ '<title>DSH Remote</title>',
74
+ '<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>',
75
+ '</head>',
76
+ '<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>',
77
+ '</html>',
78
+ ].join('');
79
+ }
80
+ /** Send a small static UTF-8 body with revalidation headers. */
45
81
  function writeStatic(res, status, type, body) {
46
82
  res.writeHead(status, {
47
- 'content-type': `${type}; charset=utf-8`,
83
+ 'content-type': type + '; charset=utf-8',
84
+ 'cache-control': 'no-cache',
85
+ 'referrer-policy': 'no-referrer',
86
+ });
87
+ res.end(body);
88
+ }
89
+ /** Send the worker with the same scope it registers for, without HTTP caching. */
90
+ function writeServiceWorker(res, body) {
91
+ res.writeHead(200, {
92
+ 'content-type': 'text/javascript; charset=utf-8',
48
93
  'cache-control': 'no-cache',
49
94
  'referrer-policy': 'no-referrer',
95
+ 'service-worker-allowed': MOBILE_ROOT,
50
96
  });
51
97
  res.end(body);
52
98
  }
53
- /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
99
+ /** Send a PNG body without decoding it to UTF-8. */
54
100
  function writePng(res, status, body) {
55
101
  res.writeHead(status, {
56
102
  'content-type': 'image/png',
@@ -59,16 +105,32 @@ function writePng(res, status, body) {
59
105
  });
60
106
  res.end(body);
61
107
  }
108
+ /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
109
+ function redirectToMobileRoot(req, res) {
110
+ const url = new URL(req.url ?? '/m', 'http://x');
111
+ res.writeHead(308, {
112
+ location: MOBILE_ROOT + url.search,
113
+ 'cache-control': 'no-store',
114
+ 'referrer-policy': 'no-referrer',
115
+ });
116
+ res.end();
117
+ }
62
118
  /**
63
119
  * Build the mobile page routes.
64
- * @returns the two exact routes to register on webServer.
120
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
65
121
  */
66
122
  export function makeMobileRoutes() {
67
123
  const handlePage = (_req, res) => {
68
- writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'));
124
+ writeStatic(res, 200, 'text/html', pageHtml(MOBILE_BUNDLE_URL));
125
+ };
126
+ const handleManifest = (_req, res) => {
127
+ writeStatic(res, 200, 'application/manifest+json', manifestJson());
128
+ };
129
+ const handleOffline = (_req, res) => {
130
+ writeStatic(res, 200, 'text/html', offlineHtml());
69
131
  };
70
132
  // The bundle is immutable for the process lifetime (a rebuild requires a
71
- // host restart), so the ~456KB body is read from disk once, not per phone.
133
+ // host restart), so the body is read from disk once, not per phone.
72
134
  let bundleBody;
73
135
  const handleBundle = async (_req, res) => {
74
136
  if (bundleBody === undefined) {
@@ -87,23 +149,46 @@ export function makeMobileRoutes() {
87
149
  }
88
150
  writeStatic(res, 200, 'text/javascript', bundleBody);
89
151
  };
90
- const handleIcon = async (_req, res) => {
91
- const path = appleTouchIconPath();
152
+ let workerBody;
153
+ const handleWorker = async (_req, res) => {
154
+ if (workerBody === undefined) {
155
+ const path = mobileAssetPath('mobile-service-worker.js');
156
+ if (!existsSync(path)) {
157
+ writeStatic(res, 503, 'text/plain', 'mobile service worker not found');
158
+ return;
159
+ }
160
+ try {
161
+ workerBody = await readFile(path, 'utf8');
162
+ }
163
+ catch {
164
+ writeStatic(res, 500, 'text/plain', 'failed to read the mobile service worker');
165
+ return;
166
+ }
167
+ }
168
+ writeServiceWorker(res, workerBody);
169
+ };
170
+ const makeIconHandler = (asset) => async (_req, res) => {
171
+ const path = mobileAssetPath(asset);
92
172
  if (!existsSync(path)) {
93
- writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found');
173
+ writeStatic(res, 404, 'text/plain', asset + ' not found');
94
174
  return;
95
175
  }
96
176
  try {
97
- const body = await readFile(path);
98
- writePng(res, 200, body);
177
+ writePng(res, 200, await readFile(path));
99
178
  }
100
179
  catch {
101
- writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon');
180
+ writeStatic(res, 500, 'text/plain', 'failed to read ' + asset);
102
181
  }
103
182
  };
104
183
  return [
105
- { kind: 'exact', path: '/m', handler: handlePage },
106
- { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
107
- { kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
184
+ { kind: 'exact', path: '/m', handler: redirectToMobileRoot },
185
+ { kind: 'exact', path: MOBILE_ROOT, handler: handlePage },
186
+ { kind: 'exact', path: MOBILE_BUNDLE_URL, handler: handleBundle },
187
+ { kind: 'exact', path: MOBILE_MANIFEST_URL, handler: handleManifest },
188
+ { kind: 'exact', path: MOBILE_WORKER_URL, handler: handleWorker },
189
+ { kind: 'exact', path: MOBILE_OFFLINE_URL, handler: handleOffline },
190
+ { kind: 'exact', path: '/m/apple-touch-icon.png', handler: makeIconHandler('apple-touch-icon.png') },
191
+ { kind: 'exact', path: '/m/icon-192.png', handler: makeIconHandler('mobile-icon-192.png') },
192
+ { kind: 'exact', path: '/m/icon-512.png', handler: makeIconHandler('mobile-icon-512.png') },
108
193
  ];
109
194
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Optional cordis service other host plugins can ctx.get without taking a
3
+ * package dependency: a live paired-device cookie is an allow path on
4
+ * routes that sit outside /api (and therefore outside api/gate).
5
+ */
6
+ import type { IncomingMessage } from 'node:http';
7
+ import { Service, type Context } from '@deepseek-ai/cordis';
8
+ /** Named lookup key sibling plugins pass to ctx.get. */
9
+ export declare const REMOTE_WEB_UI_PAIRING = "remoteWebUiPairing";
10
+ /**
11
+ * Pairing identity for one HTTP request. Structural: consumers must not
12
+ * import this class, only the method shape.
13
+ */
14
+ export declare class RemoteWebUiPairing extends Service {
15
+ private readonly check;
16
+ /**
17
+ * @param ctx - host plugin context.
18
+ * @param check - live cookie + session predicate (re-read per request).
19
+ */
20
+ constructor(ctx: Context, check: (request: IncomingMessage) => boolean);
21
+ /**
22
+ * Whether the request carries a live paired-device cookie.
23
+ * @param request - the incoming HTTP request.
24
+ * @returns true when the session is live and was refreshed.
25
+ */
26
+ isPairedDevice(request: IncomingMessage): boolean;
27
+ }
28
+ //# sourceMappingURL=pairing-access.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing-access.d.ts","sourceRoot":"","sources":["../../src/pairing-access.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,WAAW,CAAA;AAChD,OAAO,EAAE,OAAO,EAAE,KAAK,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAE3D,wDAAwD;AACxD,eAAO,MAAM,qBAAqB,uBAAuB,CAAA;AAEzD;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,OAAO;IAO3C,OAAO,CAAC,QAAQ,CAAC,KAAK;IANxB;;;OAGG;IACH,YACE,GAAG,EAAE,OAAO,EACK,KAAK,EAAE,CAAC,OAAO,EAAE,eAAe,KAAK,OAAO,EAG9D;IAED;;;;OAIG;IACH,cAAc,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAEhD;CACF"}
@@ -0,0 +1,26 @@
1
+ import { Service } from '@deepseek-ai/cordis';
2
+ /** Named lookup key sibling plugins pass to ctx.get. */
3
+ export const REMOTE_WEB_UI_PAIRING = 'remoteWebUiPairing';
4
+ /**
5
+ * Pairing identity for one HTTP request. Structural: consumers must not
6
+ * import this class, only the method shape.
7
+ */
8
+ export class RemoteWebUiPairing extends Service {
9
+ check;
10
+ /**
11
+ * @param ctx - host plugin context.
12
+ * @param check - live cookie + session predicate (re-read per request).
13
+ */
14
+ constructor(ctx, check) {
15
+ super(ctx, REMOTE_WEB_UI_PAIRING);
16
+ this.check = check;
17
+ }
18
+ /**
19
+ * Whether the request carries a live paired-device cookie.
20
+ * @param request - the incoming HTTP request.
21
+ * @returns true when the session is live and was refreshed.
22
+ */
23
+ isPairedDevice(request) {
24
+ return this.check(request);
25
+ }
26
+ }
@@ -87,6 +87,13 @@ export interface PairingConfig {
87
87
  maxDevices: number;
88
88
  /** Cookie name carrying the device id. */
89
89
  cookieName: string;
90
+ /**
91
+ * Path to a JSON file where paired device sessions are persisted. When
92
+ * set, sessions survive process restarts (the phone keeps its 365-day
93
+ * cookie), so re-pairing after a dsh web restart is not required. When
94
+ * unset (default), sessions stay memory-only — the previous behavior.
95
+ */
96
+ devicesFile?: string;
90
97
  }
91
98
  /** Result of one accept() attempt. */
92
99
  export type AcceptResult = {
@@ -138,6 +145,26 @@ export declare class PairingService {
138
145
  * @param clock - clock/entropy source (injectable for tests).
139
146
  */
140
147
  constructor(config: PairingConfig, clock?: PairingClock);
148
+ /**
149
+ * Restore device sessions persisted by a previous process run. A corrupt
150
+ * or missing file is tolerated (an empty device table, never a throw) —
151
+ * persistence is an availability convenience, not a security boundary.
152
+ */
153
+ private loadPersisted;
154
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
155
+ private clampToMaxDevices;
156
+ /**
157
+ * Write the current device table to the configured file. Called on the
158
+ * mutation boundaries that change the set of live sessions (accept and
159
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
160
+ * per-request write storm is avoided — after a restart the phone's first
161
+ * heartbeat re-warms its own session.
162
+ *
163
+ * Device ids are session credentials (the gate authorizes requests by the
164
+ * cookie's device id), so the file is written 0600 via a temp file and
165
+ * atomic rename; a crash mid-write can never leave a half-written store.
166
+ */
167
+ private persist;
141
168
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
142
169
  get lanBaseUrl(): string | undefined;
143
170
  /** The LAN base URL for one specific literal (undefined when not constructible). */
@@ -1 +1 @@
1
- {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/pairing.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,uDAAuD;AACvD,MAAM,MAAM,YAAY;AACtB,mEAAmE;AACjE,cAAc;AAChB,oEAAoE;GAClE,SAAS;AACX,4DAA4D;GAC1D,SAAS;AACX,sDAAsD;GACpD,WAAW;AACb,6EAA6E;GAC3E,cAAc,CAAA;AAElB,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAA;IACjB,iDAAiD;IACjD,QAAQ,EAAE,OAAO,CAAA;IACjB,sFAAsF;IACtF,EAAE,EAAE,MAAM,CAAA;IACV,iEAAiE;IACjE,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAED,8EAA8E;AAC9E,MAAM,WAAW,aAAa;IAC5B,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAA;IACjB,gEAAgE;IAChE,UAAU,EAAE,MAAM,CAAA;CACnB;AAED,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,gFAAgF;IAChF,KAAK,EAAE,UAAU,GAAG,SAAS,GAAG,QAAQ,CAAA;IACxC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,YAAY,CAAA;IACnB,yEAAyE;IACzE,YAAY,EAAE,OAAO,CAAA;IACrB,oEAAoE;IACpE,YAAY,EAAE,MAAM,EAAE,CAAA;IACtB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,wFAAwF;IACxF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2CAA2C;IAC3C,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAA;IACnB,yDAAyD;IACzD,WAAW,EAAE,MAAM,CAAA;CACpB;AAED,wEAAwE;AACxE,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAA;IAClB,oEAAoE;IACpE,cAAc,EAAE,MAAM,CAAA;IACtB,qEAAqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,sCAAsC;AACtC,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC9B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,SAAS,GAAG,MAAM,CAAA;CAAE,CAAA;AAE3C,yEAAyE;AACzE,qBAAa,sBAAuB,SAAQ,KAAK;IAC/C;;OAEG;IACH,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,YAAY;IAC3B,GAAG,IAAI,MAAM,CAAA;IACb,WAAW,IAAI,MAAM,CAAA;CACtB;AAED,yDAAyD;AACzD,eAAO,MAAM,YAAY,EAAE,YAG1B,CAAA;AAED;;;;;GAKG;AACH,qBAAa,cAAc;IAqBhB,MAAM,EAAE,aAAa;IAC5B,OAAO,CAAC,QAAQ,CAAC,KAAK;IArBxB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiC;IACxD,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAmC;IAC3D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiD;IAC3E,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,OAAO,CAAQ;IACvB,OAAO,CAAC,WAAW,CAAI;IACvB,0EAA0E;IAC1E,OAAO,CAAC,QAAQ,CAA4B;IAC5C,sEAAsE;IACtE,OAAO,CAAC,UAAU,CAAoB;IACtC,mEAAmE;IACnE,OAAO,CAAC,YAAY,CAA0B;IAE9C;;;;;OAKG;IACH,YACS,MAAM,EAAE,aAAa,EACX,KAAK,GAAE,YAA2B,EAGpD;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAmBrB,8EAA8E;IAC9E,OAAO,CAAC,iBAAiB;IAOzB;;;;;;;;;;OAUG;IACH,OAAO,CAAC,OAAO;IAaf,wFAAwF;IACxF,IAAI,UAAU,IAAI,MAAM,GAAG,SAAS,CAEnC;IAED,oFAAoF;IACpF,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEjD;IAED,wEAAwE;IACxE,IAAI,YAAY,IAAI,MAAM,EAAE,CAE3B;IAED,6EAA6E;IAC7E,WAAW,CAAC,OAAO,EAAE,SAAS;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,GAAG,IAAI,CAGvE;IAED,+DAA+D;IAC/D,IAAI,aAAa,IAAI,MAAM,GAAG,SAAS,CAEtC;IAED,2EAA2E;IAC3E,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAG9C;IAED,qFAAqF;IACrF,eAAe,CAAC,MAAM,EAAE,YAAY,GAAG,SAAS,GAAG,IAAI,CAGtD;IAED;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAsBlF;IAED;;;;;;OAMG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,YAAY,CAoBlC;IAED;;;;OAIG;IACH,IAAI,IAAI,IAAI,CAMX;IAED;;;;;;OAMG;IACH,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMrC;IAED,oEAAoE;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEnC;IAED;;;;OAIG;IACH,KAAK,IAAI,IAAI,CAEZ;IAED,2EAA2E;IAC3E,QAAQ,IAAI,eAAe,CAc1B;IAED,oEAAoE;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGnC;IAED,yEAAyE;IACzE,OAAO,CAAC,QAAQ,EAAE,CAAC,QAAQ,EAAE,eAAe,KAAK,IAAI,GAAG,MAAM,IAAI,CAGjE;IAED,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,WAAW;IASnB,OAAO,CAAC,UAAU;IAIlB,OAAO,CAAC,MAAM;CAaf"}
@@ -15,6 +15,8 @@
15
15
  * devices are cut off on their next gated request.
16
16
  */
17
17
  import { randomBytes } from 'node:crypto';
18
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
19
+ import { dirname } from 'node:path';
18
20
  /** Thrown by issue() for an address outside the sampled LAN literals. */
19
21
  export class UnknownLanAddressError extends Error {
20
22
  /**
@@ -60,6 +62,70 @@ export class PairingService {
60
62
  constructor(config, clock = defaultClock) {
61
63
  this.config = config;
62
64
  this.clock = clock;
65
+ this.loadPersisted();
66
+ }
67
+ /**
68
+ * Restore device sessions persisted by a previous process run. A corrupt
69
+ * or missing file is tolerated (an empty device table, never a throw) —
70
+ * persistence is an availability convenience, not a security boundary.
71
+ */
72
+ loadPersisted() {
73
+ const file = this.config.devicesFile;
74
+ if (file === undefined)
75
+ return;
76
+ try {
77
+ const saved = JSON.parse(readFileSync(file, 'utf8'));
78
+ if (typeof saved !== 'object' || saved === null)
79
+ return;
80
+ for (const [deviceId, session] of Object.entries(saved)) {
81
+ if (typeof deviceId !== 'string')
82
+ continue;
83
+ if (typeof session !== 'object' || session === null)
84
+ continue;
85
+ const { createdAt, lastSeenAt } = session;
86
+ if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number')
87
+ continue;
88
+ this.devices.set(deviceId, { createdAt, lastSeenAt });
89
+ }
90
+ this.clampToMaxDevices();
91
+ }
92
+ catch {
93
+ // Unreadable/corrupt: start empty rather than refusing to boot.
94
+ }
95
+ }
96
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
97
+ clampToMaxDevices() {
98
+ if (this.devices.size <= this.config.maxDevices)
99
+ return;
100
+ const overflow = this.devices.size - this.config.maxDevices;
101
+ const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
102
+ for (const [id] of ordered.slice(0, overflow))
103
+ this.devices.delete(id);
104
+ }
105
+ /**
106
+ * Write the current device table to the configured file. Called on the
107
+ * mutation boundaries that change the set of live sessions (accept and
108
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
109
+ * per-request write storm is avoided — after a restart the phone's first
110
+ * heartbeat re-warms its own session.
111
+ *
112
+ * Device ids are session credentials (the gate authorizes requests by the
113
+ * cookie's device id), so the file is written 0600 via a temp file and
114
+ * atomic rename; a crash mid-write can never leave a half-written store.
115
+ */
116
+ persist() {
117
+ const file = this.config.devicesFile;
118
+ if (file === undefined)
119
+ return;
120
+ try {
121
+ mkdirSync(dirname(file), { recursive: true });
122
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
123
+ writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 });
124
+ renameSync(temp, file);
125
+ }
126
+ catch (error) {
127
+ console.error('remote-web-ui: failed to persist paired devices', error);
128
+ }
63
129
  }
64
130
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
65
131
  get lanBaseUrl() {
@@ -153,6 +219,7 @@ export class PairingService {
153
219
  this.devices.delete(oldest.id);
154
220
  }
155
221
  this.devices.set(deviceId, { createdAt: now, lastSeenAt: now });
222
+ this.persist();
156
223
  this.notify();
157
224
  return { ok: true, deviceId };
158
225
  }
@@ -164,6 +231,7 @@ export class PairingService {
164
231
  stop() {
165
232
  this.tokens.clear();
166
233
  this.devices.clear();
234
+ this.persist();
167
235
  this.stopped = true;
168
236
  this.notify();
169
237
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=pairing.persist.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pairing.persist.test.d.ts","sourceRoot":"","sources":["../../src/pairing.persist.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,128 @@
1
+ /**
2
+ * PairingService device-session persistence: with `devicesFile` set, sessions
3
+ * written on accept/stop survive a process restart (a new PairingService
4
+ * instance), so a previously paired phone — whose cookie already lives 365
5
+ * days — never needs to re-scan the QR after `dsh web` restarts.
6
+ */
7
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
11
+ import { pairingConfigOf } from './index.js';
12
+ import { PairingService } from './pairing.js';
13
+ const BASE_CONFIG = {
14
+ tokenTtlMs: 60_000,
15
+ offlineAfterMs: 25_000,
16
+ maxDevices: 4,
17
+ cookieName: 'dsh_pair',
18
+ };
19
+ /** Deterministic clock: tokens are issued in a fixed, readable sequence. */
20
+ function makeClock() {
21
+ let n = 0;
22
+ return {
23
+ now: () => 1_000_000 + n,
24
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
25
+ };
26
+ }
27
+ /** Pair a device on a service: issue a token and immediately accept it. */
28
+ function pairDevice(service) {
29
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com');
30
+ const { token } = service.issue();
31
+ const result = service.accept(token);
32
+ if (!result.ok)
33
+ throw new Error(`pair failed: ${result.code}`);
34
+ return result.deviceId;
35
+ }
36
+ describe('PairingService device persistence', () => {
37
+ let dir;
38
+ beforeEach(() => {
39
+ dir = mkdtempSync(join(tmpdir(), 'remote-web-ui-persist-'));
40
+ });
41
+ afterEach(() => {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ });
44
+ it('restores paired sessions across a process restart', () => {
45
+ const file = join(dir, 'devices.json');
46
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
47
+ const deviceId = pairDevice(first);
48
+ expect(readFileSync(file, 'utf8')).toContain(deviceId);
49
+ // Device ids are session credentials: the persisted file is 0600.
50
+ expect(statSync(file).mode & 0o777).toBe(0o600);
51
+ // Simulated restart: a brand-new service instance reading the same file.
52
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
53
+ expect(second.hasDevice(deviceId)).toBe(true);
54
+ });
55
+ it('carries devicesFile through the config mapping used by sync() (regression)', () => {
56
+ const file = join(dir, 'devices.json');
57
+ const mapped = pairingConfigOf({
58
+ tokenTtlMs: 60_000,
59
+ offlineAfterMs: 25_000,
60
+ maxDevices: 4,
61
+ cookieName: 'dsh_pair',
62
+ devicesFile: file,
63
+ });
64
+ expect(mapped.devicesFile).toBe(file);
65
+ });
66
+ it('keeps persisting after a sync-style service.config rebuild', () => {
67
+ const file = join(dir, 'devices.json');
68
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
69
+ // applyImpl/sync() reassigns service.config from the resolved config on
70
+ // mount and on every settings change; the rebuild must not drop devicesFile.
71
+ service.config = pairingConfigOf({
72
+ tokenTtlMs: 60_000,
73
+ offlineAfterMs: 25_000,
74
+ maxDevices: 4,
75
+ cookieName: 'dsh_pair',
76
+ devicesFile: file,
77
+ });
78
+ const deviceId = pairDevice(service);
79
+ const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
80
+ expect(restarted.hasDevice(deviceId)).toBe(true);
81
+ });
82
+ it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
83
+ const file = join(dir, 'devices.json');
84
+ writeFileSync(file, JSON.stringify({
85
+ dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
86
+ dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
87
+ dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
88
+ }));
89
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock());
90
+ expect(service.hasDevice('dev3')).toBe(true);
91
+ expect(service.hasDevice('dev2')).toBe(true);
92
+ expect(service.hasDevice('dev1')).toBe(false);
93
+ });
94
+ it('keeps sessions memory-only when devicesFile is unset', () => {
95
+ const first = new PairingService({ ...BASE_CONFIG }, makeClock());
96
+ const deviceId = pairDevice(first);
97
+ // No file was written; a fresh instance without the option knows nothing.
98
+ const second = new PairingService({ ...BASE_CONFIG }, makeClock());
99
+ expect(second.hasDevice(deviceId)).toBe(false);
100
+ });
101
+ it('persists revocation by stop() across a restart', () => {
102
+ const file = join(dir, 'devices.json');
103
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
104
+ const deviceId = pairDevice(first);
105
+ first.stop();
106
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock());
107
+ expect(second.hasDevice(deviceId)).toBe(false);
108
+ });
109
+ it('persists FIFO eviction when the device cap is reached', () => {
110
+ const file = join(dir, 'devices.json');
111
+ const first = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock());
112
+ const firstDevice = pairDevice(first);
113
+ const secondDevice = pairDevice(first);
114
+ const thirdDevice = pairDevice(first); // evicts firstDevice
115
+ expect(first.hasDevice(firstDevice)).toBe(false);
116
+ const second = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock());
117
+ expect(second.hasDevice(secondDevice)).toBe(true);
118
+ expect(second.hasDevice(thirdDevice)).toBe(true);
119
+ expect(second.hasDevice(firstDevice)).toBe(false);
120
+ });
121
+ it('tolerates a corrupt or missing file instead of refusing to boot', () => {
122
+ const file = join(dir, 'devices.json');
123
+ writeFileSync(file, '{ this is not json !!!');
124
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())).not.toThrow();
125
+ const ghost = join(dir, 'does-not-exist.json');
126
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow();
127
+ });
128
+ });
@@ -244,7 +244,7 @@ export function makeRoutes(deps) {
244
244
  const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`;
245
245
  writeJson(res, 200, {
246
246
  ok: true,
247
- url: `${base}/?pair=${token}${workspaceQuery}`,
247
+ url: `${base}/m/?pair=${token}${workspaceQuery}`,
248
248
  token,
249
249
  expiresAt,
250
250
  // Every constructible base, so a multi-homed panel can switch the
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Remote update support for the dsh-web-ui family — host half. Detects the
3
- * installed aggregate package (@linxin666/dsh-web-ui-all) and its family
4
- * children, probes the npm registry for newer releases, and runs the actual
5
- * update as `pnpm update --latest` inside the owning dsh profile directory.
3
+ * installed aggregate package (@linxin666/dsh-web-ui-all), or the directly
4
+ * installed family packages when the aggregate is absent, probes npm for newer
5
+ * releases, and runs `pnpm update --latest` inside the owning dsh profile.
6
6
  *
7
7
  * Pure logic with injected seams (manifest reading, registry fetches, process
8
8
  * spawning) so the whole surface is unit-testable without touching disk,
@@ -1 +1 @@
1
- {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/update.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,iDAAiD;AACjD,eAAO,MAAM,YAAY,+BAA+B,CAAA;AAExD,oEAAoE;AACpE,eAAO,MAAM,YAAY,gBAAgB,CAAA;AAEzC,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,8BAA8B,CAAA;AAE5D,+EAA+E;AAC/E,eAAO,MAAM,YAAY,iCAAiC,CAAA;AAQ1D,0EAA0E;AAC1E,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;IACb,2DAA2D;IAC3D,UAAU,EAAE,MAAM,EAAE,CAAA;CACrB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS,CASlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CA2B5D;AAYD,yDAAyD;AACzD,MAAM,WAAW,YAAY;IAC3B,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,kCAAkC;IAClC,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,kBAAkB,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAahF;AAED,6DAA6D;AAC7D,KAAK,cAAc,GAAG,MAAM,GAAG;IAAE,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAAA;AAE9D,uEAAuE;AACvE,wBAAgB,YAAY,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAG1D;AAED,kDAAkD;AAClD,MAAM,WAAW,mBAAmB;IAClC,oBAAoB;IACpB,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,OAAO,EAAE,MAAM,CAAA;IACf,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,oDAAoD;IACpD,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC3B,iGAAiG;IACjG,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,SAAS,CAAA;IAChC,sCAAsC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,6CAA6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,qDAAqD;IACrD,QAAQ,EAAE,mBAAmB,EAAE,CAAA;IAC/B,qDAAqD;IACrD,QAAQ,EAAE,OAAO,CAAA;IACjB,uDAAuD;IACvD,KAAK,CAAC,EAAE,MAAM,CAAA;CACf;AAED,6EAA6E;AAC7E,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,kBAAkB,CAAC,EAAE,MAAM,CAAA;IAC3B,4EAA4E;IAC5E,OAAO,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAA;IAC9C,oEAAoE;IACpE,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CACvD;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GACjD,MAAM,GAAG,SAAS,CAUpB;AAED,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B,2BAA2B;IAC3B,WAAW,EAAE,MAAM,CAAA;IACnB,+CAA+C;IAC/C,UAAU,EAAE,MAAM,CAAA;IAClB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,IAAI,EAAE;IAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE,GACpC,YAAY,GAAG;IAAE,KAAK,EAAE,WAAW,GAAG,MAAM,CAAA;CAAE,CAoBhD;AAED,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,cAAc,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,EAAE,CAQhF;AAiBD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAAE,EAAE,EAAE,OAAO,CAAC;IAAC,IAAI,IAAI,OAAO,CAAC,OAAO,CAAC,CAAA;CAAE,CAAC,EAC9E,SAAS,SAAS,GACjB,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAiB7B;AA4BD;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAsD/E;AAED,4DAA4D;AAC5D,MAAM,MAAM,eAAe;AACzB,2BAA2B;AACzB,cAAc;AAChB,6CAA6C;GAC3C,SAAS;AACX,2CAA2C;GACzC,WAAW;AACb,iEAAiE;GAC/D,MAAM;AACR,4BAA4B;GAC1B,aAAa;AACf,6DAA6D;GAC3D,OAAO;AACT,iFAAiF;GAC/E,eAAe,CAAA;AAEnB,gCAAgC;AAChC,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,OAAO,CAAA;IACX,0EAA0E;IAC1E,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,6DAA6D;IAC7D,MAAM,EAAE,MAAM,CAAA;IACd,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gEAAgE;IAChE,SAAS,CAAC,EAAE,eAAe,CAAA;CAC5B;AAED,+CAA+C;AAC/C,MAAM,WAAW,aAAa;IAC5B,0CAA0C;IAC1C,UAAU,EAAE,MAAM,CAAA;IAClB,sCAAsC;IACtC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B,oDAAoD;IACpD,SAAS,CAAC,EAAE,OAAO,KAAK,CAAA;IACxB,mDAAmD;IACnD,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC,QAAQ,CAAA;CAC3B;AAuBD;;;;;;GAMG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,OAAO,CAAC,eAAe,CAAC,CA8HvE;AAED,0EAA0E;AAC1E,MAAM,WAAW,qBAAqB;IACpC,iDAAiD;IACjD,GAAG,EAAE,aAAa,CAAA;IAClB,8DAA8D;IAC9D,KAAK,EAAE,eAAe,CAAA;CACvB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAsB,iBAAiB,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,eAAe,CAAC,CAgE7F"}