@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,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,
@@ -173,10 +173,13 @@ export function resolveUpdateTarget(deps) {
173
173
  const spec = profileManifest?.dependencies?.[anchor];
174
174
  if (isLinkedSpec(spec))
175
175
  return { error: 'link' };
176
+ // Standalone installs carry no aggregate: the anchor's own dependency
177
+ // list misses every sibling @linxin666/* plugin installed directly into
178
+ // the profile, so union the profile's direct family deps (#377).
176
179
  return {
177
180
  profileName: profile.name,
178
181
  profileDir: profile.dir,
179
- packages: [anchor, ...familyChildren(manifest)],
182
+ packages: familyUpdatePackages(anchor, manifest, profileManifest),
180
183
  };
181
184
  }
182
185
  /** Family children of the anchor: its dependencies under the family scope. */
@@ -191,6 +194,18 @@ export function familyChildren(anchorManifest) {
191
194
  }
192
195
  return names;
193
196
  }
197
+ /** Registry-managed family packages covered by one update operation. */
198
+ function familyUpdatePackages(anchor, anchorManifest, profileManifest) {
199
+ const names = new Set([anchor, ...familyChildren(anchorManifest)]);
200
+ const dependencies = profileManifest?.dependencies;
201
+ if (typeof dependencies !== 'object' || dependencies === null)
202
+ return [...names];
203
+ for (const [name, spec] of Object.entries(dependencies)) {
204
+ if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec))
205
+ names.add(name);
206
+ }
207
+ return [...names];
208
+ }
194
209
  /**
195
210
  * Probe the npm registry for one package's latest release.
196
211
  * @param name - the package name (scope slash URL-encoded).
@@ -228,15 +243,20 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
228
243
  */
229
244
  const VERSION_UNKNOWN = '0.0.0';
230
245
  /** The resolved current version of one family package (probe failure tolerated). */
231
- function readInstalledVersion(resolve, name) {
246
+ function readInstalledVersion(resolve, name, profileDir) {
232
247
  try {
233
248
  const path = resolve(name + '/package.json');
234
249
  const version = path === undefined ? undefined : readManifest(path)?.version;
235
- return typeof version === 'string' ? version : VERSION_UNKNOWN;
250
+ if (typeof version === 'string')
251
+ return version;
236
252
  }
237
- catch {
253
+ catch { /* fall through to the profile's direct dependency path */ }
254
+ // Names originate in the profile dependency map, but still validate the
255
+ // npm package shape before using one as path segments.
256
+ if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name))
238
257
  return VERSION_UNKNOWN;
239
- }
258
+ const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version;
259
+ return typeof version === 'string' ? version : VERSION_UNKNOWN;
240
260
  }
241
261
  /**
242
262
  * Build the update status: locate the anchor, detect the install mode, and
@@ -267,7 +287,10 @@ export async function checkUpdates(deps) {
267
287
  if (profile === undefined) {
268
288
  return { mode: 'link', packages: [], outdated: false };
269
289
  }
270
- const names = [anchor, ...familyChildren(manifest)];
290
+ // Union the profile's direct family deps so standalone installs (no
291
+ // aggregate) still check every installed @linxin666/* plugin (#377);
292
+ // familyUpdatePackages skips link:/file: development dependencies.
293
+ const names = familyUpdatePackages(anchor, manifest, profileManifest);
271
294
  // The registry probes are independent: run them together instead of
272
295
  // serializing up to N x 10s of registry latency behind one status call.
273
296
  const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)));
@@ -277,7 +300,7 @@ export async function checkUpdates(deps) {
277
300
  const latest = latestList[index];
278
301
  if (latest === undefined)
279
302
  probeFailures++;
280
- const current = readInstalledVersion(deps.resolve, name);
303
+ const current = readInstalledVersion(deps.resolve, name, profile.dir);
281
304
  packages.push({
282
305
  name,
283
306
  current,
@@ -494,7 +517,7 @@ export async function runUpdateVerified(deps) {
494
517
  // green exit that left everything in place could be mistaken for success.
495
518
  const before = new Map();
496
519
  for (const name of deps.run.packages) {
497
- const version = readInstalledVersion(deps.check.resolve, name);
520
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir);
498
521
  if (version !== VERSION_UNKNOWN)
499
522
  before.set(name, version);
500
523
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
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.1.20",
4
+ "version": "0.2.0",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -61,17 +61,17 @@
61
61
  },
62
62
  "devDependencies": {
63
63
  "@deepseek-ai/cordis": "^4.0.1",
64
- "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.6",
65
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
66
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
67
- "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
68
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.6",
69
- "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.6",
70
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
71
- "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.6",
72
- "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
73
- "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6",
74
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
64
+ "@deepseek-ai/dsh-client-connection": "^0.1.0-rc.7",
65
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.7",
66
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.7",
67
+ "@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.7",
68
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.0-rc.7",
69
+ "@deepseek-ai/dsh-client-ui-sidebar": "^0.1.0-rc.7",
70
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.7",
71
+ "@deepseek-ai/dsh-host-apiproxy": "^0.1.0-rc.7",
72
+ "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.7",
73
+ "@deepseek-ai/dsh-invariants": "^0.1.0-rc.7",
74
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7",
75
75
  "@testing-library/dom": "^10.4.1",
76
76
  "@testing-library/react": "^16.3.2",
77
77
  "@types/node": "^22.20.0",
@@ -56,6 +56,12 @@ export interface PluginSettingsCardProps<TKey extends string = string> {
56
56
  * already provides the selection.
57
57
  */
58
58
  alwaysOpen?: boolean
59
+ /**
60
+ * Hide the save/discard footer: cards whose body applies its own changes
61
+ * immediately (an embedded external settings section) have no staged
62
+ * edits, so the footer would sit there permanently disabled.
63
+ */
64
+ hideFooter?: boolean
59
65
  /** The plugin's controls. */
60
66
  children: ReactNode
61
67
  }
@@ -140,6 +146,9 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
140
146
  <div className={css.body}>
141
147
  {!state.writable ? <p className={css.readOnly} role="status">{props.t('settings.readOnly')}</p> : null}
142
148
  {props.children}
149
+ {props.hideFooter === true
150
+ ? null
151
+ : (
143
152
  <div className={css.footer}>
144
153
  {state.failed
145
154
  ? (
@@ -165,6 +174,7 @@ export function PluginSettingsCard<TKey extends string = string>(props: PluginSe
165
174
  {props.t(!state.saving ? 'settings.save' : 'settings.saving')}
166
175
  </button>
167
176
  </div>
177
+ )}
168
178
  </div>
169
179
  )
170
180
  : null}
@@ -222,13 +222,13 @@ export function RemoteEntry({ wide, useWorkspaces, t }: RemoteEntryProps) {
222
222
  )
223
223
  }
224
224
 
225
- /** The trigger: an icon button matching the settings rail/row geometry. */
225
+ /** The trigger: an icon-only control with a persistent accessible label. */
226
226
  function TooltipAnchor({ wide, label, onClick }: { wide: boolean; label: string; onClick: () => void }) {
227
227
  return (
228
228
  <button
229
229
  type="button"
230
230
  className={css.trigger}
231
- data-wide={wide ? undefined : 'rail'}
231
+ data-wide={wide ? 'wide' : 'rail'}
232
232
  aria-label={label}
233
233
  title={label}
234
234
  onClick={onClick}
@@ -140,6 +140,7 @@ export function RemoteSettingsCard(props: RemoteSettingsCardProps) {
140
140
  t={t}
141
141
  titleKey="settings.title"
142
142
  descriptionKey="settings.description"
143
+ defaultOpen={false}
143
144
  state={state}
144
145
  onSave={props.save}
145
146
  onDiscard={props.discard}
@@ -29,14 +29,31 @@ export interface UpdateEntryProps {
29
29
  export function UpdateEntry({ wide, t }: UpdateEntryProps) {
30
30
  const [open, setOpen] = useState(false)
31
31
  const [view, setView] = useState<UpdateView>({ kind: "checking" })
32
+ const [updateAvailable, setUpdateAvailable] = useState(false)
32
33
  const runToken = useRef(0)
34
+ const availabilityToken = useRef(0)
35
+ const mounted = useRef(false)
36
+
37
+ const probeAvailability = useCallback(async (): Promise<void> => {
38
+ const token = ++availabilityToken.current
39
+ try {
40
+ const status = await fetchUpdateStatus()
41
+ if (token === availabilityToken.current) {
42
+ setUpdateAvailable(status.mode === "npm" && status.outdated)
43
+ }
44
+ } catch {
45
+ if (token === availabilityToken.current) setUpdateAvailable(false)
46
+ }
47
+ }, [])
33
48
 
34
49
  const check = useCallback(async (): Promise<void> => {
50
+ const availabilityCheck = ++availabilityToken.current
35
51
  setView({ kind: "checking" })
36
52
  let status: UpdateStatus
37
53
  try {
38
54
  status = await fetchUpdateStatus()
39
55
  } catch (error) {
56
+ if (availabilityCheck === availabilityToken.current) setUpdateAvailable(false)
40
57
  // HTTP 404: the update route is not mounted — the host process runs an
41
58
  // older plugin build (client refreshed, host did not). Restarting dsh
42
59
  // web loads the new plugin; a plain network failure gets the generic
@@ -48,6 +65,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
48
65
  setView({ kind: "error", message: t("update.offline"), detail: t("update.offlineDetail") })
49
66
  return
50
67
  }
68
+ if (availabilityCheck === availabilityToken.current) {
69
+ setUpdateAvailable(status.mode === "npm" && status.outdated)
70
+ }
51
71
  if (status.error === "registry-unreachable") {
52
72
  setView({ kind: "result", status })
53
73
  return
@@ -60,6 +80,7 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
60
80
  const token = ++runToken.current
61
81
  try {
62
82
  const result = await runUpdate()
83
+ if (result.ok && mounted.current) setUpdateAvailable(false)
63
84
  if (token !== runToken.current) return
64
85
  setView({ kind: "done", result })
65
86
  } catch {
@@ -78,8 +99,18 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
78
99
  setOpen(false)
79
100
  }, [])
80
101
 
81
- // Unmount safety: an in-flight update must not land on a dead component.
82
- useEffect(() => () => { runToken.current++ }, [])
102
+ // Check once after mount without opening the panel or starting an update.
103
+ useEffect(() => {
104
+ mounted.current = true
105
+ void probeAvailability()
106
+ return () => {
107
+ mounted.current = false
108
+ runToken.current++
109
+ availabilityToken.current++
110
+ }
111
+ }, [probeAvailability])
112
+
113
+ const updateLabel = updateAvailable ? t("update.availableLabel") : t("update.label")
83
114
 
84
115
  return (
85
116
  <>
@@ -87,8 +118,9 @@ export function UpdateEntry({ wide, t }: UpdateEntryProps) {
87
118
  type="button"
88
119
  className={css.trigger}
89
120
  data-wide={wide ? undefined : "rail"}
90
- aria-label={t("update.label")}
91
- title={t("update.label")}
121
+ data-update-available={updateAvailable ? "true" : undefined}
122
+ aria-label={updateLabel}
123
+ title={updateLabel}
92
124
  onClick={openPanel}
93
125
  >
94
126
  <IconDownloadOutline16 size={wide ? 16 : 18} />
@@ -95,7 +95,7 @@ async function runAccept(token: string, page: PageSurface): Promise<void> {
95
95
  // mobile surface can open the intended workspace instead of losing the
96
96
  // QR context at this navigation boundary.
97
97
  if (isMobileSurface()) {
98
- url.pathname = '/m'
98
+ url.pathname = '/m/'
99
99
  page.navigate(`${url.pathname}${url.search}${url.hash}`)
100
100
  } else {
101
101
  page.reload()
@@ -74,6 +74,7 @@ export const zh = {
74
74
  'settings.invalidNumber': '请输入数字,留空则使用默认值。',
75
75
  // 更新面板(侧边栏更新按钮)。
76
76
  'update.label': '检查更新',
77
+ 'update.availableLabel': '发现新版本,检查更新',
77
78
  'update.title': '检查更新',
78
79
  'update.checking': '正在检查远程仓库中的新版本…',
79
80
  'update.upToDate': '当前已是最新版本',
@@ -187,6 +188,7 @@ export const en = {
187
188
  'settings.invalidNumber': 'Enter a number, or leave blank to use the default.',
188
189
  // Update panel (the sidebar update trigger).
189
190
  'update.label': 'Check for updates',
191
+ 'update.availableLabel': 'New version available. Check for updates',
190
192
  'update.title': 'Check for updates',
191
193
  'update.checking': 'Checking the remote repository for newer releases…',
192
194
  'update.upToDate': 'Everything is up to date',
@@ -21,6 +21,7 @@
21
21
  /* Trigger: settings row height beside the trigger row (wide), or the 36px
22
22
  rail circle; the seat's owner (ui-sidebar) lays out the geometry. */
23
23
  .trigger {
24
+ position: relative;
24
25
  flex: none;
25
26
  display: inline-flex;
26
27
  align-items: center;
@@ -37,6 +38,30 @@
37
38
  transition: background-color 120ms ease, color 120ms ease, box-shadow 120ms ease;
38
39
  }
39
40
 
41
+ .trigger[data-update-available]::after {
42
+ position: absolute;
43
+ top: 4px;
44
+ right: 4px;
45
+ width: 8px;
46
+ height: 8px;
47
+ box-sizing: border-box;
48
+ border: 2px solid var(--dsw-alias-bg-layer-1);
49
+ border-radius: 50%;
50
+ background: var(--dsw-alias-brand-primary);
51
+ content: '';
52
+ pointer-events: none;
53
+ }
54
+
55
+ .trigger[data-wide='wide'] {
56
+ flex: 1 1 auto;
57
+ justify-content: flex-start;
58
+ width: auto;
59
+ min-width: 0;
60
+ border-radius: 8px;
61
+ gap: 8px;
62
+ padding: 0 10px;
63
+ }
64
+
40
65
  .trigger:hover:not(:disabled) {
41
66
  background: var(--dsw-alias-interactive-bg-hover);
42
67
  color: var(--dsw-alias-label-primary);
@@ -429,6 +454,7 @@
429
454
  display: flex;
430
455
  align-items: center;
431
456
  gap: 6px;
457
+ min-width: 0;
432
458
  }
433
459
 
434
460
  .entryRow[data-rail='rail'] {
package/src/gate.ts CHANGED
@@ -89,8 +89,20 @@ export function makeGateListener(
89
89
  if (!active) return false
90
90
  const require = typeof requirePairingForLan === 'function' ? requirePairingForLan() : requirePairingForLan
91
91
  if (!require) return next()
92
- const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
93
- if (deviceId === undefined) return false
94
- return service.touchDevice(deviceId) ? next() : false
92
+ return isPairedDeviceRequest(service, request) ? next() : false
95
93
  }
96
94
  }
95
+
96
+ /**
97
+ * Whether a request carries a live, non-revoked paired-device cookie for
98
+ * this service. Sibling host routes outside /api (aionui-panel, etc.) use
99
+ * the same check via the remoteWebUiPairing service.
100
+ * @param service - the pairing service that owns the device table.
101
+ * @param request - the incoming HTTP request.
102
+ * @returns true when the cookie names a live session (and lastSeenAt was refreshed).
103
+ */
104
+ export function isPairedDeviceRequest(service: PairingService, request: IncomingMessage): boolean {
105
+ const deviceId = readCookie(request.headers.cookie, service.config.cookieName)
106
+ if (deviceId === undefined) return false
107
+ return service.touchDevice(deviceId)
108
+ }
package/src/index.ts CHANGED
@@ -15,8 +15,9 @@ import type { Context } from '@deepseek-ai/cordis'
15
15
  import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
16
  import z from 'schemastery'
17
17
  import type {} from '@deepseek-ai/dsh-host-webserver'
18
- import { PairingService } from './pairing.ts'
19
- import { makeGateListener } from './gate.ts'
18
+ import { PairingService, type PairingConfig } from './pairing.ts'
19
+ import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
20
+ import { RemoteWebUiPairing } from './pairing-access.ts'
20
21
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
21
22
  import { makeMobileRoutes } from './mobile-routes.ts'
22
23
  import { makeMobileApiRoutes } from './mobile-api.ts'
@@ -89,6 +90,14 @@ export interface Config {
89
90
  * warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
90
91
  */
91
92
  publicBaseUrl?: string
93
+ /**
94
+ * Absolute path to a JSON file where paired device sessions are persisted.
95
+ * When set, a paired phone keeps its session across `dsh web` restarts
96
+ * (the phone-side cookie already lives 365 days), so re-scanning the QR
97
+ * after each restart is unnecessary. Unset keeps sessions memory-only
98
+ * (previous behavior).
99
+ */
100
+ devicesFile?: string
92
101
  /**
93
102
  * When true, the plugin runs its own Cloudflare quick tunnel (the
94
103
  * cloudflared binary ships with the package — no user-side install) and
@@ -115,6 +124,7 @@ export const Config: z<Config> = z.object({
115
124
  cookieName: z.string().min(1).default('dsh_pair'),
116
125
  requirePairingForLan: z.boolean().default(true),
117
126
  publicBaseUrl: z.string(),
127
+ devicesFile: z.string(),
118
128
  autoTunnel: z.boolean().default(false),
119
129
  mobileEnterToSend: z.boolean().default(true),
120
130
  enabled: z.boolean().default(true),
@@ -128,7 +138,29 @@ const SWEEP_INTERVAL_MS = 10_000
128
138
  * which legitimately resolves to `undefined` when unset (the schema keeps it
129
139
  * optional, so `Required` alone would over-narrow it to `string`).
130
140
  */
131
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl'>> & { publicBaseUrl: string | undefined }
141
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
142
+ publicBaseUrl: string | undefined
143
+ devicesFile: string | undefined
144
+ }
145
+
146
+ /**
147
+ * The single mapping from resolved plugin config to the pairing service
148
+ * config. Both the constructed service and every live settings sync reuse
149
+ * it, so no field can be silently dropped when the web settings surface
150
+ * pushes a new value into the running service.
151
+ */
152
+ export function pairingConfigOf(resolved: Pick<
153
+ ResolvedConfig,
154
+ 'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
155
+ >): PairingConfig {
156
+ return {
157
+ tokenTtlMs: resolved.tokenTtlMs,
158
+ offlineAfterMs: resolved.offlineAfterMs,
159
+ maxDevices: resolved.maxDevices,
160
+ cookieName: resolved.cookieName,
161
+ devicesFile: resolved.devicesFile,
162
+ }
163
+ }
132
164
 
133
165
  /** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
134
166
  const DEFAULTS: ResolvedConfig = {
@@ -138,6 +170,7 @@ const DEFAULTS: ResolvedConfig = {
138
170
  cookieName: 'dsh_pair',
139
171
  requirePairingForLan: true,
140
172
  publicBaseUrl: undefined,
173
+ devicesFile: undefined,
141
174
  autoTunnel: false,
142
175
  mobileEnterToSend: true,
143
176
  enabled: true,
@@ -158,6 +191,7 @@ function applyImpl(ctx: Context, config?: Config): void {
158
191
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
159
192
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
160
193
  publicBaseUrl: config?.publicBaseUrl,
194
+ devicesFile: config?.devicesFile,
161
195
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
162
196
  mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
163
197
  enabled: config?.enabled ?? DEFAULTS.enabled,
@@ -175,17 +209,13 @@ function applyImpl(ctx: Context, config?: Config): void {
175
209
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
176
210
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
177
211
  publicBaseUrl: value.publicBaseUrl,
212
+ devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
178
213
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
179
214
  mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
180
215
  enabled: value.enabled ?? DEFAULTS.enabled,
181
216
  }
182
217
  }
183
- const service = new PairingService({
184
- tokenTtlMs: resolved.tokenTtlMs,
185
- offlineAfterMs: resolved.offlineAfterMs,
186
- maxDevices: resolved.maxDevices,
187
- cookieName: resolved.cookieName,
188
- })
218
+ const service = new PairingService(pairingConfigOf(resolved))
189
219
 
190
220
  // ── auto tunnel ─────────────────────────────────────────────────────────
191
221
  // The minted public URL becomes the QR base (and the pairing fence's
@@ -312,14 +342,15 @@ function applyImpl(ctx: Context, config?: Config): void {
312
342
  ]
313
343
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
314
344
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
345
+ // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
346
+ // plugin is not installed; stop() / enabled=false still refuse cookies.
347
+ new RemoteWebUiPairing(ctx, (request) => {
348
+ if (!resolve().enabled) return false
349
+ return isPairedDeviceRequest(service, request)
350
+ })
315
351
  const sync = (): void => {
316
352
  const value = resolve()
317
- service.config = {
318
- tokenTtlMs: value.tokenTtlMs,
319
- offlineAfterMs: value.offlineAfterMs,
320
- maxDevices: value.maxDevices,
321
- cookieName: value.cookieName,
322
- }
353
+ service.config = pairingConfigOf(value)
323
354
  // The auto tunnel owns the public base while enabled: the minted URL
324
355
  // lands in the service through the tunnel's phase listener. The manual
325
356
  // publicBaseUrl applies only when the auto tunnel is off.
@@ -0,0 +1,28 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { PairRequiredView } from './PairRequiredView.tsx'
5
+
6
+ afterEach(() => {
7
+ cleanup()
8
+ vi.unstubAllGlobals()
9
+ })
10
+
11
+ describe('PairRequiredView', () => {
12
+ it('accepts a pasted link in the installed app context', async () => {
13
+ const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }))
14
+ vi.stubGlobal('fetch', fetch)
15
+ const onPaired = vi.fn()
16
+ render(<PairRequiredView onPaired={onPaired} />)
17
+
18
+ fireEvent.change(screen.getByLabelText('配对链接'), { target: { value: 'https://phone.example/m/?pair=tok-1&workspace=ws-7' } })
19
+ fireEvent.click(screen.getByRole('button', { name: '配对' }))
20
+
21
+ await waitFor(() => expect(onPaired).toHaveBeenCalledWith('/m/?workspace=ws-7'))
22
+ })
23
+
24
+ it('shows an initial QR failure without starting the mobile data channel', () => {
25
+ render(<PairRequiredView initialError="配对链接已被使用。" onPaired={vi.fn()} />)
26
+ expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。')
27
+ })
28
+ })
@@ -0,0 +1,58 @@
1
+ /** Device-pairing gate for installed mobile web-app contexts with isolated storage. */
2
+ import { type FormEvent, useState } from 'react'
3
+ import { acceptMobilePair, mobilePairPath, parseMobilePairInput } from './pairing.ts'
4
+
5
+ export interface PairRequiredViewProps {
6
+ initialError?: string
7
+ onPaired(path: string): void
8
+ }
9
+
10
+ /** Collect a fresh desktop-issued pairing link when this client has no paired cookie. */
11
+ export function PairRequiredView({ initialError, onPaired }: PairRequiredViewProps) {
12
+ const [value, setValue] = useState('')
13
+ const [error, setError] = useState<string | undefined>(initialError)
14
+ const [submitting, setSubmitting] = useState(false)
15
+
16
+ const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
17
+ event.preventDefault()
18
+ const input = parseMobilePairInput(value)
19
+ if (input === undefined) {
20
+ setError('请输入有效的配对链接。')
21
+ return
22
+ }
23
+
24
+ setSubmitting(true)
25
+ setError(undefined)
26
+ const result = await acceptMobilePair(input.token)
27
+ setSubmitting(false)
28
+ if (!result.ok) {
29
+ setError(result.message)
30
+ return
31
+ }
32
+
33
+ onPaired(mobilePairPath(input.workspaceId))
34
+ }
35
+
36
+ return (
37
+ <main className="mobile mobile-pair" aria-labelledby="mobile-pair-title">
38
+ <form className="mobile-pairCard" onSubmit={(event) => { void submit(event) }}>
39
+ <h1 id="mobile-pair-title" className="mobile-title">设备配对</h1>
40
+ <p className="mobile-muted">粘贴桌面端复制的配对链接以连接此设备。</p>
41
+ <label className="mobile-pairLabel" htmlFor="mobile-pair-link">配对链接</label>
42
+ <input
43
+ id="mobile-pair-link"
44
+ className="mobile-pairInput"
45
+ value={value}
46
+ onChange={(event) => { setValue(event.target.value) }}
47
+ autoComplete="off"
48
+ autoCapitalize="off"
49
+ spellCheck={false}
50
+ />
51
+ {error === undefined ? null : <p className="mobile-error" role="alert">{error}</p>}
52
+ <button className="mobile-new mobile-pairSubmit" type="submit" disabled={submitting}>
53
+ {submitting ? '正在配对' : '配对'}
54
+ </button>
55
+ </form>
56
+ </main>
57
+ )
58
+ }
@@ -11,6 +11,8 @@ import { createRoot } from 'react-dom/client'
11
11
  import { App } from './views/App.tsx'
12
12
  import { mobileCss } from './mobile-styles.ts'
13
13
  import { initMobileTheme } from './mobile-theme.ts'
14
+ import { registerMobilePwa } from '../mobile-pwa.ts'
15
+ import { consumeMobilePairUrl } from './pairing.ts'
14
16
 
15
17
  // Apply the persisted (or default light) theme before first paint, so the
16
18
  // page never flashes the wrong palette.
@@ -24,4 +26,16 @@ document.head.appendChild(style)
24
26
 
25
27
  const root = document.getElementById('root')
26
28
  if (root === null) throw new Error('mobile: #root missing')
27
- createRoot(root).render(<App />)
29
+ void bootMobile(root)
30
+
31
+ async function bootMobile(mount: HTMLElement): Promise<void> {
32
+ const pair = await consumeMobilePairUrl(window.location.href)
33
+ if (pair.kind === 'accepted') {
34
+ window.location.replace(pair.path)
35
+ return
36
+ }
37
+ if (pair.kind === 'failed') window.history.replaceState(null, '', pair.path)
38
+
39
+ void registerMobilePwa()
40
+ createRoot(mount).render(<App initialPairError={pair.kind === 'failed' ? pair.message : undefined} />)
41
+ }
@@ -1020,4 +1020,52 @@ body {
1020
1020
  transition: none;
1021
1021
  }
1022
1022
  }
1023
+
1024
+ /* ── installed-app pairing ───────────────────────────────────────────── */
1025
+
1026
+ .mobile-pair {
1027
+ min-height: 100dvh;
1028
+ display: grid;
1029
+ place-items: center;
1030
+ padding: 24px;
1031
+ }
1032
+
1033
+ .mobile-pairCard {
1034
+ display: grid;
1035
+ width: min(100%, 420px);
1036
+ gap: 12px;
1037
+ padding: 24px;
1038
+ border: 1px solid var(--m-border);
1039
+ border-radius: 12px;
1040
+ background: var(--m-bg-raised);
1041
+ box-shadow: var(--m-shadow-raise);
1042
+ }
1043
+
1044
+ .mobile-pairLabel {
1045
+ color: var(--m-text-secondary);
1046
+ font-size: 13px;
1047
+ font-weight: 600;
1048
+ }
1049
+
1050
+ .mobile-pairInput {
1051
+ width: 100%;
1052
+ min-height: 42px;
1053
+ box-sizing: border-box;
1054
+ padding: 9px 12px;
1055
+ border: 1px solid var(--m-border);
1056
+ border-radius: 8px;
1057
+ background: var(--m-bg-input);
1058
+ color: var(--m-text);
1059
+ font: inherit;
1060
+ outline: none;
1061
+ }
1062
+
1063
+ .mobile-pairInput:focus-visible {
1064
+ border-color: var(--m-accent);
1065
+ box-shadow: 0 0 0 2px var(--m-bg), 0 0 0 4px var(--m-accent);
1066
+ }
1067
+
1068
+ .mobile-pairSubmit {
1069
+ width: 100%;
1070
+ }
1023
1071
  `