@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
package/src/pairing.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { randomBytes } from 'node:crypto'
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { dirname } from 'node:path'
19
21
 
20
22
  /** The observable pairing phases the panel renders. */
21
23
  export type PairingPhase =
@@ -95,6 +97,13 @@ export interface PairingConfig {
95
97
  maxDevices: number
96
98
  /** Cookie name carrying the device id. */
97
99
  cookieName: string
100
+ /**
101
+ * Path to a JSON file where paired device sessions are persisted. When
102
+ * set, sessions survive process restarts (the phone keeps its 365-day
103
+ * cookie), so re-pairing after a dsh web restart is not required. When
104
+ * unset (default), sessions stay memory-only — the previous behavior.
105
+ */
106
+ devicesFile?: string
98
107
  }
99
108
 
100
109
  /** Result of one accept() attempt. */
@@ -154,7 +163,65 @@ export class PairingService {
154
163
  constructor(
155
164
  public config: PairingConfig,
156
165
  private readonly clock: PairingClock = defaultClock,
157
- ) {}
166
+ ) {
167
+ this.loadPersisted()
168
+ }
169
+
170
+ /**
171
+ * Restore device sessions persisted by a previous process run. A corrupt
172
+ * or missing file is tolerated (an empty device table, never a throw) —
173
+ * persistence is an availability convenience, not a security boundary.
174
+ */
175
+ private loadPersisted(): void {
176
+ const file = this.config.devicesFile
177
+ if (file === undefined) return
178
+ try {
179
+ const saved = JSON.parse(readFileSync(file, 'utf8')) as unknown
180
+ if (typeof saved !== 'object' || saved === null) return
181
+ for (const [deviceId, session] of Object.entries(saved)) {
182
+ if (typeof deviceId !== 'string') continue
183
+ if (typeof session !== 'object' || session === null) continue
184
+ const { createdAt, lastSeenAt } = session as { createdAt?: unknown; lastSeenAt?: unknown }
185
+ if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number') continue
186
+ this.devices.set(deviceId, { createdAt, lastSeenAt })
187
+ }
188
+ this.clampToMaxDevices()
189
+ } catch {
190
+ // Unreadable/corrupt: start empty rather than refusing to boot.
191
+ }
192
+ }
193
+
194
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
195
+ private clampToMaxDevices(): void {
196
+ if (this.devices.size <= this.config.maxDevices) return
197
+ const overflow = this.devices.size - this.config.maxDevices
198
+ const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
199
+ for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
200
+ }
201
+
202
+ /**
203
+ * Write the current device table to the configured file. Called on the
204
+ * mutation boundaries that change the set of live sessions (accept and
205
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
206
+ * per-request write storm is avoided — after a restart the phone's first
207
+ * heartbeat re-warms its own session.
208
+ *
209
+ * Device ids are session credentials (the gate authorizes requests by the
210
+ * cookie's device id), so the file is written 0600 via a temp file and
211
+ * atomic rename; a crash mid-write can never leave a half-written store.
212
+ */
213
+ private persist(): void {
214
+ const file = this.config.devicesFile
215
+ if (file === undefined) return
216
+ try {
217
+ mkdirSync(dirname(file), { recursive: true })
218
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
219
+ writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 })
220
+ renameSync(temp, file)
221
+ } catch (error) {
222
+ console.error('remote-web-ui: failed to persist paired devices', error)
223
+ }
224
+ }
158
225
 
159
226
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
160
227
  get lanBaseUrl(): string | undefined {
@@ -254,6 +321,7 @@ export class PairingService {
254
321
  if (oldest !== undefined) this.devices.delete(oldest.id)
255
322
  }
256
323
  this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
324
+ this.persist()
257
325
  this.notify()
258
326
  return { ok: true, deviceId }
259
327
  }
@@ -266,6 +334,7 @@ export class PairingService {
266
334
  stop(): void {
267
335
  this.tokens.clear()
268
336
  this.devices.clear()
337
+ this.persist()
269
338
  this.stopped = true
270
339
  this.notify()
271
340
  }
package/src/routes.ts CHANGED
@@ -264,7 +264,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
264
264
  const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
265
265
  writeJson(res, 200, {
266
266
  ok: true,
267
- url: `${base}/?pair=${token}${workspaceQuery}`,
267
+ url: `${base}/m/?pair=${token}${workspaceQuery}`,
268
268
  token,
269
269
  expiresAt,
270
270
  // Every constructible base, so a multi-homed panel can switch the
package/src/update.ts CHANGED
@@ -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,
@@ -235,10 +235,13 @@ export function resolveUpdateTarget(
235
235
  const profileManifest = readManifest(join(profile.dir, 'package.json'))
236
236
  const spec = (profileManifest?.dependencies as Record<string, DependencySpec> | undefined)?.[anchor]
237
237
  if (isLinkedSpec(spec)) return { error: 'link' }
238
+ // Standalone installs carry no aggregate: the anchor's own dependency
239
+ // list misses every sibling @linxin666/* plugin installed directly into
240
+ // the profile, so union the profile's direct family deps (#377).
238
241
  return {
239
242
  profileName: profile.name,
240
243
  profileDir: profile.dir,
241
- packages: [anchor, ...familyChildren(manifest)],
244
+ packages: familyUpdatePackages(anchor, manifest, profileManifest),
242
245
  }
243
246
  }
244
247
 
@@ -253,6 +256,21 @@ export function familyChildren(anchorManifest: Record<string, unknown>): string[
253
256
  return names
254
257
  }
255
258
 
259
+ /** Registry-managed family packages covered by one update operation. */
260
+ function familyUpdatePackages(
261
+ anchor: string,
262
+ anchorManifest: Record<string, unknown>,
263
+ profileManifest: Record<string, unknown> | undefined,
264
+ ): string[] {
265
+ const names = new Set([anchor, ...familyChildren(anchorManifest)])
266
+ const dependencies = profileManifest?.dependencies
267
+ if (typeof dependencies !== 'object' || dependencies === null) return [...names]
268
+ for (const [name, spec] of Object.entries(dependencies)) {
269
+ if (name.startsWith(FAMILY_SCOPE) && typeof spec === 'string' && !isLinkedSpec(spec)) names.add(name)
270
+ }
271
+ return [...names]
272
+ }
273
+
256
274
  /**
257
275
  * Probe the npm registry for one package's latest release.
258
276
  * @param name - the package name (scope slash URL-encoded).
@@ -292,14 +310,21 @@ export async function fetchLatestVersion(
292
310
  const VERSION_UNKNOWN = '0.0.0'
293
311
 
294
312
  /** The resolved current version of one family package (probe failure tolerated). */
295
- function readInstalledVersion(resolve: (specifier: string) => string | undefined, name: string): string {
313
+ function readInstalledVersion(
314
+ resolve: (specifier: string) => string | undefined,
315
+ name: string,
316
+ profileDir?: string,
317
+ ): string {
296
318
  try {
297
319
  const path = resolve(name + '/package.json')
298
320
  const version = path === undefined ? undefined : readManifest(path)?.version
299
- return typeof version === 'string' ? version : VERSION_UNKNOWN
300
- } catch {
301
- return VERSION_UNKNOWN
302
- }
321
+ if (typeof version === 'string') return version
322
+ } catch { /* fall through to the profile's direct dependency path */ }
323
+ // Names originate in the profile dependency map, but still validate the
324
+ // npm package shape before using one as path segments.
325
+ if (profileDir === undefined || !/^@linxin666\/[a-z0-9][a-z0-9._-]*$/.test(name)) return VERSION_UNKNOWN
326
+ const version = readManifest(join(profileDir, 'node_modules', ...name.split('/'), 'package.json'))?.version
327
+ return typeof version === 'string' ? version : VERSION_UNKNOWN
303
328
  }
304
329
 
305
330
  /**
@@ -331,7 +356,10 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
331
356
  if (profile === undefined) {
332
357
  return { mode: 'link', packages: [], outdated: false }
333
358
  }
334
- const names = [anchor, ...familyChildren(manifest)]
359
+ // Union the profile's direct family deps so standalone installs (no
360
+ // aggregate) still check every installed @linxin666/* plugin (#377);
361
+ // familyUpdatePackages skips link:/file: development dependencies.
362
+ const names = familyUpdatePackages(anchor, manifest, profileManifest)
335
363
  // The registry probes are independent: run them together instead of
336
364
  // serializing up to N x 10s of registry latency behind one status call.
337
365
  const latestList = await Promise.all(names.map(name => deps.fetchLatest(name)))
@@ -340,7 +368,7 @@ export async function checkUpdates(deps: UpdateCheckDeps): Promise<UpdateStatus>
340
368
  names.forEach((name, index) => {
341
369
  const latest = latestList[index]
342
370
  if (latest === undefined) probeFailures++
343
- const current = readInstalledVersion(deps.resolve, name)
371
+ const current = readInstalledVersion(deps.resolve, name, profile.dir)
344
372
  packages.push({
345
373
  name,
346
374
  current,
@@ -604,7 +632,7 @@ export async function runUpdateVerified(deps: UpdateRunVerifiedDeps): Promise<Up
604
632
  // green exit that left everything in place could be mistaken for success.
605
633
  const before = new Map<string, string>()
606
634
  for (const name of deps.run.packages) {
607
- const version = readInstalledVersion(deps.check.resolve, name)
635
+ const version = readInstalledVersion(deps.check.resolve, name, deps.run.profileDir)
608
636
  if (version !== VERSION_UNKNOWN) before.set(name, version)
609
637
  }
610
638
  const result = await runUpdate(deps.run)