livedesk 0.1.692 → 0.1.696

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 (64) hide show
  1. package/README.md +13 -9
  2. package/bin/livedesk.js +12 -12
  3. package/electron/electron-builder.linux-x64.yml +17 -1
  4. package/electron/linux-icons/128x128.png +0 -0
  5. package/electron/linux-icons/16x16.png +0 -0
  6. package/electron/linux-icons/256x256.png +0 -0
  7. package/electron/linux-icons/32x32.png +0 -0
  8. package/electron/linux-icons/48x48.png +0 -0
  9. package/electron/linux-icons/512x512.png +0 -0
  10. package/electron/linux-icons/64x64.png +0 -0
  11. package/electron/vuvodesk.metainfo.xml +25 -0
  12. package/hub/package.json +1 -1
  13. package/hub/src/console-direct-frame-admission.mjs +60 -12
  14. package/hub/src/console-direct-frame-admission.test.mjs +57 -0
  15. package/hub/src/console-direct-ice-evidence.mjs +87 -0
  16. package/hub/src/console-direct-ice-evidence.test.mjs +59 -0
  17. package/hub/src/console-direct.js +156 -93
  18. package/hub/src/console-direct.test.mjs +128 -0
  19. package/hub/src/console-ice-setup-gate.mjs +30 -0
  20. package/hub/src/console-router-mapping.mjs +187 -0
  21. package/hub/src/console-router-mapping.test.mjs +287 -0
  22. package/hub/src/console-upnp-gateway.mjs +230 -0
  23. package/hub/src/plan-device-access.test.mjs +10 -4
  24. package/hub/src/server.js +10 -3
  25. package/hub/src/settings/settings-schema.js +3 -2
  26. package/package.json +1 -1
  27. package/web/dist/_headers +3 -0
  28. package/web/dist/app.html +2 -2
  29. package/web/dist/app.webmanifest +1 -1
  30. package/web/dist/assets/{AgentSettingsTab-DO2Z6NkJ.js → AgentSettingsTab-DE87RukW.js} +1 -1
  31. package/web/dist/assets/{AgentsPage-_shsjGZi.js → AgentsPage-CegBXGiy.js} +8 -8
  32. package/web/dist/assets/CaptureGallery-C9vKLkR_.js +1 -0
  33. package/web/dist/assets/{DesktopUpdatePanel-DuatXF9C.js → DesktopUpdatePanel-DopP2EeD.js} +1 -1
  34. package/web/dist/assets/{HubApp-Cl6KXmZZ.js → HubApp-CXzY-toF.js} +5 -5
  35. package/web/dist/assets/{MonitorStackIcon-BCZRKGg9.js → MonitorStackIcon-CiDB9DkB.js} +5 -5
  36. package/web/dist/assets/SettingsPage-Beec8L9Q.js +1 -0
  37. package/web/dist/assets/SettingsTabs-v57M-CCZ.js +1 -0
  38. package/web/dist/assets/{ShareFilesPage-DwpF_ImR.js → ShareFilesPage-DCbC6-YF.js} +1 -1
  39. package/web/dist/assets/SupportPage-DruQIvyG.js +2 -0
  40. package/web/dist/assets/VuvoDeskApp-D9YNArJ0.js +1 -0
  41. package/web/dist/assets/app-B234Zxw4.js +2 -0
  42. package/web/dist/assets/{client-8jUbxy3j.js → client-D-D35ghV.js} +1 -1
  43. package/web/dist/assets/{icons-CuXJn-Hz.js → icons-ChMazCZR.js} +1 -1
  44. package/web/dist/assets/main-CSJAuJm1.js +2 -0
  45. package/web/dist/assets/main-CVbEVGge.css +1 -0
  46. package/web/dist/assets/public-site-routing-pddZbLCj.js +1 -0
  47. package/web/dist/assets/{pwa-startup-DvbrYEQi.js → pwa-startup-BiuYh5eN.js} +1 -1
  48. package/web/dist/assets/{react-rvm9rmed.js → react-CnkEM7Rb.js} +1 -1
  49. package/web/dist/assets/vuvodesk-mobile-wall.jpg +0 -0
  50. package/web/dist/assets/vuvodesk-wall.png +0 -0
  51. package/web/dist/index.html +8 -8
  52. package/web/dist/robots.txt +1 -1
  53. package/web/dist/sitemap.xml +7 -7
  54. package/web/dist/sw.js +2 -2
  55. package/web/dist/vuvodesk-build-evidence.json +79 -69
  56. package/web/dist/assets/CaptureGallery-YQRtdPdf.js +0 -1
  57. package/web/dist/assets/SettingsPage-fj5cHPfI.js +0 -1
  58. package/web/dist/assets/SettingsTabs-CbCpfIYW.js +0 -1
  59. package/web/dist/assets/SupportPage-FMuoakR7.js +0 -2
  60. package/web/dist/assets/VuvoDeskApp-BG169yB2.js +0 -1
  61. package/web/dist/assets/app-Cy9L1xCR.js +0 -2
  62. package/web/dist/assets/main-BL1yIEvm.js +0 -2
  63. package/web/dist/assets/main-CLwaKs4-.css +0 -1
  64. package/web/dist/assets/preload-helper-DD1OZmLC.js +0 -1
package/README.md CHANGED
@@ -22,10 +22,14 @@ capture helpers, stale monitor generations, unbounded frame queues, or an
22
22
  unproven Direct/P2P/relay transition. Release builds run the same lifecycle,
23
23
  transport recovery, and browser H.264 population gates used by CI.
24
24
 
25
- ## Unified launch
25
+ ## Unified launch
26
+
27
+ The public command is `vuvodesk`. Its small npm entry package loads this exact
28
+ `livedesk` runtime version in the same process. Existing `livedesk@latest`
29
+ commands and update storage remain supported.
26
30
 
27
31
  ```powershell
28
- npx -y --prefer-online livedesk@latest
32
+ npx -y --prefer-online vuvodesk@latest
29
33
  ```
30
34
 
31
35
  The first interactive launch asks whether this computer is a Hub or Client.
@@ -36,14 +40,14 @@ device role is available, it takes precedence over the offline cache.
36
40
  For development and migration checks, the role can be forced for one run:
37
41
 
38
42
  ```powershell
39
- npx -y --prefer-online livedesk@latest --force-role hub
40
- npx -y --prefer-online livedesk@latest --force-role client
43
+ npx -y --prefer-online vuvodesk@latest --force-role hub
44
+ npx -y --prefer-online vuvodesk@latest --force-role client
41
45
  ```
42
46
 
43
47
  ## Hub
44
48
 
45
49
  ```powershell
46
- npx -y --prefer-online livedesk@latest hub
50
+ npx -y --prefer-online vuvodesk@latest hub
47
51
  ```
48
52
 
49
53
  This starts the VuvoDesk Hub, opens the local screen wall, accepts clients, and
@@ -67,7 +71,7 @@ and startup retry, then exits without looping.
67
71
  ## Client compatibility
68
72
 
69
73
  ```powershell
70
- npx -y --prefer-online livedesk@latest client 3
74
+ npx -y --prefer-online vuvodesk@latest client 3
71
75
  ```
72
76
 
73
77
  The `client` form remains as a legacy compatibility alias. It records the
@@ -85,7 +89,7 @@ Screen Recording and Accessibility permissions, then explicitly confirm that
85
89
  the motion is already running:
86
90
 
87
91
  ```bash
88
- npx -y --prefer-online livedesk@latest diagnose mac-physical --confirm-active-motion
92
+ npx -y --prefer-online vuvodesk@latest diagnose mac-physical --confirm-active-motion
89
93
  ```
90
94
 
91
95
  This command branches before role resolution, state migration, and the normal
@@ -105,7 +109,7 @@ result. It is written under `~/.livedesk/diagnostics`. To select a path relative
105
109
  to the directory where the command was invoked:
106
110
 
107
111
  ```bash
108
- npx -y --prefer-online livedesk@latest diagnose mac-physical --confirm-active-motion --report ./mac-result.json
112
+ npx -y --prefer-online vuvodesk@latest diagnose mac-physical --confirm-active-motion --report ./mac-result.json
109
113
  ```
110
114
 
111
115
  Use `diagnose mac-physical --help` to inspect requirements without starting a
@@ -130,7 +134,7 @@ While the local Hub is running and at least one Client is sending a current
130
134
  Mode 3 H.264 stream, run:
131
135
 
132
136
  ```bash
133
- npx -y --prefer-online livedesk@latest diagnose transport-physical
137
+ npx -y --prefer-online vuvodesk@latest diagnose transport-physical
134
138
  ```
135
139
 
136
140
  The diagnostic proves one unchanged stream identity through Direct TCP,
package/bin/livedesk.js CHANGED
@@ -343,11 +343,11 @@ function printHelp() {
343
343
  VuvoDesk
344
344
 
345
345
  Usage:
346
- npx -y --prefer-online livedesk@latest
347
- npx -y --prefer-online livedesk@latest --force-role hub
348
- npx -y --prefer-online livedesk@latest --force-role client
349
- npx -y --prefer-online livedesk@latest diagnose mac-physical --confirm-active-motion
350
- npx -y --prefer-online livedesk@latest diagnose transport-physical
346
+ npx -y --prefer-online vuvodesk@latest
347
+ npx -y --prefer-online vuvodesk@latest --force-role hub
348
+ npx -y --prefer-online vuvodesk@latest --force-role client
349
+ npx -y --prefer-online vuvodesk@latest diagnose mac-physical --confirm-active-motion
350
+ npx -y --prefer-online vuvodesk@latest diagnose transport-physical
351
351
 
352
352
  Commands:
353
353
  No command Resolve the signed-in device role and start the matching runtime.
@@ -386,8 +386,8 @@ function printMacPhysicalDiagnosticHelp() {
386
386
  VuvoDesk macOS physical diagnostic
387
387
 
388
388
  Usage:
389
- npx -y --prefer-online livedesk@latest diagnose mac-physical --confirm-active-motion
390
- npx -y --prefer-online livedesk@latest diagnose mac-physical --confirm-active-motion --report ./mac-result.json
389
+ npx -y --prefer-online vuvodesk@latest diagnose mac-physical --confirm-active-motion
390
+ npx -y --prefer-online vuvodesk@latest diagnose mac-physical --confirm-active-motion --report ./mac-result.json
391
391
 
392
392
  Requirements:
393
393
  macOS with two connected displays showing visibly different, continuously moving content.
@@ -455,8 +455,8 @@ function printClientHelp() {
455
455
  VuvoDesk Client
456
456
 
457
457
  Usage:
458
- npx -y --prefer-online livedesk@latest client
459
- npx -y --prefer-online livedesk@latest client 3
458
+ npx -y --prefer-online vuvodesk@latest client
459
+ npx -y --prefer-online vuvodesk@latest client 3
460
460
 
461
461
  Default flow:
462
462
  Opens a VuvoDesk connection page with Google sign-in or a 6-digit PIN.
@@ -3270,7 +3270,7 @@ async function main() {
3270
3270
  const commandArgs = args.slice(1).map(value => String(value || '').trim().toLowerCase());
3271
3271
  if (commandArgs.some(value => value === '--help' || value === '-h' || value === 'help')) {
3272
3272
  if (command === 'client') {
3273
- console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
3273
+ console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online vuvodesk@latest` for automatic role selection.');
3274
3274
  printClientHelp();
3275
3275
  } else {
3276
3276
  printHelp();
@@ -3279,7 +3279,7 @@ async function main() {
3279
3279
  }
3280
3280
  if (commandArgs.some(value => value === '--version' || value === '-v' || value === 'version')) {
3281
3281
  if (command === 'client') {
3282
- console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
3282
+ console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online vuvodesk@latest` for automatic role selection.');
3283
3283
  }
3284
3284
  console.log(readVersion());
3285
3285
  return;
@@ -3302,7 +3302,7 @@ async function main() {
3302
3302
  writeRoleCache({ ...resolvedRole, source: 'local-cache', isOfflineFallback: false }, roleStateOptions);
3303
3303
  runtimeArgs = args.slice(1);
3304
3304
  if (command === 'client') {
3305
- console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
3305
+ console.warn('[VuvoDesk] Legacy client command detected. Use `npx -y --prefer-online vuvodesk@latest` for automatic role selection.');
3306
3306
  }
3307
3307
  } else {
3308
3308
  const forceDesktopBootstrap = process.env.LIVEDESK_DESKTOP_HOST === '1'
@@ -26,10 +26,11 @@ asarUnpack:
26
26
  - node_modules/node-datachannel/**
27
27
  - node_modules/@node-datachannel/linux-x64-gnu/**
28
28
  linux:
29
- icon: packages/vuvodesk/electron/icon.png
29
+ icon: packages/vuvodesk/electron/linux-icons
30
30
  category: Utility
31
31
  executableName: livedesk
32
32
  maintainer: "lovecrdm <lovecrdm@gmail.com>"
33
+ synopsis: VuvoDesk remote desktop and screen wall
33
34
  target:
34
35
  - target: AppImage
35
36
  arch:
@@ -37,6 +38,21 @@ linux:
37
38
  - target: deb
38
39
  arch:
39
40
  - x64
41
+ deb:
42
+ packageName: vuvodesk
43
+ packageCategory: utils
44
+ description: >-
45
+ View your computers together and control a selected screen with VuvoDesk.
46
+ Updates are checked and downloaded inside VuvoDesk. Installing a deb update
47
+ may require your administrator password.
48
+ fpm:
49
+ - --replaces
50
+ - livedesk
51
+ - --conflicts
52
+ - livedesk
53
+ - --provides
54
+ - livedesk
55
+ - packages/vuvodesk/electron/vuvodesk.metainfo.xml=/usr/share/metainfo/com.livedesk.desktop.metainfo.xml
40
56
  extraMetadata:
41
57
  homepage: https://vuvodesk.com
42
58
  publish:
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,25 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <component type="desktop-application">
3
+ <id>com.livedesk.desktop</id>
4
+ <metadata_license>MIT</metadata_license>
5
+ <project_license>MIT</project_license>
6
+ <name>VuvoDesk</name>
7
+ <summary>View and control your computers from one screen</summary>
8
+ <developer id="com.vuvodesk">
9
+ <name>VuvoDesk</name>
10
+ </developer>
11
+ <description>
12
+ <p>VuvoDesk brings your computers into a screen wall and lets you control a selected screen.</p>
13
+ <p>Connect with the desktop application or the mobile web app. Updates are checked inside VuvoDesk; installing a deb update may require your administrator password.</p>
14
+ </description>
15
+ <launchable type="desktop-id">livedesk.desktop</launchable>
16
+ <icon type="stock">livedesk</icon>
17
+ <url type="homepage">https://www.vuvodesk.com</url>
18
+ <url type="help">https://www.vuvodesk.com/support</url>
19
+ <categories>
20
+ <category>Utility</category>
21
+ </categories>
22
+ <provides>
23
+ <binary>livedesk</binary>
24
+ </provides>
25
+ </component>
package/hub/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/hub",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "VuvoDesk local Hub API and browser frame bridge",
5
5
  "type": "module",
6
6
  "main": "src/server.js",
@@ -3,7 +3,8 @@ export const CONSOLE_MEDIA_ADMISSION = Object.freeze({
3
3
  peerBufferedBytes: 1024 * 1024,
4
4
  bufferedAgeMs: 120,
5
5
  maxStreams: 64,
6
- maxMetadataBytes: 64 * 1024
6
+ maxMetadataBytes: 64 * 1024,
7
+ maxSendBatches: 16
7
8
  });
8
9
 
9
10
  function h264Identity(packet) {
@@ -31,13 +32,44 @@ function h264Identity(packet) {
31
32
  // timer, task, or outbound queue; every decision precedes wire fragmentation.
32
33
  export function createConsoleFrameAdmission() {
33
34
  const streams = new Map();
34
- let bufferedSince = null;
35
+ const sendBatches = [];
36
+ let accountedBytes = 0;
35
37
  let droppedFrames = 0;
36
38
  let recoveredStreams = 0;
39
+ const flow = { inputFrames: 0, inputKeyFrames: 0, admittedFrames: 0,
40
+ laneByteDrops: 0, peerByteDrops: 0, ageDrops: 0, dependentDrops: 0 };
41
+ let lastInputAt = null, lastKeyAt = null, lastAdmittedAt = null;
42
+ function sent(bytes, now) {
43
+ if (!(bytes > 0)) return;
44
+ accountedBytes += bytes;
45
+ if (sendBatches.length >= CONSOLE_MEDIA_ADMISSION.maxSendBatches) {
46
+ // Coalesce at the newer end using the older timestamp: bounds remain
47
+ // conservative, never younger than the bytes whose age we protect.
48
+ const last = sendBatches.at(-1);
49
+ last.bytes += bytes;
50
+ } else sendBatches.push({ bytes, at: now });
51
+ }
52
+ function oldestBufferedAt(bufferedBytes, now) {
53
+ if (bufferedBytes > accountedBytes) sent(bufferedBytes - accountedBytes, now);
54
+ let drained = Math.max(0, accountedBytes - bufferedBytes);
55
+ while (drained > 0 && sendBatches.length > 0) {
56
+ const oldest = sendBatches[0];
57
+ const consumed = Math.min(drained, oldest.bytes);
58
+ oldest.bytes -= consumed;
59
+ drained -= consumed;
60
+ accountedBytes -= consumed;
61
+ if (oldest.bytes === 0) sendBatches.shift();
62
+ }
63
+ return sendBatches[0]?.at ?? now;
64
+ }
37
65
  return {
66
+ sent,
38
67
  admit(packet, { laneBufferedBytes, peerBufferedBytes, now }) {
39
68
  const identity = h264Identity(packet);
40
69
  if (!identity) return 'unmanaged';
70
+ flow.inputFrames += 1;
71
+ lastInputAt = now;
72
+ if (identity.key) { flow.inputKeyFrames += 1; lastKeyAt = now; }
41
73
  let state = streams.get(identity.stream);
42
74
  if (!state) {
43
75
  if (streams.size >= CONSOLE_MEDIA_ADMISSION.maxStreams) return 'capacity';
@@ -47,34 +79,50 @@ export function createConsoleFrameAdmission() {
47
79
  state.owner = identity.owner;
48
80
  state.awaitingKey = true;
49
81
  }
50
- if (laneBufferedBytes <= 0) bufferedSince = null;
51
- else if (bufferedSince === null) bufferedSince = now;
52
- const pressured = (laneBufferedBytes > 0 && (
53
- laneBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.laneBufferedBytes
54
- || now - bufferedSince >= CONSOLE_MEDIA_ADMISSION.bufferedAgeMs
55
- )) || (peerBufferedBytes > 0
56
- && peerBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.peerBufferedBytes);
57
- if (pressured || (state.awaitingKey && !identity.key)) {
82
+ const oldestAt = oldestBufferedAt(Math.max(0, laneBufferedBytes), now);
83
+ const laneBytePressure = laneBufferedBytes > 0
84
+ && laneBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.laneBufferedBytes;
85
+ const peerBytePressure = peerBufferedBytes > 0
86
+ && peerBufferedBytes + packet.length > CONSOLE_MEDIA_ADMISSION.peerBufferedBytes;
87
+ const agePressure = laneBufferedBytes > 0
88
+ && now - oldestAt >= CONSOLE_MEDIA_ADMISSION.bufferedAgeMs;
89
+ if (laneBytePressure || peerBytePressure || agePressure || (state.awaitingKey && !identity.key)) {
90
+ // Exclusive reasons distinguish the original pressure from subsequent
91
+ // dependent deltas. Keep only scalar evidence, never frames or timers.
92
+ if (laneBytePressure) flow.laneByteDrops += 1;
93
+ else if (peerBytePressure) flow.peerByteDrops += 1;
94
+ else if (agePressure) flow.ageDrops += 1;
95
+ else flow.dependentDrops += 1;
58
96
  state.awaitingKey = true;
59
97
  droppedFrames += 1;
60
98
  return 'drop';
61
99
  }
62
100
  if (state.awaitingKey) recoveredStreams += 1;
63
101
  state.awaitingKey = false;
102
+ flow.admittedFrames += 1;
103
+ lastAdmittedAt = now;
64
104
  return 'send';
65
105
  },
66
- inspect() {
106
+ inspect(now = performance.now()) {
107
+ const age = at => at === null ? null : Math.max(0, now - at);
67
108
  return {
109
+ ...flow,
110
+ lastInputAgeMs: age(lastInputAt),
111
+ lastKeyAgeMs: age(lastKeyAt),
112
+ lastAdmittedAgeMs: age(lastAdmittedAt),
68
113
  streamOwners: streams.size,
69
114
  awaitingKeyStreams: [...streams.values()].filter(state => state.awaitingKey).length,
70
115
  droppedFrames,
71
116
  recoveredStreams,
117
+ sendBatches: sendBatches.length,
72
118
  retainedPayloadBytes: 0
73
119
  };
74
120
  },
75
121
  clear() {
76
122
  streams.clear();
77
- bufferedSince = null;
123
+ sendBatches.length = 0;
124
+ accountedBytes = 0;
125
+ lastInputAt = lastKeyAt = lastAdmittedAt = null;
78
126
  }
79
127
  };
80
128
  }
@@ -58,3 +58,60 @@ test('a multiplexed lane has a fixed owner bound and releases it on close', () =
58
58
  gate.clear();
59
59
  assert.equal(gate.admit(packet({ device: 'replacement', key: true }), empty), 'send');
60
60
  });
61
+
62
+ test('a continuously draining nonempty lane does not confuse current bytes with a stale backlog', () => {
63
+ const gate = createConsoleFrameAdmission();
64
+ for (let n = 0; n < 300; n++) {
65
+ const now = n * 33;
66
+ // Half of each accepted batch remains at the next sample, even though
67
+ // every older batch has drained. A never-zero timestamp would drop at 120ms.
68
+ assert.equal(gate.admit(packet({ key: n === 0 }), {
69
+ laneBufferedBytes: n === 0 ? 0 : 1000, peerBufferedBytes: 1000, now
70
+ }), 'send');
71
+ gate.sent(2000, now);
72
+ }
73
+ assert.equal(gate.inspect().droppedFrames, 0);
74
+ assert.ok(gate.inspect().sendBatches <= 2);
75
+ gate.clear();
76
+ assert.equal(gate.inspect().sendBatches, 0);
77
+ });
78
+
79
+ test('fresh sends cannot rejuvenate actually blocked bytes and batch metadata remains bounded', () => {
80
+ const gate = createConsoleFrameAdmission();
81
+ assert.equal(gate.admit(packet({ key: true }), empty), 'send');
82
+ gate.sent(2000, 0);
83
+ let pending = 2000;
84
+ for (let n = 1; n < 120; n++) {
85
+ assert.equal(gate.admit(packet(), { laneBufferedBytes: pending, peerBufferedBytes: pending, now: n }), 'send');
86
+ gate.sent(100, n); pending += 100;
87
+ }
88
+ assert.equal(gate.inspect().sendBatches, CONSOLE_MEDIA_ADMISSION.maxSendBatches);
89
+ assert.equal(gate.admit(packet(), { laneBufferedBytes: pending, peerBufferedBytes: pending, now: 120 }), 'drop');
90
+ assert.equal(gate.admit(packet(), { ...empty, now: 121 }), 'drop');
91
+ assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 122 }), 'send');
92
+ assert.equal(gate.inspect().sendBatches, 0);
93
+ });
94
+
95
+ test('read-driven flow evidence distinguishes source starvation, pressure and waiting for a key', () => {
96
+ const gate = createConsoleFrameAdmission();
97
+ assert.equal(gate.inspect(0).lastInputAgeMs, null);
98
+ gate.admit(packet({ key: true }), empty);
99
+ gate.sent(2000, 0);
100
+ const queued = { laneBufferedBytes: 2000, peerBufferedBytes: 2000, now: 120 };
101
+ assert.equal(gate.admit(packet(), queued), 'drop');
102
+ assert.equal(gate.admit(packet(), { ...empty, now: 150 }), 'drop');
103
+ const stalled = gate.inspect(200);
104
+ assert.equal(stalled.inputFrames, 3);
105
+ assert.equal(stalled.inputKeyFrames, 1);
106
+ assert.equal(stalled.admittedFrames, 1);
107
+ assert.equal(stalled.ageDrops, 1);
108
+ assert.equal(stalled.dependentDrops, 1);
109
+ assert.equal(stalled.lastInputAgeMs, 50);
110
+ assert.equal(stalled.lastKeyAgeMs, 200);
111
+ assert.equal(stalled.lastAdmittedAgeMs, 200);
112
+ assert.equal(gate.admit(packet({ key: true }), { ...empty, now: 220 }), 'send');
113
+ assert.equal(gate.inspect(221).lastAdmittedAgeMs, 1);
114
+ gate.clear();
115
+ assert.equal(gate.inspect(222).lastInputAgeMs, null);
116
+ assert.equal(gate.inspect(222).streamOwners, 0);
117
+ });
@@ -0,0 +1,87 @@
1
+ import { isIP } from 'node:net';
2
+
3
+ // Connection setup only: no SDP, addresses, credentials, packets, timers, or
4
+ // references to native peers are retained. Counts describe announcements, not
5
+ // unique paths (SDP and trickled candidates can announce the same path).
6
+ export const CONSOLE_ICE_HISTORY_LIMIT = 8;
7
+ // Explicit advanced configuration only. This does not open a firewall/router
8
+ // port or enable a relay; unset configuration retains native ephemeral ports.
9
+ export function normalizeConsoleIcePortRange(value) {
10
+ if (value === undefined || value === null || value === '') return {};
11
+ const match = String(value).trim().match(/^(\d{4,5})-(\d{4,5})$/);
12
+ const begin = Number(match?.[1]);
13
+ const end = Number(match?.[2]);
14
+ if (!match || begin < 1024 || end > 65535 || end < begin || end - begin >= 64) {
15
+ throw new Error('console-direct-ice-port-range-invalid');
16
+ }
17
+ return { portRangeBegin: begin, portRangeEnd: end };
18
+ }
19
+ const MAX_COUNT = 65_535;
20
+ const count = value => Math.min(MAX_COUNT, value + 1);
21
+ const states = new Set(['new', 'gathering', 'complete', 'completed', 'checking',
22
+ 'connecting', 'connected', 'disconnected', 'failed', 'closed']);
23
+ const reasons = new Set([
24
+ 'console-direct-ice-timeout', 'console-direct-ice-failed', 'console-direct-ice-closed',
25
+ 'console-direct-peer-failed', 'console-direct-peer-closed', 'console-direct-peer-replaced',
26
+ 'console-direct-description-failed', 'console-direct-candidate-failed',
27
+ 'console-direct-candidate-invalid', 'console-direct-relay-candidate-rejected',
28
+ 'console-direct-relay-pair-rejected', 'console-direct-remote-closed',
29
+ 'console-direct-peer-disconnected-timeout', 'console-direct-stopped'
30
+ ]);
31
+
32
+ export const safeIceState = value => states.has(value) ? value : 'unknown';
33
+
34
+ function candidateCounts() {
35
+ return { announcements: 0, host: 0, srflx: 0, prflx: 0, relay: 0,
36
+ ipv4: 0, ipv6: 0, mdns: 0, udp: 0, tcp: 0, unknown: 0 };
37
+ }
38
+
39
+ export function createConsoleIceEvidence() {
40
+ return { localDescriptionSent: false, remoteDescriptionApplied: false,
41
+ localCandidateSendFailures: 0,
42
+ gatheringState: 'unknown', local: candidateCounts(), remote: candidateCounts() };
43
+ }
44
+
45
+ export function recordConsoleIceCandidate(counts, value) {
46
+ if (typeof value !== 'string' || !value.trim()) return;
47
+ counts.announcements = count(counts.announcements);
48
+ if (value.length > 4096) { counts.unknown = count(counts.unknown); return; }
49
+ const parts = value.trim().replace(/^a=/, '').split(/\s+/);
50
+ const transport = parts[2]?.toLowerCase();
51
+ const type = parts[7]?.toLowerCase();
52
+ if (!/^candidate:/i.test(parts[0]) || parts[6]?.toLowerCase() !== 'typ'
53
+ || !['host', 'srflx', 'prflx', 'relay'].includes(type)) {
54
+ counts.unknown = count(counts.unknown);
55
+ return;
56
+ }
57
+ counts[type] = count(counts[type]);
58
+ if (transport === 'udp' || transport === 'tcp') counts[transport] = count(counts[transport]);
59
+ const family = isIP(parts[4] || '');
60
+ if (family) counts[`ipv${family}`] = count(counts[`ipv${family}`]);
61
+ else if (/^[a-z0-9-]+\.local$/i.test(parts[4] || '')) counts.mdns = count(counts.mdns);
62
+ else counts.unknown = count(counts.unknown);
63
+ }
64
+
65
+ export function recordConsoleIceDescription(counts, sdp) {
66
+ if (typeof sdp !== 'string' || sdp.length > 64 * 1024) return;
67
+ for (const line of sdp.split(/\r?\n/)) {
68
+ if (/^a=candidate:/i.test(line)) recordConsoleIceCandidate(counts, line);
69
+ }
70
+ }
71
+
72
+ export function snapshotConsoleIceEvidence(evidence) {
73
+ return { ...evidence, local: { ...evidence.local }, remote: { ...evidence.remote } };
74
+ }
75
+
76
+ export function retainConsoleIceRetirement(history, owner, reason, now = Date.now()) {
77
+ const snapshot = {
78
+ consoleId: owner.consoleId, connectionId: owner.connectionId, generation: owner.generation,
79
+ endedAt: new Date(now).toISOString(), durationMs: Math.max(0, now - owner.createdAt),
80
+ reason: reasons.has(reason) ? reason : 'other-peer-retirement',
81
+ peerState: safeIceState(owner.peerState), iceState: safeIceState(owner.iceState),
82
+ controlOpened: owner.controlOpened === true,
83
+ ...snapshotConsoleIceEvidence(owner.iceEvidence)
84
+ };
85
+ history.push(snapshot);
86
+ if (history.length > CONSOLE_ICE_HISTORY_LIMIT) history.shift();
87
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import {
4
+ createConsoleIceEvidence, normalizeConsoleIcePortRange, recordConsoleIceCandidate, recordConsoleIceDescription,
5
+ retainConsoleIceRetirement, snapshotConsoleIceEvidence
6
+ } from './console-direct-ice-evidence.mjs';
7
+
8
+ test('explicit ICE port ranges remain bounded and never alter the default', () => {
9
+ assert.deepEqual(normalizeConsoleIcePortRange(), {});
10
+ assert.deepEqual(normalizeConsoleIcePortRange(''), {});
11
+ assert.deepEqual(normalizeConsoleIcePortRange('54443-54443'), { portRangeBegin: 54443, portRangeEnd: 54443 });
12
+ assert.deepEqual(normalizeConsoleIcePortRange('54443-54450'), { portRangeBegin: 54443, portRangeEnd: 54450 });
13
+ for (const invalid of ['1-2', '1023-1024', '65535-65536', '55555-54443', '54443-54507', '54443', 'abc', '54443-54450x']) {
14
+ assert.throws(() => normalizeConsoleIcePortRange(invalid), /port-range-invalid/);
15
+ }
16
+ });
17
+
18
+ test('ICE evidence counts candidate families without retaining endpoints or SDP secrets', () => {
19
+ const evidence = createConsoleIceEvidence();
20
+ recordConsoleIceDescription(evidence.local, [
21
+ 'a=ice-pwd:DO-NOT-RETAIN',
22
+ 'a=candidate:1 1 UDP 123 192.168.0.4 59900 typ host',
23
+ 'a=candidate:2 1 udp 123 203.0.113.123 59901 typ srflx raddr 192.168.0.4 rport 59900',
24
+ 'a=candidate:3 1 tcp 123 2001:db8::abcd 59902 typ host tcptype passive',
25
+ 'a=candidate:4 1 udp 123 device-secret.local 59903 typ host'
26
+ ].join('\r\n'));
27
+ recordConsoleIceCandidate(evidence.remote, 'invalid SDP DO-NOT-RETAIN');
28
+ assert.equal(evidence.local.announcements, 4);
29
+ assert.equal(evidence.local.host, 3);
30
+ assert.equal(evidence.local.srflx, 1);
31
+ assert.equal(evidence.local.ipv4, 2);
32
+ assert.equal(evidence.local.ipv6, 1);
33
+ assert.equal(evidence.local.mdns, 1);
34
+ assert.equal(evidence.local.udp, 3);
35
+ assert.equal(evidence.local.tcp, 1);
36
+ assert.equal(evidence.remote.unknown, 1);
37
+ assert.doesNotMatch(JSON.stringify(evidence), /DO-NOT-RETAIN|192\.168|203\.0|db8|device-secret|5990/);
38
+ const snapshot = snapshotConsoleIceEvidence(evidence);
39
+ snapshot.local.host = 99;
40
+ assert.equal(evidence.local.host, 3);
41
+ });
42
+
43
+ test('retirement history is bounded, detached, and uses fixed reason/state labels', () => {
44
+ const history = [];
45
+ const owner = { consoleId: 'console', connectionId: 'connection', createdAt: 100,
46
+ peerState: 'connecting', iceState: 'checking', iceEvidence: createConsoleIceEvidence() };
47
+ for (let generation = 1; generation <= 30; generation++) {
48
+ retainConsoleIceRetirement(history, { ...owner, generation }, 'console-direct-ice-timeout', 200);
49
+ }
50
+ assert.equal(history.length, 8);
51
+ assert.equal(history[0].generation, 23);
52
+ assert.equal(history.at(-1).durationMs, 100);
53
+ owner.iceEvidence.remote.host = 99;
54
+ assert.equal(history.at(-1).remote.host, 0);
55
+ retainConsoleIceRetirement(history, { ...owner, peerState: 'secret-state' }, 'secret-reason', 200);
56
+ assert.equal(history.at(-1).peerState, 'unknown');
57
+ assert.equal(history.at(-1).reason, 'other-peer-retirement');
58
+ assert.doesNotMatch(JSON.stringify(history), /secret-/);
59
+ });