@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.
@@ -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 = 1;
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: false,
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
- this.record = {
33
- revision: Math.max(0, Number(raw?.revision) || 0),
34
- updatedAt: String(raw?.updatedAt || ''),
35
- settings: normalizeLiveDeskSettings(raw?.settings || raw)
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
+ });