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

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  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 +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
@@ -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
+ });
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAoL3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAIhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;aACrB,KAAK,EAAE,iBAAiB;aACxB,MAAM,EAAE,kBAAkB;aAC1B,IAAI,EAAE,gBAAgB;aACtB,SAAS,EAAE,qBAAqB;aAChC,MAAM,EAAE,kBAAkB;aAC1B,MAAM,EAAE,kBAAkB;CAClB,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;iBAG7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AA6BjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;IACH,YAAY,OAAO,EAAE,cAAc,EAElC;IAED,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI,CAepD;IAED,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI,CAUpC;IAED,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;CAChC;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAwM3D"}
@@ -200,7 +200,16 @@ export function makeRoutes(deps) {
200
200
  const ACCEPT_MAX_ATTEMPTS = 10;
201
201
  const ACCEPT_WINDOW_MS = 30_000;
202
202
  const rateLimitAccept = (req) => {
203
- const ip = req.socket?.remoteAddress ?? 'unknown';
203
+ const socketIp = req.socket?.remoteAddress ?? 'unknown';
204
+ // Behind the auto-tunnel every internet client arrives from 127.0.0.1,
205
+ // so a single shared bucket would let one attacker keep the legitimate
206
+ // owner rate-limited. Partition the availability bucket by the first
207
+ // client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
208
+ // authentication and only separates buckets, it never grants access.
209
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
210
+ ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
211
+ : undefined;
212
+ const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded;
204
213
  const nowMs = Date.now();
205
214
  // The map lives as long as the plugin: prune expired windows once the
206
215
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -244,7 +253,7 @@ export function makeRoutes(deps) {
244
253
  const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`;
245
254
  writeJson(res, 200, {
246
255
  ok: true,
247
- url: `${base}/?pair=${token}${workspaceQuery}`,
256
+ url: `${base}/m/?pair=${token}${workspaceQuery}`,
248
257
  token,
249
258
  expiresAt,
250
259
  // Every constructible base, so a multi-homed panel can switch the
@@ -287,6 +296,9 @@ export function makeRoutes(deps) {
287
296
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
288
297
  return;
289
298
  }
299
+ // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
300
+ // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
301
+ // top-level navigations working while blocking cross-site subrequests.
290
302
  res.writeHead(200, {
291
303
  'content-type': 'application/json; charset=utf-8',
292
304
  'set-cookie': [
@@ -337,7 +349,15 @@ export function makeRoutes(deps) {
337
349
  return;
338
350
  }
339
351
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
340
- writeJson(res, 200, { ok: true, paired: deviceId !== undefined && service.hasDevice(deviceId), ...service.snapshot() });
352
+ const paired = deviceId !== undefined && service.hasDevice(deviceId);
353
+ const snapshot = service.snapshot();
354
+ // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
355
+ // token expiry, device roster, and public tunnel URL are an oracle for
356
+ // targeting and timing and stay behind a live device cookie.
357
+ const visible = paired
358
+ ? snapshot
359
+ : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses };
360
+ writeJson(res, 200, { ok: true, paired, ...visible });
341
361
  };
342
362
  const handleEvents = (req, res) => {
343
363
  if (!requireMethod(req, res, 'GET'))
@@ -64,6 +64,7 @@ export declare class TunnelManager {
64
64
  private urlTimer;
65
65
  private restartTimer;
66
66
  private attempts;
67
+ private generation;
67
68
  private stopping;
68
69
  private readonly urlListeners;
69
70
  private readonly phaseListeners;
@@ -1 +1 @@
1
- {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/tunnel.ts"],"names":[],"mappings":"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"}
@@ -46,6 +46,9 @@ export class TunnelManager {
46
46
  urlTimer;
47
47
  restartTimer;
48
48
  attempts = 0;
49
+ // Generation counter: a stale ensureBinary resolution from an earlier
50
+ // start() must not spawn a second handle after a stop/start cycle.
51
+ generation = 0;
49
52
  stopping = false;
50
53
  urlListeners = new Set();
51
54
  phaseListeners = new Set();
@@ -81,6 +84,7 @@ export class TunnelManager {
81
84
  this.stopping = false;
82
85
  this.targetUrl = targetUrl;
83
86
  this.attempts = 0;
87
+ this.generation += 1;
84
88
  this.attempt();
85
89
  }
86
90
  /** Stop the tunnel for good: no restarts, no state. */
@@ -107,12 +111,13 @@ export class TunnelManager {
107
111
  attempt() {
108
112
  if (this.stopping || this.targetUrl === undefined)
109
113
  return;
114
+ const gen = this.generation;
110
115
  this.setPhase('starting');
111
116
  this.handle = undefined;
112
117
  this.url = undefined;
113
118
  this.error = undefined;
114
119
  void this.ensureBinary().then(() => {
115
- if (this.stopping || this.targetUrl === undefined)
120
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
116
121
  return;
117
122
  const handle = this.factory(this.targetUrl);
118
123
  this.handle = handle;
@@ -139,7 +144,7 @@ export class TunnelManager {
139
144
  });
140
145
  }).catch((value) => {
141
146
  // Binary install failed (no network, no platform build): report it.
142
- if (this.stopping || this.targetUrl === undefined)
147
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation)
143
148
  return;
144
149
  const message = value instanceof Error ? value.message : String(value);
145
150
  this.fail(`could not obtain the cloudflared binary: ${message}`);
@@ -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,
@@ -137,7 +137,7 @@ export declare function familyChildren(anchorManifest: Record<string, unknown>):
137
137
  * @param timeoutMs - probe timeout.
138
138
  * @returns the latest version string, or undefined on any failure.
139
139
  */
140
- export declare function fetchLatestVersion(name: string, fetchImpl: (url: string) => Promise<{
140
+ export declare function fetchLatestVersion(name: string, fetchImpl: (url: string, init?: RequestInit) => Promise<{
141
141
  ok: boolean;
142
142
  json(): Promise<unknown>;
143
143
  }>, timeoutMs?: number): Promise<string | undefined>;
@@ -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":"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"}
@@ -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).
@@ -203,7 +218,7 @@ export async function fetchLatestVersion(name, fetchImpl, timeoutMs = 10_000) {
203
218
  const controller = new AbortController();
204
219
  const timer = setTimeout(() => { controller.abort(); }, timeoutMs);
205
220
  try {
206
- const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest');
221
+ const response = await fetchImpl(NPM_REGISTRY + '/' + name.replace('/', '%2F') + '/latest', { signal: controller.signal });
207
222
  if (!response.ok)
208
223
  return undefined;
209
224
  const body = await response.json();
@@ -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.1",
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",