@livedesk/hub 0.1.58 → 0.1.61
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/package.json +1 -1
- package/src/auth/workspace-access.js +176 -0
- package/src/console-direct.js +329 -10
- package/src/console-direct.test.mjs +332 -3
- package/src/control-presentation-borrow-contract.test.mjs +999 -0
- package/src/http/hub-ui-session.js +155 -32
- package/src/live-stream-monitor-contract.js +195 -3
- package/src/remote-hub.js +108 -56
- package/src/server.js +1324 -396
- package/src/settings/settings-schema.js +25 -6
- package/src/settings/settings-store.js +9 -8
- package/src/wall-source-restart-contract.test.mjs +48 -0
- package/src/wall-source-restart-runtime.test.mjs +146 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
export const SETTINGS_SCHEMA_VERSION =
|
|
4
|
+
export const SETTINGS_SCHEMA_VERSION = 2;
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
|
|
7
7
|
settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
@@ -80,9 +80,9 @@ export const DEFAULT_LIVEDESK_SETTINGS = Object.freeze({
|
|
|
80
80
|
includeRemoteCursor: true,
|
|
81
81
|
captureSaveLocation: 'VuvoDesk Captures',
|
|
82
82
|
captureAutoDelete: 'never'
|
|
83
|
-
},
|
|
84
|
-
agent: {
|
|
85
|
-
enabled:
|
|
83
|
+
},
|
|
84
|
+
agent: {
|
|
85
|
+
enabled: true,
|
|
86
86
|
defaultPermissionMode: 'safe-auto',
|
|
87
87
|
askBeforeDestructive: true,
|
|
88
88
|
allowProcessManagement: true,
|
|
@@ -156,8 +156,27 @@ function normalizeSection(source, defaults, rules = {}) {
|
|
|
156
156
|
return result;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
|
|
160
|
-
const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
|
|
159
|
+
const bools = keys => Object.fromEntries(keys.map(key => [key, { type: 'boolean' }]));
|
|
160
|
+
const numbers = (entries) => Object.fromEntries(entries.map(([key, min, max]) => [key, { type: 'number', min, max }]));
|
|
161
|
+
|
|
162
|
+
export function migrateLiveDeskSettings(value = {}) {
|
|
163
|
+
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
164
|
+
const storedVersion = Math.max(0, Math.trunc(Number(source.settingsSchemaVersion) || 0));
|
|
165
|
+
if (storedVersion >= SETTINGS_SCHEMA_VERSION) return source;
|
|
166
|
+
const agent = source.agent && typeof source.agent === 'object' && !Array.isArray(source.agent)
|
|
167
|
+
? source.agent
|
|
168
|
+
: {};
|
|
169
|
+
return {
|
|
170
|
+
...source,
|
|
171
|
+
settingsSchemaVersion: SETTINGS_SCHEMA_VERSION,
|
|
172
|
+
agent: {
|
|
173
|
+
...agent,
|
|
174
|
+
// Schema 1 shipped disabled-by-default. Schema 2 treats that old value
|
|
175
|
+
// as the retired default; a later explicit opt-out is stored as schema 2.
|
|
176
|
+
enabled: true
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
161
180
|
|
|
162
181
|
const RULES = {
|
|
163
182
|
connection: {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { DEFAULT_LIVEDESK_SETTINGS, defaultSettingsPath, normalizeLiveDeskSettings, publicSettings } from './settings-schema.js';
|
|
3
|
+
import { DEFAULT_LIVEDESK_SETTINGS, defaultSettingsPath, migrateLiveDeskSettings, normalizeLiveDeskSettings, publicSettings } from './settings-schema.js';
|
|
4
4
|
|
|
5
5
|
export class SettingsConflictError extends Error {
|
|
6
6
|
constructor(settings) {
|
|
@@ -27,13 +27,14 @@ export class LiveDeskSettingsStore {
|
|
|
27
27
|
|
|
28
28
|
async getRecord() {
|
|
29
29
|
if (this.record) return structuredClone(this.record);
|
|
30
|
-
try {
|
|
31
|
-
const raw = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(await readFile(this.filePath, 'utf8'));
|
|
32
|
+
const migratedSettings = migrateLiveDeskSettings(raw?.settings || raw);
|
|
33
|
+
this.record = {
|
|
34
|
+
revision: Math.max(0, Number(raw?.revision) || 0),
|
|
35
|
+
updatedAt: String(raw?.updatedAt || ''),
|
|
36
|
+
settings: normalizeLiveDeskSettings(migratedSettings)
|
|
37
|
+
};
|
|
37
38
|
} catch {
|
|
38
39
|
this.record = { revision: 0, updatedAt: '', settings: normalizeLiveDeskSettings(DEFAULT_LIVEDESK_SETTINGS) };
|
|
39
40
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import test from 'node:test';
|
|
4
|
+
|
|
5
|
+
const [remoteHubSource, serverSource] = await Promise.all([
|
|
6
|
+
readFile(new URL('./remote-hub.js', import.meta.url), 'utf8'),
|
|
7
|
+
readFile(new URL('./server.js', import.meta.url), 'utf8')
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
function sourceSlice(source, startMarker, endMarker) {
|
|
11
|
+
const start = source.indexOf(startMarker);
|
|
12
|
+
const end = source.indexOf(endMarker, start + startMarker.length);
|
|
13
|
+
assert.ok(start >= 0 && end > start, `missing source slice: ${startMarker}`);
|
|
14
|
+
return source.slice(start, end);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('a recovered frame subscription asks the Hub to reuse its healthy source', () => {
|
|
18
|
+
const subscriptionStart = sourceSlice(
|
|
19
|
+
serverSource,
|
|
20
|
+
'function startFrameSubscriptionLive(',
|
|
21
|
+
'function restartFrameSubscriptionLive('
|
|
22
|
+
);
|
|
23
|
+
assert.match(
|
|
24
|
+
subscriptionStart,
|
|
25
|
+
/reuseExisting: liveOptions\.forceRestart !== true[\s\S]{0,180}reason === 'subscribe' \|\| reason === 'watchdog'/
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('the atomic Hub start decision reuses only a matching live source', () => {
|
|
30
|
+
const liveStart = sourceSlice(
|
|
31
|
+
remoteHubSource,
|
|
32
|
+
'function startLiveStream(deviceId, options = {})',
|
|
33
|
+
'function stopLiveStream('
|
|
34
|
+
);
|
|
35
|
+
assert.match(
|
|
36
|
+
liveStart,
|
|
37
|
+
/options\.reuseExisting === true[\s\S]{0,160}liveStreamMatchesOptions\(activeLiveStream, normalized\)[\s\S]{0,160}liveStreamIsReusable\(activeLiveStream\)[\s\S]{0,900}reused: true/
|
|
38
|
+
);
|
|
39
|
+
assert.match(
|
|
40
|
+
liveStart,
|
|
41
|
+
/!liveStreamIsReusable\(activeLiveStream\)[\s\S]{0,900}RemoteLiveStreamStaleRestart[\s\S]{0,900}const captureGeneration = nextCaptureGeneration\(device\)/
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('Wall lane recovery has no special native-restart wire option', () => {
|
|
46
|
+
assert.doesNotMatch(serverSource, /restartIfSourceStale|sourceFreshRestartSuppressed/);
|
|
47
|
+
assert.doesNotMatch(remoteHubSource, /restartIfSourceStale|sourceFreshRestartSuppressed/);
|
|
48
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
test('a new Wall frame lane reuses a healthy exact capture', async () => {
|
|
8
|
+
const hub = createRemoteHub({
|
|
9
|
+
env: {
|
|
10
|
+
...process.env,
|
|
11
|
+
LIVEDESK_REMOTE_HUB: '1',
|
|
12
|
+
REMOTE_HUB_HOST: '127.0.0.1',
|
|
13
|
+
REMOTE_HUB_PORT: '0'
|
|
14
|
+
},
|
|
15
|
+
pairToken: 'wall-source-reuse-token'
|
|
16
|
+
});
|
|
17
|
+
let socket;
|
|
18
|
+
try {
|
|
19
|
+
const status = await hub.start();
|
|
20
|
+
socket = net.createConnection({ host: '127.0.0.1', port: status.port });
|
|
21
|
+
await once(socket, 'connect');
|
|
22
|
+
|
|
23
|
+
const messages = [];
|
|
24
|
+
let buffer = '';
|
|
25
|
+
socket.on('data', chunk => {
|
|
26
|
+
buffer += chunk.toString('utf8');
|
|
27
|
+
let newline = buffer.indexOf('\n');
|
|
28
|
+
while (newline >= 0) {
|
|
29
|
+
const line = buffer.slice(0, newline).trim();
|
|
30
|
+
buffer = buffer.slice(newline + 1);
|
|
31
|
+
if (line) messages.push(JSON.parse(line));
|
|
32
|
+
newline = buffer.indexOf('\n');
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
const waitUntil = async predicate => {
|
|
36
|
+
const deadline = Date.now() + 2_000;
|
|
37
|
+
while (Date.now() < deadline) {
|
|
38
|
+
if (predicate()) return;
|
|
39
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
40
|
+
}
|
|
41
|
+
throw new Error('timed out waiting for Wall source reuse fixture');
|
|
42
|
+
};
|
|
43
|
+
const send = message => socket.write(`${JSON.stringify(message)}\n`);
|
|
44
|
+
|
|
45
|
+
send({
|
|
46
|
+
type: 'hello',
|
|
47
|
+
pairToken: 'wall-source-reuse-token',
|
|
48
|
+
deviceId: 'wall-source-device',
|
|
49
|
+
deviceName: 'Wall source reuse fixture',
|
|
50
|
+
hostname: 'wall-source-reuse',
|
|
51
|
+
platform: 'darwin',
|
|
52
|
+
arch: 'arm64',
|
|
53
|
+
protocol: 'mindexec.remote.agent',
|
|
54
|
+
protocolVersion: 2,
|
|
55
|
+
capabilities: { liveStream: true, frameProtocol: {}, frameModes: [] }
|
|
56
|
+
});
|
|
57
|
+
await waitUntil(() => messages.some(message => message.type === 'welcome'));
|
|
58
|
+
|
|
59
|
+
const streamId = 'wall-wall-source-device';
|
|
60
|
+
const options = {
|
|
61
|
+
streamId,
|
|
62
|
+
streamPurpose: 'wall',
|
|
63
|
+
mode: 'mode3-h264-hw',
|
|
64
|
+
frameMode: 'mode3-h264-hw',
|
|
65
|
+
fps: 30,
|
|
66
|
+
maxWidth: 960,
|
|
67
|
+
maxHeight: 540,
|
|
68
|
+
quality: 68,
|
|
69
|
+
monitorIndex: 0
|
|
70
|
+
};
|
|
71
|
+
const first = hub.startLiveStream('wall-source-device', {
|
|
72
|
+
...options,
|
|
73
|
+
commandId: 'wall-command-1',
|
|
74
|
+
forceRestart: true,
|
|
75
|
+
restartToken: 'initial-wall-start'
|
|
76
|
+
});
|
|
77
|
+
assert.equal(first.ok, true);
|
|
78
|
+
await waitUntil(() => messages.some(message => (
|
|
79
|
+
message.type === 'command'
|
|
80
|
+
&& message.command === 'stream.start'
|
|
81
|
+
&& message.commandId === 'wall-command-1'
|
|
82
|
+
)));
|
|
83
|
+
|
|
84
|
+
send({
|
|
85
|
+
type: 'stream.open',
|
|
86
|
+
streamId,
|
|
87
|
+
commandId: 'wall-command-1',
|
|
88
|
+
captureGeneration: first.captureGeneration,
|
|
89
|
+
monitorIndex: 0,
|
|
90
|
+
streamPurpose: 'wall',
|
|
91
|
+
mode: 'mode3-h264-hw',
|
|
92
|
+
frameMode: 'mode3-h264-hw',
|
|
93
|
+
width: 960,
|
|
94
|
+
height: 540
|
|
95
|
+
});
|
|
96
|
+
send({
|
|
97
|
+
type: 'stream.frame',
|
|
98
|
+
streamId,
|
|
99
|
+
commandId: 'wall-command-1',
|
|
100
|
+
captureGeneration: first.captureGeneration,
|
|
101
|
+
monitorIndex: 0,
|
|
102
|
+
streamPurpose: 'wall',
|
|
103
|
+
frameSeq: 1,
|
|
104
|
+
frameMode: 'mode3-h264-hw',
|
|
105
|
+
mimeType: 'video/h264',
|
|
106
|
+
isKeyFrame: true,
|
|
107
|
+
chunkType: 'key',
|
|
108
|
+
width: 960,
|
|
109
|
+
height: 540,
|
|
110
|
+
data: Buffer.from([
|
|
111
|
+
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1f,
|
|
112
|
+
0x00, 0x00, 0x00, 0x01, 0x68, 0xce, 0x06, 0xe2,
|
|
113
|
+
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x84
|
|
114
|
+
]).toString('base64')
|
|
115
|
+
});
|
|
116
|
+
await waitUntil(() => hub.getDeviceLiveFrame('wall-source-device')?.currentGenerationVerified === true);
|
|
117
|
+
|
|
118
|
+
const commandCountBeforeReuse = messages.filter(message => message.command === 'stream.start').length;
|
|
119
|
+
const reused = hub.startLiveStream('wall-source-device', {
|
|
120
|
+
...options,
|
|
121
|
+
reuseExisting: true
|
|
122
|
+
});
|
|
123
|
+
assert.equal(reused.ok, true);
|
|
124
|
+
assert.equal(reused.reused, true);
|
|
125
|
+
assert.equal(reused.commandId, first.commandId);
|
|
126
|
+
assert.equal(reused.captureGeneration, first.captureGeneration);
|
|
127
|
+
await new Promise(resolve => setTimeout(resolve, 20));
|
|
128
|
+
assert.equal(
|
|
129
|
+
messages.filter(message => message.command === 'stream.start').length,
|
|
130
|
+
commandCountBeforeReuse,
|
|
131
|
+
'a replacement browser lane must not restart a healthy shared capture'
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const hardRestart = hub.startLiveStream('wall-source-device', {
|
|
135
|
+
...options,
|
|
136
|
+
commandId: 'wall-command-hard-restart',
|
|
137
|
+
forceRestart: true,
|
|
138
|
+
restartToken: 'explicit-owner-restart'
|
|
139
|
+
});
|
|
140
|
+
assert.equal(hardRestart.ok, true);
|
|
141
|
+
assert.ok(hardRestart.captureGeneration > first.captureGeneration);
|
|
142
|
+
} finally {
|
|
143
|
+
socket?.destroy();
|
|
144
|
+
await hub.close();
|
|
145
|
+
}
|
|
146
|
+
});
|