@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.
- package/README.i18n.yaml +2 -2
- package/README.md +41 -19
- package/README.zh.md +17 -12
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +64 -0
- package/lib/client.js +37 -10
- package/lib/client.js.map +1 -1
- package/lib/index.js +290 -53
- package/lib/mobile.js +278 -17
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/PluginSettingsCard.d.ts +6 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +5 -3
- package/lib/types/client/RemoteEntry.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +1 -1
- package/lib/types/client/UpdateEntry.d.ts.map +1 -1
- package/lib/types/client/UpdateEntry.js +36 -3
- package/lib/types/client/deep-link.js +1 -1
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +2 -0
- package/lib/types/gate.d.ts +9 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +15 -4
- package/lib/types/index.d.ts +25 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +30 -13
- package/lib/types/mobile/PairRequiredView.d.ts +7 -0
- package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.js +28 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
- package/lib/types/mobile/PairRequiredView.test.js +24 -0
- package/lib/types/mobile/index.js +14 -1
- package/lib/types/mobile/mobile-styles.d.ts +1 -1
- package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
- package/lib/types/mobile/mobile-styles.js +48 -0
- package/lib/types/mobile/pairing.d.ts +31 -0
- package/lib/types/mobile/pairing.d.ts.map +1 -0
- package/lib/types/mobile/pairing.js +62 -0
- package/lib/types/mobile/views/App.d.ts +12 -5
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +34 -7
- package/lib/types/mobile/views/App.test.d.ts +2 -0
- package/lib/types/mobile/views/App.test.d.ts.map +1 -0
- package/lib/types/mobile/views/App.test.js +52 -0
- package/lib/types/mobile-pwa.d.ts +6 -0
- package/lib/types/mobile-pwa.d.ts.map +1 -0
- package/lib/types/mobile-pwa.js +17 -0
- package/lib/types/mobile-routes.d.ts +5 -8
- package/lib/types/mobile-routes.d.ts.map +1 -1
- package/lib/types/mobile-routes.js +115 -30
- package/lib/types/pairing-access.d.ts +28 -0
- package/lib/types/pairing-access.d.ts.map +1 -0
- package/lib/types/pairing-access.js +26 -0
- package/lib/types/pairing.d.ts +27 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +68 -0
- package/lib/types/pairing.persist.test.d.ts +2 -0
- package/lib/types/pairing.persist.test.d.ts.map +1 -0
- package/lib/types/pairing.persist.test.js +128 -0
- package/lib/types/routes.js +1 -1
- package/lib/types/update.d.ts +3 -3
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +34 -11
- package/package.json +12 -12
- package/src/client/PluginSettingsCard.tsx +10 -0
- package/src/client/RemoteEntry.tsx +2 -2
- package/src/client/RemoteSettingsCard.tsx +1 -0
- package/src/client/UpdateEntry.tsx +36 -4
- package/src/client/deep-link.ts +1 -1
- package/src/client/locales.ts +2 -0
- package/src/client/remote.module.css +26 -0
- package/src/gate.ts +15 -3
- package/src/index.ts +46 -15
- package/src/mobile/PairRequiredView.test.tsx +28 -0
- package/src/mobile/PairRequiredView.tsx +58 -0
- package/src/mobile/index.tsx +15 -1
- package/src/mobile/mobile-styles.ts +48 -0
- package/src/mobile/pairing.ts +77 -0
- package/src/mobile/views/App.test.tsx +62 -0
- package/src/mobile/views/App.tsx +53 -6
- package/src/mobile-pwa.ts +19 -0
- package/src/mobile-routes.ts +123 -30
- package/src/pairing-access.ts +36 -0
- package/src/pairing.persist.test.ts +145 -0
- package/src/pairing.ts +70 -1
- package/src/routes.ts +1 -1
- 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)
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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:
|
|
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(
|
|
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
|
-
|
|
300
|
-
} catch {
|
|
301
|
-
|
|
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
|
-
|
|
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)
|