livedesk 0.1.691 → 0.1.695
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.md +13 -9
- package/bin/livedesk.js +12 -12
- package/client/package.json +5 -5
- package/electron/electron-builder.linux-x64.yml +17 -1
- package/electron/linux-icons/128x128.png +0 -0
- package/electron/linux-icons/16x16.png +0 -0
- package/electron/linux-icons/256x256.png +0 -0
- package/electron/linux-icons/32x32.png +0 -0
- package/electron/linux-icons/48x48.png +0 -0
- package/electron/linux-icons/512x512.png +0 -0
- package/electron/linux-icons/64x64.png +0 -0
- package/electron/vuvodesk.metainfo.xml +25 -0
- package/hub/package.json +1 -1
- package/hub/src/monitor-capture-ownership.mjs +15 -0
- package/hub/src/monitor-capture-ownership.test.mjs +16 -0
- package/hub/src/monitor-capture-runtime.test.mjs +121 -0
- package/hub/src/monitor-capture-viewers.test.mjs +229 -0
- package/hub/src/plan-device-access.test.mjs +10 -4
- package/hub/src/remote-hub.js +134 -45
- package/hub/src/server.js +12 -7
- package/package.json +6 -6
- package/web/dist/_headers +3 -0
- package/web/dist/app.html +2 -2
- package/web/dist/app.webmanifest +1 -1
- package/web/dist/assets/{AgentSettingsTab-CV_0za4F.js → AgentSettingsTab-DUUxlm9j.js} +1 -1
- package/web/dist/assets/{AgentsPage-BtrLYt9x.js → AgentsPage-Cqp2uXJm.js} +8 -8
- package/web/dist/assets/CaptureGallery-yJ9Z2oAV.js +1 -0
- package/web/dist/assets/{DesktopUpdatePanel-DuatXF9C.js → DesktopUpdatePanel-DopP2EeD.js} +1 -1
- package/web/dist/assets/{HubApp-D_DdfH3v.js → HubApp-DGbc5N_w.js} +12 -12
- package/web/dist/assets/{MonitorStackIcon-NxxMF_Ag.js → MonitorStackIcon-D0aNf3fk.js} +5 -5
- package/web/dist/assets/{SettingsPage-Dlkeg_u1.js → SettingsPage-gPMYkJ7M.js} +1 -1
- package/web/dist/assets/SettingsTabs-v57M-CCZ.js +1 -0
- package/web/dist/assets/{ShareFilesPage-DBmE6Ewf.js → ShareFilesPage-Ou3F9U8g.js} +1 -1
- package/web/dist/assets/SupportPage-DKGBA5uL.js +2 -0
- package/web/dist/assets/VuvoDeskApp-CP5bEUhN.js +1 -0
- package/web/dist/assets/app-yqW82BcW.js +2 -0
- package/web/dist/assets/{client-8jUbxy3j.js → client-D-D35ghV.js} +1 -1
- package/web/dist/assets/{icons-CuXJn-Hz.js → icons-ChMazCZR.js} +1 -1
- package/web/dist/assets/main-Bnj1Y1s_.js +2 -0
- package/web/dist/assets/main-CVbEVGge.css +1 -0
- package/web/dist/assets/public-site-routing-pddZbLCj.js +1 -0
- package/web/dist/assets/{pwa-startup-DvbrYEQi.js → pwa-startup-BiuYh5eN.js} +1 -1
- package/web/dist/assets/{react-rvm9rmed.js → react-CnkEM7Rb.js} +1 -1
- package/web/dist/assets/vuvodesk-mobile-wall.jpg +0 -0
- package/web/dist/assets/vuvodesk-wall.png +0 -0
- package/web/dist/index.html +8 -8
- package/web/dist/robots.txt +1 -1
- package/web/dist/sitemap.xml +7 -7
- package/web/dist/sw.js +2 -2
- package/web/dist/vuvodesk-build-evidence.json +82 -72
- package/web/dist/assets/CaptureGallery-CBc58XJD.js +0 -1
- package/web/dist/assets/SettingsTabs-CbCpfIYW.js +0 -1
- package/web/dist/assets/SupportPage-BfijaHuW.js +0 -2
- package/web/dist/assets/VuvoDeskApp-CFq0Z-xx.js +0 -1
- package/web/dist/assets/app-CVjARKwD.js +0 -2
- package/web/dist/assets/main-B_PtY6Hx.js +0 -2
- package/web/dist/assets/main-CLwaKs4-.css +0 -1
- 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
|
|
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
|
|
40
|
-
npx -y --prefer-online
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
347
|
-
npx -y --prefer-online
|
|
348
|
-
npx -y --prefer-online
|
|
349
|
-
npx -y --prefer-online
|
|
350
|
-
npx -y --prefer-online
|
|
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
|
|
390
|
-
npx -y --prefer-online
|
|
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
|
|
459
|
-
npx -y --prefer-online
|
|
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
|
|
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
|
|
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
|
|
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'
|
package/client/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@livedesk/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.276",
|
|
4
4
|
"description": "VuvoDesk local remote client",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
"ws": "^8.18.3"
|
|
43
43
|
},
|
|
44
44
|
"optionalDependencies": {
|
|
45
|
-
"@livedesk/fast-linux-x64": "0.1.
|
|
46
|
-
"@livedesk/fast-osx-arm64": "0.1.
|
|
47
|
-
"@livedesk/fast-osx-x64": "0.1.
|
|
48
|
-
"@livedesk/fast-win-x64": "0.1.
|
|
45
|
+
"@livedesk/fast-linux-x64": "0.1.471",
|
|
46
|
+
"@livedesk/fast-osx-arm64": "0.1.471",
|
|
47
|
+
"@livedesk/fast-osx-x64": "0.1.471",
|
|
48
|
+
"@livedesk/fast-win-x64": "0.1.471"
|
|
49
49
|
},
|
|
50
50
|
"publishConfig": {
|
|
51
51
|
"access": "public"
|
|
@@ -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/
|
|
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
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Capture sharing is scoped to one physical monitor. Capability negotiation
|
|
2
|
+
// keeps old Clients on their existing exclusive-capture protocol.
|
|
3
|
+
export function supportsMonitorCapture(device) {
|
|
4
|
+
return device?.capabilities?.liveCaptureConcurrency === 'per-monitor';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function captureOwnersCompete(existing, requested, perMonitor) {
|
|
8
|
+
if (!perMonitor) return true;
|
|
9
|
+
const existingMonitor = existing?.monitorIndex;
|
|
10
|
+
const requestedMonitor = requested?.monitorIndex;
|
|
11
|
+
if (!Number.isInteger(existingMonitor) || !Number.isInteger(requestedMonitor)) return true;
|
|
12
|
+
// Input ownership remains exclusive per computer, even on different screens.
|
|
13
|
+
return existingMonitor === requestedMonitor
|
|
14
|
+
|| (existing.streamPurpose === 'control' && requested.streamPurpose === 'control');
|
|
15
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { captureOwnersCompete, supportsMonitorCapture } from './monitor-capture-ownership.mjs';
|
|
4
|
+
|
|
5
|
+
test('different monitors keep independent capture but the same monitor shares one owner', () => {
|
|
6
|
+
const wall = { monitorIndex: 0, streamPurpose: 'wall' };
|
|
7
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, monitorIndex: 1 }, true), false);
|
|
8
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, streamPurpose: 'control' }, true), true);
|
|
9
|
+
assert.equal(captureOwnersCompete(wall, { monitorIndex: 1, streamPurpose: 'control' }, true), false);
|
|
10
|
+
assert.equal(captureOwnersCompete({ ...wall, streamPurpose: 'control' },
|
|
11
|
+
{ monitorIndex: 1, streamPurpose: 'control' }, true), true);
|
|
12
|
+
assert.equal(captureOwnersCompete(wall, { ...wall, monitorIndex: 1 }, false), true);
|
|
13
|
+
assert.equal(captureOwnersCompete(wall, { streamPurpose: 'wall' }, true), true);
|
|
14
|
+
assert.equal(supportsMonitorCapture({ capabilities: { liveCaptureConcurrency: 'per-monitor' } }), true);
|
|
15
|
+
assert.equal(supportsMonitorCapture({ capabilities: { liveCaptureConcurrency: 'exclusive' } }), false);
|
|
16
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
import { createRemoteHub } from './remote-hub.js';
|
|
6
|
+
|
|
7
|
+
const keyFrame = Buffer.from([
|
|
8
|
+
0, 0, 0, 1, 0x67, 0x42, 0, 0x1f,
|
|
9
|
+
0, 0, 0, 1, 0x68, 0xce, 6, 0xe2,
|
|
10
|
+
0, 0, 0, 1, 0x65, 0x88, 0x84
|
|
11
|
+
]).toString('base64');
|
|
12
|
+
|
|
13
|
+
test('two monitor viewers and a Control handoff cannot replace or stop the other monitor', async () => {
|
|
14
|
+
const hub = createRemoteHub({
|
|
15
|
+
env: { ...process.env, LIVEDESK_REMOTE_HUB: '1', REMOTE_HUB_HOST: '127.0.0.1', REMOTE_HUB_PORT: '0' },
|
|
16
|
+
pairToken: 'monitor-owner-fixture'
|
|
17
|
+
});
|
|
18
|
+
let socket;
|
|
19
|
+
const commands = [];
|
|
20
|
+
const active = new Map();
|
|
21
|
+
const waitUntil = async predicate => {
|
|
22
|
+
const deadline = Date.now() + 2_000;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (predicate()) return;
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 5));
|
|
26
|
+
}
|
|
27
|
+
throw new Error('monitor owner fixture deadline');
|
|
28
|
+
};
|
|
29
|
+
try {
|
|
30
|
+
const status = await hub.start();
|
|
31
|
+
socket = net.createConnection({ host: '127.0.0.1', port: status.port });
|
|
32
|
+
await once(socket, 'connect');
|
|
33
|
+
const send = message => socket.write(`${JSON.stringify(message)}\n`);
|
|
34
|
+
let welcomed = false;
|
|
35
|
+
let input = '';
|
|
36
|
+
let frameSeq = 0;
|
|
37
|
+
const publishFrame = (streamId, payload, commandId) => {
|
|
38
|
+
send({ type: 'stream.open', ...payload, streamId, commandId, width: 960, height: 540 });
|
|
39
|
+
send({ type: 'stream.frame', ...payload, streamId, commandId, frameSeq: ++frameSeq,
|
|
40
|
+
width: 960, height: 540, mimeType: 'video/h264', isKeyFrame: true, chunkType: 'key', data: keyFrame });
|
|
41
|
+
};
|
|
42
|
+
socket.on('data', chunk => {
|
|
43
|
+
input += chunk.toString('utf8');
|
|
44
|
+
let newline;
|
|
45
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
46
|
+
const line = input.slice(0, newline); input = input.slice(newline + 1);
|
|
47
|
+
if (!line.trim()) continue;
|
|
48
|
+
const message = JSON.parse(line);
|
|
49
|
+
if (message.type === 'welcome') welcomed = true;
|
|
50
|
+
if (message.type !== 'command') continue;
|
|
51
|
+
commands.push(message);
|
|
52
|
+
if (message.command === 'stream.start') {
|
|
53
|
+
const p = message.payload;
|
|
54
|
+
for (const [id, owner] of active) {
|
|
55
|
+
assert.ok(id === p.streamId || owner.monitorIndex !== p.monitorIndex,
|
|
56
|
+
'same monitor cannot overlap a second native owner');
|
|
57
|
+
}
|
|
58
|
+
active.set(p.streamId, { ...p, commandId: message.commandId });
|
|
59
|
+
send({ type: 'command.result', commandId: message.commandId, result: {
|
|
60
|
+
stream: true, started: true, ...p, width: 960, height: 540
|
|
61
|
+
} });
|
|
62
|
+
publishFrame(p.streamId, p, message.commandId);
|
|
63
|
+
} else if (message.command === 'stream.stop') {
|
|
64
|
+
const streamId = message.payload.streamId;
|
|
65
|
+
if (streamId) active.delete(streamId); else active.clear();
|
|
66
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
67
|
+
result: { stream: true, stopped: true, streamId, stoppedCount: 1 } });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
send({ type: 'hello', pairToken: 'monitor-owner-fixture', deviceId: 'monitor-device',
|
|
72
|
+
deviceName: 'Monitor fixture', hostname: 'monitor-fixture', platform: 'darwin', arch: 'arm64',
|
|
73
|
+
protocol: 'mindexec.remote.agent', protocolVersion: 2,
|
|
74
|
+
capabilities: { liveStream: true, liveCaptureConcurrency: 'per-monitor', frameProtocol: {}, frameModes: [] } });
|
|
75
|
+
await waitUntil(() => welcomed);
|
|
76
|
+
const options = { streamPurpose: 'wall', mode: 'mode3-h264-hw', frameMode: 'mode3-h264-hw',
|
|
77
|
+
fps: 30, maxWidth: 960, maxHeight: 540, quality: 68, reuseExisting: true, reuseSharedExisting: true };
|
|
78
|
+
const first = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 0 });
|
|
79
|
+
assert.equal(first.ok, true);
|
|
80
|
+
await waitUntil(() => hub.getDeviceLiveFrame('monitor-device', { streamId: first.streamId })?.currentGenerationVerified);
|
|
81
|
+
const second = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 1 });
|
|
82
|
+
assert.equal(second.ok, true);
|
|
83
|
+
assert.notEqual(second.streamId, first.streamId, 'Wall stream ids must include monitor ownership');
|
|
84
|
+
await waitUntil(() => hub.getDeviceLiveFrame('monitor-device', { streamId: second.streamId })?.currentGenerationVerified);
|
|
85
|
+
assert.equal(active.size, 2);
|
|
86
|
+
for (let i = 0; i < 10; i++) {
|
|
87
|
+
const monitorIndex = i % 2;
|
|
88
|
+
const original = monitorIndex ? second : first;
|
|
89
|
+
const reused = hub.startLiveStream('monitor-device', { ...options, monitorIndex, fps: 20 });
|
|
90
|
+
assert.equal(reused.reused, true);
|
|
91
|
+
assert.equal(reused.commandId, original.commandId);
|
|
92
|
+
assert.equal(reused.captureGeneration, original.captureGeneration);
|
|
93
|
+
}
|
|
94
|
+
assert.equal(commands.filter(c => c.command === 'stream.start').length, 2);
|
|
95
|
+
assert.equal(hub.getDeviceLiveFrame('monitor-device', { streamId: first.streamId }).monitorIndex, 0);
|
|
96
|
+
const controlOptions = { ...options, streamPurpose: 'control', monitorIndex: 1 };
|
|
97
|
+
const transition = hub.startLiveStream('monitor-device', controlOptions);
|
|
98
|
+
assert.equal(transition.error, 'CAPTURE_TRANSITION_IN_PROGRESS');
|
|
99
|
+
await transition.transitionPromise;
|
|
100
|
+
const control = hub.startLiveStream('monitor-device', controlOptions);
|
|
101
|
+
assert.equal(control.ok, true);
|
|
102
|
+
await waitUntil(() => active.has(control.streamId));
|
|
103
|
+
assert.ok(active.has(first.streamId));
|
|
104
|
+
assert.ok(!active.has(second.streamId));
|
|
105
|
+
assert.equal(active.size, 2);
|
|
106
|
+
const wallWhileOtherControl = hub.startLiveStream('monitor-device', { ...options, monitorIndex: 0 });
|
|
107
|
+
assert.equal(wallWhileOtherControl.commandId, first.commandId);
|
|
108
|
+
assert.equal(wallWhileOtherControl.captureGeneration, first.captureGeneration);
|
|
109
|
+
const selectedStop = hub.stopLiveStream('monitor-device', { streamPurpose: 'wall', monitorIndex: 0 });
|
|
110
|
+
assert.equal((await selectedStop.stopPromise).captureStopConfirmed, true);
|
|
111
|
+
assert.ok(active.has(control.streamId));
|
|
112
|
+
assert.ok(!active.has(first.streamId));
|
|
113
|
+
const controlStop = hub.stopLiveStream('monitor-device', { streamId: control.streamId, streamPurpose: 'control' });
|
|
114
|
+
assert.equal((await controlStop.stopPromise).captureStopConfirmed, true);
|
|
115
|
+
assert.equal(active.size, 0);
|
|
116
|
+
assert.equal(hub.getLiveCaptureResourceSnapshot('monitor-device').terminal, true);
|
|
117
|
+
} finally {
|
|
118
|
+
socket?.destroy();
|
|
119
|
+
await hub.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { once } from 'node:events';
|
|
4
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import WebSocket from 'ws';
|
|
11
|
+
|
|
12
|
+
async function freePort() {
|
|
13
|
+
const server = net.createServer();
|
|
14
|
+
server.listen(0, '127.0.0.1');
|
|
15
|
+
await once(server, 'listening');
|
|
16
|
+
const port = server.address().port;
|
|
17
|
+
await new Promise(resolve => server.close(resolve));
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function until(predicate, label, timeout = 6000) {
|
|
22
|
+
const deadline = Date.now() + timeout;
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (await predicate()) return;
|
|
25
|
+
await new Promise(resolve => setTimeout(resolve, 15));
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Monitor viewers: ${label}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test('real frame sockets keep different monitor bindings and release only the last viewer of each monitor', {
|
|
31
|
+
timeout: 25000
|
|
32
|
+
}, async () => {
|
|
33
|
+
const httpPort = await freePort();
|
|
34
|
+
let agentPort = await freePort();
|
|
35
|
+
while (agentPort === httpPort) agentPort = await freePort();
|
|
36
|
+
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'vuvodesk-monitor-viewers-'));
|
|
37
|
+
const base = `http://127.0.0.1:${httpPort}`;
|
|
38
|
+
const deviceId = 'two-monitor-agent';
|
|
39
|
+
const pairToken = 'two-monitor-fixture';
|
|
40
|
+
const root = fileURLToPath(new URL('../../../', import.meta.url));
|
|
41
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('./server.js', import.meta.url))], {
|
|
42
|
+
cwd: root, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
43
|
+
env: { ...process.env, LIVEDESK_DATA_DIR: dataDir,
|
|
44
|
+
LIVEDESK_HUB_HTTP_HOST: '127.0.0.1', LIVEDESK_HUB_HTTP_PORT: String(httpPort),
|
|
45
|
+
REMOTE_HUB_HOST: '127.0.0.1', REMOTE_HUB_PORT: String(agentPort),
|
|
46
|
+
REMOTE_HUB_PAIR_TOKEN: pairToken, REMOTE_HUB_LIVE_CAPTURE_STOP_ACK_TIMEOUT_MS: '750',
|
|
47
|
+
LIVEDESK_FRAME_STREAM_STOP_GRACE_MS: '25', LIVEDESK_PUBLIC_IP_DISCOVERY_DISABLED: '1',
|
|
48
|
+
REMOTE_HUB_PUBLIC_IP_DISCOVERY_DISABLED: '1', LIVEDESK_TEST_MODE: '1',
|
|
49
|
+
LIVEDESK_TEST_LICENSE_PLAN: 'pro', LIVEDESK_UDP_ENABLED: '0' }
|
|
50
|
+
});
|
|
51
|
+
const output = [];
|
|
52
|
+
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => {
|
|
53
|
+
output.push(String(chunk)); while (output.length > 40) output.shift();
|
|
54
|
+
});
|
|
55
|
+
const sockets = [];
|
|
56
|
+
const commands = [];
|
|
57
|
+
const active = new Map();
|
|
58
|
+
const media = new Map();
|
|
59
|
+
const mediaSockets = [];
|
|
60
|
+
let agent, welcomed = false, sessionId = '', timer, sequence = 0;
|
|
61
|
+
const send = message => agent.write(`${JSON.stringify(message)}\n`);
|
|
62
|
+
const frameMessage = owner => ({ type: 'stream.frame', ...owner, frameSeq: ++sequence,
|
|
63
|
+
width: 960, height: 540, codec: 'h264', mimeType: 'video/h264',
|
|
64
|
+
isKeyFrame: true, chunkType: 'key', data: Buffer.from([
|
|
65
|
+
0, 0, 0, 1, 0x67, 0x42, 0, 0x1f, 0, 0, 0, 1, 0x68, 0xce, 6, 0xe2,
|
|
66
|
+
0, 0, 0, 1, 0x65, 0x88, 0x84
|
|
67
|
+
]).toString('base64') });
|
|
68
|
+
const frame = owner => {
|
|
69
|
+
const socket = media.get(owner.streamId);
|
|
70
|
+
if (socket && !socket.destroyed && socket.readyForFrames)
|
|
71
|
+
socket.write(`${JSON.stringify(frameMessage(owner))}\n`);
|
|
72
|
+
};
|
|
73
|
+
async function connectMedia(owner, expectAccepted = true) {
|
|
74
|
+
const socket = net.createConnection({ host: '127.0.0.1', port: agentPort });
|
|
75
|
+
mediaSockets.push(socket);
|
|
76
|
+
const received = [];
|
|
77
|
+
let pending = '';
|
|
78
|
+
socket.on('data', data => {
|
|
79
|
+
pending += String(data);
|
|
80
|
+
let newline;
|
|
81
|
+
while ((newline = pending.indexOf('\n')) >= 0) {
|
|
82
|
+
const raw = pending.slice(0, newline); pending = pending.slice(newline + 1);
|
|
83
|
+
if (raw.trim()) received.push(JSON.parse(raw));
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
await once(socket, 'connect');
|
|
87
|
+
socket.write(`${JSON.stringify({ type: 'hello', channel: 'frame', deviceId, pairToken,
|
|
88
|
+
parentSessionId: sessionId, frameOwner: { streamId: owner.streamId, commandId: owner.commandId,
|
|
89
|
+
captureGeneration: owner.captureGeneration, monitorIndex: owner.monitorIndex } })}\n`);
|
|
90
|
+
await until(() => received.length > 0, 'monitor channel welcome');
|
|
91
|
+
if (expectAccepted) {
|
|
92
|
+
assert.equal(received[0].type, 'welcome');
|
|
93
|
+
assert.equal(received[0].frameOwner.monitorIndex, owner.monitorIndex);
|
|
94
|
+
socket.readyForFrames = true;
|
|
95
|
+
media.set(owner.streamId, socket);
|
|
96
|
+
} else {
|
|
97
|
+
assert.equal(received[0].error, 'frame-channel-owner-stale');
|
|
98
|
+
await until(() => socket.destroyed, 'stale channel rejection');
|
|
99
|
+
}
|
|
100
|
+
return socket;
|
|
101
|
+
}
|
|
102
|
+
async function connectViewer(monitorIndex, streamPurpose = 'wall') {
|
|
103
|
+
const ws = new WebSocket(`ws://127.0.0.1:${httpPort}/api/remote/frames/ws?devices=${deviceId}`,
|
|
104
|
+
{ headers: { Origin: base }, perMessageDeflate: false });
|
|
105
|
+
sockets.push(ws);
|
|
106
|
+
const messages = [], frames = [];
|
|
107
|
+
ws.on('message', (data, binary) => {
|
|
108
|
+
if (binary) {
|
|
109
|
+
const length = data.readUInt32BE(0);
|
|
110
|
+
frames.push(JSON.parse(data.subarray(4, 4 + length).toString()));
|
|
111
|
+
} else messages.push(JSON.parse(String(data)));
|
|
112
|
+
});
|
|
113
|
+
await once(ws, 'open');
|
|
114
|
+
await until(() => messages.some(m => m.type === 'RemoteFrameSocketReady'), 'socket ready');
|
|
115
|
+
ws.send(JSON.stringify({ type: 'subscribe', deviceIds: [deviceId], autoStartLive: true,
|
|
116
|
+
allowReadOnlyControlBorrow: true, streamPurpose, monitorIndex,
|
|
117
|
+
mode: 'mode3-h264-hw', frameMode: 'mode3-h264-hw', fps: 30,
|
|
118
|
+
maxWidth: 960, maxHeight: 540, quality: 68 }));
|
|
119
|
+
await until(() => frames.length > 0, `monitor ${monitorIndex} first frame`);
|
|
120
|
+
return { ws, messages, frames, monitorIndex };
|
|
121
|
+
}
|
|
122
|
+
const starts = () => commands.filter(c => c.command === 'stream.start');
|
|
123
|
+
const close = async viewer => { viewer.ws.close(); await once(viewer.ws, 'close'); };
|
|
124
|
+
try {
|
|
125
|
+
await until(async () => {
|
|
126
|
+
if (child.exitCode !== null) throw new Error(`Hub exited: ${output.join('')}`);
|
|
127
|
+
try { return (await fetch(`${base}/api/health`)).ok; } catch { return false; }
|
|
128
|
+
}, 'isolated Hub health', 10000);
|
|
129
|
+
agent = net.createConnection({ host: '127.0.0.1', port: agentPort });
|
|
130
|
+
await once(agent, 'connect');
|
|
131
|
+
let input = '';
|
|
132
|
+
agent.on('data', chunk => {
|
|
133
|
+
input += String(chunk);
|
|
134
|
+
let newline;
|
|
135
|
+
while ((newline = input.indexOf('\n')) >= 0) {
|
|
136
|
+
const raw = input.slice(0, newline); input = input.slice(newline + 1);
|
|
137
|
+
if (!raw.trim()) continue;
|
|
138
|
+
const message = JSON.parse(raw);
|
|
139
|
+
if (message.type === 'welcome') { welcomed = true; sessionId = message.sessionId; }
|
|
140
|
+
if (message.type !== 'command') continue;
|
|
141
|
+
commands.push(message);
|
|
142
|
+
if (message.command === 'stream.start') {
|
|
143
|
+
const owner = { ...message.payload, commandId: message.commandId };
|
|
144
|
+
active.set(owner.streamId, owner);
|
|
145
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
146
|
+
result: { ...owner, stream: true, started: true } });
|
|
147
|
+
send({ type: 'stream.open', ...owner, width: 960, height: 540 });
|
|
148
|
+
void connectMedia(owner).then(() => frame(owner));
|
|
149
|
+
} else if (message.command === 'stream.stop') {
|
|
150
|
+
if (message.payload.streamId) {
|
|
151
|
+
media.get(message.payload.streamId)?.destroy();
|
|
152
|
+
media.delete(message.payload.streamId);
|
|
153
|
+
active.delete(message.payload.streamId);
|
|
154
|
+
} else {
|
|
155
|
+
for (const socket of media.values()) socket.destroy();
|
|
156
|
+
media.clear(); active.clear();
|
|
157
|
+
}
|
|
158
|
+
send({ type: 'command.result', commandId: message.commandId,
|
|
159
|
+
result: { stream: true, stopped: true, streamId: message.payload.streamId } });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
send({ type: 'hello', pairToken, deviceId, deviceName: 'Two monitor fixture', platform: 'darwin',
|
|
164
|
+
arch: 'arm64', protocol: 'mindexec.remote.agent', protocolVersion: 2,
|
|
165
|
+
capabilities: { liveStream: true, liveCaptureConcurrency: 'per-monitor', frameProtocol: {}, frameModes: [] } });
|
|
166
|
+
await until(() => welcomed, 'Agent welcome');
|
|
167
|
+
timer = setInterval(() => { for (const owner of active.values()) frame(owner); }, 40);
|
|
168
|
+
const hub = await connectViewer(0);
|
|
169
|
+
const pwa = await connectViewer(1);
|
|
170
|
+
const phone = await connectViewer(0);
|
|
171
|
+
await until(() => hub.frames.length >= 4 && pwa.frames.length >= 4 && phone.frames.length >= 4,
|
|
172
|
+
'all three viewers receive frames');
|
|
173
|
+
assert.equal(starts().length, 2, 'same monitor viewer created duplicate capture');
|
|
174
|
+
const original = [hub.frames[0], pwa.frames[0]];
|
|
175
|
+
for (const viewer of [hub, pwa, phone]) {
|
|
176
|
+
assert.ok(viewer.frames.every(f => f.monitorIndex === viewer.monitorIndex), 'cross-monitor frame delivery');
|
|
177
|
+
assert.ok(viewer.frames.every(f => f.commandId === original[viewer.monitorIndex].commandId), 'capture churn');
|
|
178
|
+
}
|
|
179
|
+
const zero = [...active.values()].find(o => o.monitorIndex === 0);
|
|
180
|
+
const one = [...active.values()].find(o => o.monitorIndex === 1);
|
|
181
|
+
await connectMedia({ ...zero, captureGeneration: zero.captureGeneration + 100 }, false);
|
|
182
|
+
assert.equal(media.get(zero.streamId).destroyed, false, 'stale hello replaced current monitor channel');
|
|
183
|
+
const wrongChannel = media.get(zero.streamId);
|
|
184
|
+
wrongChannel.write(`${JSON.stringify(frameMessage(one))}\n`);
|
|
185
|
+
await until(() => wrongChannel.destroyed, 'cross-monitor packet closes only its channel');
|
|
186
|
+
const otherCount = pwa.frames.length;
|
|
187
|
+
await until(() => pwa.frames.length >= otherCount + 3, 'healthy monitor survives peer channel failure');
|
|
188
|
+
await connectMedia(zero);
|
|
189
|
+
const resumedCount = hub.frames.length;
|
|
190
|
+
await until(() => hub.frames.length >= resumedCount + 3, 'exact monitor channel reconnects');
|
|
191
|
+
assert.equal(starts().length, 2, 'transport reconnect restarted native capture');
|
|
192
|
+
await close(hub);
|
|
193
|
+
const phoneCount = phone.frames.length;
|
|
194
|
+
await until(() => phone.frames.length >= phoneCount + 3, 'same-monitor peer continues after close');
|
|
195
|
+
assert.equal(active.size, 2);
|
|
196
|
+
await close(pwa);
|
|
197
|
+
await until(() => active.size === 1, 'last monitor-one viewer stops capture');
|
|
198
|
+
assert.equal([...active.values()][0].monitorIndex, 0);
|
|
199
|
+
assert.equal([...active.values()][0].commandId, original[0].commandId);
|
|
200
|
+
const rejoined = await connectViewer(1);
|
|
201
|
+
assert.equal(starts().length, 3);
|
|
202
|
+
assert.notEqual(rejoined.frames[0].captureGeneration, original[1].captureGeneration);
|
|
203
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
204
|
+
const controller = await connectViewer(1, 'control');
|
|
205
|
+
await until(() => rejoined.frames.some(f => f.streamPurpose === 'control'), 'same-monitor Wall borrows Control');
|
|
206
|
+
assert.equal(starts().length, 4);
|
|
207
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
208
|
+
const borrowedIndex = rejoined.frames.length;
|
|
209
|
+
await close(controller);
|
|
210
|
+
await until(() => rejoined.frames.slice(borrowedIndex).some(f => f.streamPurpose === 'wall'),
|
|
211
|
+
'Control exit restores only its monitor Wall');
|
|
212
|
+
assert.equal(starts().length, 5);
|
|
213
|
+
assert.equal([...active.values()].find(o => o.monitorIndex === 0).commandId, original[0].commandId);
|
|
214
|
+
await close(rejoined);
|
|
215
|
+
await close(phone);
|
|
216
|
+
await until(() => active.size === 0, 'final viewer cleanup');
|
|
217
|
+
assert.equal(commands.filter(c => c.command === 'stream.stop').length, 5);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
error.message += `\nIsolated Hub tail: ${output.join('').slice(-2500)}`;
|
|
220
|
+
throw error;
|
|
221
|
+
} finally {
|
|
222
|
+
clearInterval(timer);
|
|
223
|
+
for (const socket of sockets) socket.terminate();
|
|
224
|
+
for (const socket of mediaSockets) socket.destroy();
|
|
225
|
+
agent?.destroy();
|
|
226
|
+
child.kill();
|
|
227
|
+
if (child.exitCode === null) await once(child, 'exit');
|
|
228
|
+
}
|
|
229
|
+
});
|