livedesk 0.1.455 → 0.1.456

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 CHANGED
@@ -51,7 +51,7 @@ Pass `--no-clean` to disable the startup cleanup explicitly. If a child Hub
51
51
  still loses a startup race with `EADDRINUSE`, the launcher performs one cleanup
52
52
  and startup retry, then exits without looping.
53
53
 
54
- ## Client compatibility
54
+ ## Client compatibility
55
55
 
56
56
  ```powershell
57
57
  npx -y --prefer-online livedesk@latest client 3
@@ -61,9 +61,36 @@ The `client` form remains as a legacy compatibility alias. It records the
61
61
  Client role and uses the same unified runtime lock. The client signs in with
62
62
  Google, discovers the active Hub, and connects to the wall. On Windows, enable
63
63
  **Start with Windows** on the connection page to reconnect automatically after
64
- reboot; the generated startup command uses the unified launcher.
65
-
66
- ## Frame modes
64
+ reboot; the generated startup command uses the unified launcher.
65
+
66
+ ## macOS physical diagnostic
67
+
68
+ On a Mac with two connected displays, stop every running LiveDesk runtime,
69
+ show visibly different content on the two displays, grant Screen Recording and
70
+ Accessibility permissions, then run:
71
+
72
+ ```bash
73
+ npx -y --prefer-online livedesk@latest diagnose mac-physical
74
+ ```
75
+
76
+ This command branches before role resolution, state migration, and the normal
77
+ runtime lock. It refuses to overlap an existing LiveDesk runtime or capture
78
+ helper, and normal macOS startup reciprocally refuses an active diagnostic
79
+ lease. It starts only an isolated loopback Hub and temporary RemoteFast Client
80
+ and stops only the exact children it created. Ctrl+C and termination signals
81
+ join the same owned cleanup before exit. The default JSON report is written to
82
+ one final path after cleanup, and run/cleanup errors always produce a failed
83
+ result. It is written under `~/.livedesk/diagnostics`. To select a path relative
84
+ to the directory where the command was invoked:
85
+
86
+ ```bash
87
+ npx -y --prefer-online livedesk@latest diagnose mac-physical --report ./mac-result.json
88
+ ```
89
+
90
+ Use `diagnose mac-physical --help` to inspect requirements without starting a
91
+ runtime or changing cached role/account state.
92
+
93
+ ## Frame modes
67
94
 
68
95
  - Mode 2: independent 320x180 RGB565+LZO tiles for large, stable walls.
69
96
  - Mode 3: direct hardware H.264 for focused remote control up to 4K.
package/bin/livedesk.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
35
35
  import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
36
36
  import { readWindowsOwnedProcessManifest } from '../client/src/runtime/windows-owned-process-manifest.js';
37
+ import { inspectMacDiagnosticLease } from '../diagnostics/livedesk-mac-physical-isolation.mjs';
37
38
 
38
39
  const require = createRequire(import.meta.url);
39
40
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -143,6 +144,22 @@ function isPidAlive(pid) {
143
144
  }
144
145
  }
145
146
 
147
+ async function assertNoActiveMacPhysicalDiagnosticLease() {
148
+ if (os.platform() !== 'darwin') return;
149
+ const lease = await inspectMacDiagnosticLease();
150
+ if (lease.status === 'missing' || lease.status === 'stale') return;
151
+ if (lease.status === 'active') {
152
+ throw new Error(
153
+ `Mac physical diagnostic is running (pid ${lease.pid}); normal LiveDesk startup was not started. `
154
+ + 'Wait for its owned cleanup to finish or stop the diagnostic with Ctrl+C.'
155
+ );
156
+ }
157
+ throw new Error(
158
+ `Mac physical diagnostic ownership is unreadable at ${lease.path}; normal LiveDesk startup was not started `
159
+ + 'to avoid overlapping a capture runtime.'
160
+ );
161
+ }
162
+
146
163
  function canClaimHubUpdateResult(current, next) {
147
164
  const currentOperationId = String(current?.operationId || '');
148
165
  const nextOperationId = String(next?.operationId || '');
@@ -312,15 +329,18 @@ function printHelp() {
312
329
  process.stdout.write(`
313
330
  LiveDesk
314
331
 
315
- Usage:
316
- npx -y --prefer-online livedesk@latest
317
- npx -y --prefer-online livedesk@latest --force-role hub
318
- npx -y --prefer-online livedesk@latest --force-role client
319
-
320
- Commands:
321
- No command Resolve the signed-in device role and start the matching runtime.
322
- hub Legacy alias for --force-role hub.
323
- client Legacy alias for --force-role client.
332
+ Usage:
333
+ npx -y --prefer-online livedesk@latest
334
+ npx -y --prefer-online livedesk@latest --force-role hub
335
+ npx -y --prefer-online livedesk@latest --force-role client
336
+ npx -y --prefer-online livedesk@latest diagnose mac-physical
337
+
338
+ Commands:
339
+ No command Resolve the signed-in device role and start the matching runtime.
340
+ hub Legacy alias for --force-role hub.
341
+ client Legacy alias for --force-role client.
342
+ diagnose mac-physical
343
+ Run the isolated two-display macOS capture/control physical gate.
324
344
 
325
345
  Hub options:
326
346
  --no-open Do not open the browser automatically.
@@ -342,10 +362,63 @@ Common:
342
362
  Do not ask for a role on first run; fail if no role is known.
343
363
  --help Show this help.
344
364
  --version Show version.
345
- `.trimStart());
346
- }
347
-
348
- function printClientHelp() {
365
+ `.trimStart());
366
+ }
367
+
368
+ function printMacPhysicalDiagnosticHelp() {
369
+ process.stdout.write(`
370
+ LiveDesk macOS physical diagnostic
371
+
372
+ Usage:
373
+ npx -y --prefer-online livedesk@latest diagnose mac-physical
374
+ npx -y --prefer-online livedesk@latest diagnose mac-physical --report ./mac-result.json
375
+
376
+ Requirements:
377
+ macOS with two connected displays showing visibly different content.
378
+ Screen Recording permission for the terminal/LiveDesk capture helper.
379
+ Accessibility permission for the terminal/LiveDesk input helper.
380
+ Stop every existing LiveDesk Hub, Client, and desktop runtime first.
381
+
382
+ Behavior:
383
+ Starts one isolated loopback Hub and one temporary RemoteFast Client.
384
+ Refuses to overlap a running LiveDesk runtime or capture helper.
385
+ Stops only the exact child processes created by this diagnostic.
386
+ Writes the default report under ~/.livedesk/diagnostics.
387
+
388
+ Options:
389
+ --report <path> Write the JSON report relative to the invocation directory.
390
+ --monitor <n> Initial zero-based display index. Default: 0.
391
+ --width <px> Requested capture width. Default: 1920.
392
+ --height <px> Requested capture height. Default: 1080.
393
+ --help Show this help without resolving a role or acquiring a runtime lock.
394
+ `.trimStart());
395
+ }
396
+
397
+ async function runDiagnosticCommand(args) {
398
+ const name = String(args[0] || '').trim().toLowerCase();
399
+ if (!name || name === '--help' || name === '-h' || name === 'help') {
400
+ printMacPhysicalDiagnosticHelp();
401
+ return;
402
+ }
403
+ if (name !== 'mac-physical') {
404
+ throw new Error(`Unknown LiveDesk diagnostic: ${name}. Supported diagnostic: mac-physical.`);
405
+ }
406
+ if (args.slice(1).some(argument => ['--help', '-h', 'help'].includes(String(argument).toLowerCase()))) {
407
+ printMacPhysicalDiagnosticHelp();
408
+ return;
409
+ }
410
+ const diagnosticEntry = resolve(
411
+ packageRoot,
412
+ 'diagnostics',
413
+ 'livedesk-mode3-capture-smoke.mjs'
414
+ );
415
+ if (!existsSync(diagnosticEntry)) {
416
+ throw new Error(`LiveDesk mac-physical diagnostic is missing from this package: ${diagnosticEntry}`);
417
+ }
418
+ await import(pathToFileURL(diagnosticEntry).href);
419
+ }
420
+
421
+ function printClientHelp() {
349
422
  process.stdout.write(`
350
423
  LiveDesk Client
351
424
 
@@ -2847,8 +2920,14 @@ async function runDesktopRoleBootstrap() {
2847
2920
 
2848
2921
  async function main() {
2849
2922
  const rawArgs = process.argv.slice(2);
2923
+ const rawCommand = String(rawArgs[0] || '').trim().toLowerCase();
2924
+ if (rawCommand === 'diagnose') {
2925
+ await runDiagnosticCommand(rawArgs.slice(1));
2926
+ return;
2927
+ }
2850
2928
  restoreUpdateOriginalCwd();
2851
2929
  if (rawArgs.includes('--internal-legacy-client-update')) {
2930
+ await assertNoActiveMacPhysicalDiagnosticLease();
2852
2931
  await runLegacyClientUpdateSupervisorCli();
2853
2932
  return;
2854
2933
  }
@@ -2870,11 +2949,12 @@ async function main() {
2870
2949
  printHelp();
2871
2950
  return;
2872
2951
  }
2873
- if (command === '--version' || command === '-v' || command === 'version') {
2874
- console.log(readVersion());
2875
- return;
2876
- }
2877
- const explicitRoleCommand = command === 'hub' || command === 'manager' || command === 'client';
2952
+ if (command === '--version' || command === '-v' || command === 'version') {
2953
+ console.log(readVersion());
2954
+ return;
2955
+ }
2956
+ await assertNoActiveMacPhysicalDiagnosticLease();
2957
+ const explicitRoleCommand = command === 'hub' || command === 'manager' || command === 'client';
2878
2958
  let resolvedRole;
2879
2959
  let runtimeArgs = args;
2880
2960
  if (explicitRoleCommand) {
@@ -4795,8 +4795,8 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4795
4795
  const parsed = parseLauncherArgs(argv);
4796
4796
  if (parsed.retiredModelOptionsDiscarded > 0) {
4797
4797
  console.warn(
4798
- '[LiveDesk Client] Ignored retired Client model-runtime options. '
4799
- + 'Approved AI computer commands are handled by the Hub Codex Agent.'
4798
+ '[LiveDesk Client] Ignored retired Client options. '
4799
+ + 'Computer commands are handled by the Hub Codex Agent.'
4800
4800
  );
4801
4801
  }
4802
4802
  if (parsed.updateWaitPid) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.210",
3
+ "version": "0.1.211",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,12 +9,12 @@
9
9
  "livedesk-client-node": "bin/livedesk-client-node.js",
10
10
  "livedesk-client-fast": "bin/livedesk-client-fast.js"
11
11
  },
12
- "files": [
13
- "bin/",
14
- "src/",
15
- "README.md",
16
- "THIRD_PARTY_NOTICES.md"
17
- ],
12
+ "files": [
13
+ "bin/",
14
+ "src/",
15
+ "README.md",
16
+ "THIRD_PARTY_NOTICES.md"
17
+ ],
18
18
  "scripts": {
19
19
  "check": "node --check bin/client-version.js && node --check bin/livedesk-client.js && node --check bin/livedesk-client-node.js && node --check bin/livedesk-client-fast.js",
20
20
  "test:version": "node --test tests/client-version.test.mjs",
@@ -31,21 +31,21 @@
31
31
  "engines": {
32
32
  "node": ">=20"
33
33
  },
34
- "dependencies": {
35
- "@ffmpeg-installer/ffmpeg": "^1.1.0",
36
- "ffmpeg-static": "^5.3.0",
37
- "@livedesk/runtime-core": "0.1.0",
38
- "@supabase/supabase-js": "^2.110.0",
39
- "node-screenshots": "^0.2.8",
40
- "ws": "^8.18.3"
41
- },
42
- "optionalDependencies": {
43
- "@livedesk/fast-linux-x64": "0.1.415",
44
- "@livedesk/fast-osx-arm64": "0.1.415",
45
- "@livedesk/fast-osx-x64": "0.1.415",
46
- "@livedesk/fast-win-x64": "0.1.415"
47
- },
48
- "publishConfig": {
34
+ "dependencies": {
35
+ "@ffmpeg-installer/ffmpeg": "^1.1.0",
36
+ "ffmpeg-static": "^5.3.0",
37
+ "@livedesk/runtime-core": "0.1.0",
38
+ "@supabase/supabase-js": "^2.110.0",
39
+ "node-screenshots": "^0.2.8",
40
+ "ws": "^8.18.3"
41
+ },
42
+ "optionalDependencies": {
43
+ "@livedesk/fast-linux-x64": "0.1.415",
44
+ "@livedesk/fast-osx-arm64": "0.1.415",
45
+ "@livedesk/fast-osx-x64": "0.1.415",
46
+ "@livedesk/fast-win-x64": "0.1.415"
47
+ },
48
+ "publishConfig": {
49
49
  "access": "public"
50
50
  }
51
51
  }
@@ -0,0 +1,365 @@
1
+ import path from 'node:path';
2
+
3
+ const MAC_VIDEO_HELPER_NAME = 'livedesk-sck-h264';
4
+ const MAC_AUDIO_HELPER_NAME = 'livedesk-sck-audio';
5
+
6
+ function normalizeProcessText(value) {
7
+ return String(value || '').trim().replaceAll('\\', '/');
8
+ }
9
+
10
+ function parseMacProcessRecords(psOutput) {
11
+ const records = [];
12
+ for (const rawLine of String(psOutput || '').split(/\r?\n/)) {
13
+ const match = rawLine.match(/^\s*(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/);
14
+ if (!match) continue;
15
+ const pid = Number(match[1]);
16
+ const parentPid = Number(match[2]);
17
+ if (!Number.isInteger(pid) || pid <= 1 || !Number.isInteger(parentPid)) continue;
18
+ records.push({
19
+ pid,
20
+ parentPid,
21
+ commandName: basenameWithoutQuotes(match[3]),
22
+ commandLine: String(match[4] || '').trim()
23
+ });
24
+ }
25
+ return records;
26
+ }
27
+
28
+ function isMacCaptureHelperRecord(record) {
29
+ const commandName = String(record?.commandName || '');
30
+ const commandLine = normalizeProcessText(record?.commandLine);
31
+ const executable = basenameWithoutQuotes(commandLine.split(/\s+/)[0]);
32
+ const videoPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-h264\/livedesk-sck-h264(?:\s|$)/i;
33
+ const audioPath = /(?:^|\/)\.livedesk\/helpers\/macos-sck-audio\/livedesk-sck-audio(?:\s|$)/i;
34
+ return commandName === MAC_VIDEO_HELPER_NAME
35
+ || commandName === 'livedesk-sck-h2'
36
+ || commandName === MAC_AUDIO_HELPER_NAME
37
+ || commandName === 'livedesk-sck-aud'
38
+ || executable === MAC_VIDEO_HELPER_NAME
39
+ || executable === MAC_AUDIO_HELPER_NAME
40
+ || videoPath.test(commandLine)
41
+ || audioPath.test(commandLine);
42
+ }
43
+
44
+ function finite(value, fallback = 0) {
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) ? parsed : fallback;
47
+ }
48
+
49
+ function basenameWithoutQuotes(value) {
50
+ return path.basename(String(value || '').trim().replace(/^["']|["']$/g, ''));
51
+ }
52
+
53
+ export function parseMacHelperProcesses(psOutput) {
54
+ return parseMacProcessRecords(psOutput).filter(isMacCaptureHelperRecord);
55
+ }
56
+
57
+ export function parseMacLiveDeskRuntimeProcesses(psOutput, { ignorePids = [] } = {}) {
58
+ const ignored = new Set(
59
+ (Array.isArray(ignorePids) ? ignorePids : [])
60
+ .map(Number)
61
+ .filter(pid => Number.isInteger(pid) && pid > 1)
62
+ );
63
+ const results = [];
64
+ for (const record of parseMacProcessRecords(psOutput)) {
65
+ if (ignored.has(record.pid)) continue;
66
+ const commandName = String(record.commandName || '');
67
+ const commandLine = normalizeProcessText(record.commandLine);
68
+ let kind = '';
69
+ if (isMacCaptureHelperRecord(record)) {
70
+ kind = 'capture-helper';
71
+ } else if (
72
+ /^livedesk-client-fast$/i.test(commandName)
73
+ || /(?:^|\/|[\s"'])livedesk-client-fast(?:\.dll)?(?:\s|$|["'])/i.test(commandLine)
74
+ ) {
75
+ kind = 'client-agent';
76
+ } else if (
77
+ /(?:node_modules\/(?:livedesk\/client|@livedesk\/client)|packages\/(?:livedesk\/client|client))\/bin\/livedesk-client(?:-node)?\.js(?:\s|$|["'])/i
78
+ .test(commandLine)
79
+ ) {
80
+ kind = 'client-runtime';
81
+ } else if (
82
+ /(?:node_modules\/livedesk|packages\/livedesk)\/bin\/livedesk\.js(?:\s|$|["'])/i
83
+ .test(commandLine)
84
+ || /(?:^|[\s"'])[^"'\s]*node_modules\/\.bin\/livedesk(?:\s|$|["'])/i
85
+ .test(commandLine)
86
+ ) {
87
+ kind = 'unified-runtime';
88
+ } else if (
89
+ /(?:node_modules\/(?:livedesk\/hub|@livedesk\/hub)|packages\/(?:livedesk\/hub|hub))\/src\/server\.js(?:\s|$|["'])/i
90
+ .test(commandLine)
91
+ ) {
92
+ kind = 'hub-runtime';
93
+ } else if (
94
+ /(?:^|\/)LiveDesk\.app\/Contents\/MacOS\/LiveDesk(?:\s|$|["'])/i.test(commandLine)
95
+ ) {
96
+ kind = 'desktop-runtime';
97
+ }
98
+ if (!kind) continue;
99
+ results.push({
100
+ ...record,
101
+ kind
102
+ });
103
+ }
104
+ return results;
105
+ }
106
+
107
+ const MODIFIERS = Object.freeze([
108
+ { code: 'ShiftLeft', key: 'Shift', keyCode: 16, location: 1, flag: 'shiftKey' },
109
+ { code: 'ControlLeft', key: 'Control', keyCode: 17, location: 1, flag: 'ctrlKey' },
110
+ { code: 'AltLeft', key: 'Alt', keyCode: 18, location: 1, flag: 'altKey' },
111
+ { code: 'MetaLeft', key: 'Meta', keyCode: 91, location: 1, flag: 'metaKey' }
112
+ ]);
113
+
114
+ export function buildMacModifierProbeSteps(startInputSeq = 1) {
115
+ const pressed = [];
116
+ const steps = [];
117
+ let inputSeq = Math.max(1, Math.floor(finite(startInputSeq, 1)));
118
+ const snapshot = () => ({
119
+ pressedCodes: [...pressed],
120
+ shiftKey: pressed.includes('ShiftLeft'),
121
+ ctrlKey: pressed.includes('ControlLeft'),
122
+ altKey: pressed.includes('AltLeft'),
123
+ metaKey: pressed.includes('MetaLeft')
124
+ });
125
+
126
+ for (const modifier of MODIFIERS) {
127
+ pressed.push(modifier.code);
128
+ steps.push({
129
+ type: 'keydown',
130
+ key: modifier.key,
131
+ code: modifier.code,
132
+ keyCode: modifier.keyCode,
133
+ location: modifier.location,
134
+ repeat: false,
135
+ inputSeq: inputSeq++,
136
+ ...snapshot()
137
+ });
138
+ }
139
+ for (const modifier of [...MODIFIERS].reverse()) {
140
+ pressed.splice(pressed.indexOf(modifier.code), 1);
141
+ steps.push({
142
+ type: 'keyup',
143
+ key: modifier.key,
144
+ code: modifier.code,
145
+ keyCode: modifier.keyCode,
146
+ location: modifier.location,
147
+ repeat: false,
148
+ inputSeq: inputSeq++,
149
+ ...snapshot()
150
+ });
151
+ }
152
+ return steps;
153
+ }
154
+
155
+ export function buildMacModifierReleaseSteps(pressedCodes = [], startInputSeq = 1) {
156
+ const pressed = MODIFIERS
157
+ .map(modifier => modifier.code)
158
+ .filter(code => Array.isArray(pressedCodes) && pressedCodes.includes(code));
159
+ const steps = [];
160
+ let inputSeq = Math.max(1, Math.floor(finite(startInputSeq, 1)));
161
+ for (const modifier of [...MODIFIERS].reverse()) {
162
+ const pressedIndex = pressed.indexOf(modifier.code);
163
+ if (pressedIndex < 0) continue;
164
+ pressed.splice(pressedIndex, 1);
165
+ steps.push({
166
+ type: 'keyup',
167
+ key: modifier.key,
168
+ code: modifier.code,
169
+ keyCode: modifier.keyCode,
170
+ location: modifier.location,
171
+ repeat: false,
172
+ inputSeq: inputSeq++,
173
+ pressedCodes: [...pressed],
174
+ shiftKey: pressed.includes('ShiftLeft'),
175
+ ctrlKey: pressed.includes('ControlLeft'),
176
+ altKey: pressed.includes('AltLeft'),
177
+ metaKey: pressed.includes('MetaLeft')
178
+ });
179
+ }
180
+ return steps;
181
+ }
182
+
183
+ export function noteMacModifierBeforeSend(pressedCodes, step = {}) {
184
+ if (!(pressedCodes instanceof Set)) {
185
+ throw new TypeError('Mac modifier pressed state must be a Set.');
186
+ }
187
+ const code = String(step.code || '').trim();
188
+ if (step.type === 'keydown' && code) {
189
+ // A timed-out ACK is ambiguous: the native keydown may already have been
190
+ // applied. Track it before the send boundary so cleanup remains fail-safe.
191
+ pressedCodes.add(code);
192
+ }
193
+ return pressedCodes;
194
+ }
195
+
196
+ export function noteMacModifierAfterAppliedAck(pressedCodes, step = {}) {
197
+ if (!(pressedCodes instanceof Set)) {
198
+ throw new TypeError('Mac modifier pressed state must be a Set.');
199
+ }
200
+ const code = String(step.code || '').trim();
201
+ if (step.type === 'keyup' && code) {
202
+ // Only a native applied ACK proves that releasing this key succeeded.
203
+ pressedCodes.delete(code);
204
+ }
205
+ return pressedCodes;
206
+ }
207
+
208
+ function check(id, ok, detail) {
209
+ return { id, ok: ok === true, detail: String(detail || '') };
210
+ }
211
+
212
+ export function classifyMacPhysicalGate(snapshot = {}) {
213
+ const requestedFps = Math.max(1, finite(snapshot.requestedFps, 30));
214
+ const minimumFps = requestedFps * 0.8;
215
+ const telemetry = snapshot.telemetry || {};
216
+ const helper = snapshot.helper || {};
217
+ const monitor = snapshot.monitor || {};
218
+ const modifier = snapshot.modifier || {};
219
+ const transport = snapshot.transport || {};
220
+ const diagnostic = snapshot.diagnostic || {};
221
+ const abortedSignal = String(diagnostic.abortedSignal || '').trim();
222
+ const runError = String(
223
+ diagnostic.runError
224
+ || snapshot.error
225
+ || (abortedSignal ? `interrupted by ${abortedSignal}` : '')
226
+ || ''
227
+ ).trim();
228
+ const cleanupErrors = Array.isArray(diagnostic.cleanupErrors)
229
+ ? diagnostic.cleanupErrors.map(value => String(value || '').trim()).filter(Boolean)
230
+ : [];
231
+ const captureFps = Math.max(0, finite(telemetry.captureFps));
232
+ const compressionFps = Math.max(0, finite(telemetry.compressionFps));
233
+ const stdoutFps = Math.max(0, finite(telemetry.stdoutFps));
234
+ const idleOptimized = finite(telemetry.idleSkipped) > 0
235
+ && finite(telemetry.backpressureSkipped) === 0;
236
+ const captureAtTarget = captureFps >= minimumFps;
237
+ const compressionKeepsUp = idleOptimized
238
+ || (captureFps > 0 && compressionFps >= captureFps * 0.8);
239
+ const stdoutKeepsUp = (idleOptimized && compressionFps <= 0)
240
+ || (compressionFps > 0 && stdoutFps >= compressionFps * 0.8);
241
+ const deliveredAtTarget = idleOptimized
242
+ || finite(snapshot.deliveredFps) >= minimumFps;
243
+ const helperPids = Array.isArray(helper.observedPids)
244
+ ? helper.observedPids.map(Number).filter(pid => Number.isInteger(pid) && pid > 1)
245
+ : [];
246
+ const expectedModifierAcks = Math.max(1, finite(modifier.expectedAcks, 8));
247
+ const checks = [
248
+ check(
249
+ 'diagnostic-run',
250
+ !runError,
251
+ runError || 'completed'
252
+ ),
253
+ check(
254
+ 'diagnostic-cleanup',
255
+ cleanupErrors.length === 0,
256
+ cleanupErrors.length === 0 ? 'completed' : cleanupErrors.join('; ')
257
+ ),
258
+ check(
259
+ 'first-frame',
260
+ finite(snapshot.firstFrameMs) > 0 && finite(snapshot.firstFrameMs) < 8_000,
261
+ `${Math.round(finite(snapshot.firstFrameMs))}ms`
262
+ ),
263
+ check(
264
+ 'monitor-first-frame',
265
+ finite(snapshot.monitorSwitchFirstFrameMs) > 0
266
+ && finite(snapshot.monitorSwitchFirstFrameMs) < 8_000,
267
+ `${Math.round(finite(snapshot.monitorSwitchFirstFrameMs))}ms`
268
+ ),
269
+ check(
270
+ 'native-profile',
271
+ snapshot.platformProfile === 'macos-screencapturekit-nv12-videotoolbox',
272
+ snapshot.platformProfile || 'missing'
273
+ ),
274
+ check(
275
+ 'telemetry-current',
276
+ finite(telemetry.version) >= 2
277
+ && Number.isFinite(Number(telemetry.ageMs))
278
+ && finite(telemetry.ageMs) <= 3_000,
279
+ `v${finite(telemetry.version)} age=${Number.isFinite(Number(telemetry.ageMs)) ? Math.round(finite(telemetry.ageMs)) : '-'}ms`
280
+ ),
281
+ check(
282
+ 'hardware-videotoolbox',
283
+ telemetry.hardwareAccelerated === true,
284
+ telemetry.hardwareAccelerated === true ? 'hardware' : 'not-confirmed'
285
+ ),
286
+ check('screencapturekit-fps', captureAtTarget, `${captureFps.toFixed(1)}/${requestedFps}`),
287
+ check(
288
+ 'videotoolbox-fps',
289
+ compressionKeepsUp,
290
+ `${compressionFps.toFixed(1)} after ${captureFps.toFixed(1)}${idleOptimized ? ' idle-optimized' : ''}`
291
+ ),
292
+ check(
293
+ 'stdout-fps',
294
+ stdoutKeepsUp,
295
+ `${stdoutFps.toFixed(1)} after ${compressionFps.toFixed(1)}${idleOptimized ? ' idle-optimized' : ''}`
296
+ ),
297
+ check(
298
+ 'delivered-fps',
299
+ deliveredAtTarget,
300
+ `${finite(snapshot.deliveredFps).toFixed(1)}/${requestedFps}${idleOptimized ? ' idle-optimized' : ''}`
301
+ ),
302
+ check(
303
+ 'single-helper',
304
+ finite(helper.maxConcurrent) <= 1,
305
+ `max=${finite(helper.maxConcurrent)} pids=${helperPids.join(',') || '-'}`
306
+ ),
307
+ check('helper-drained', finite(helper.finalCount) === 0, `final=${finite(helper.finalCount)}`),
308
+ check('two-monitors', finite(monitor.count) >= 2, `count=${finite(monitor.count)}`),
309
+ check(
310
+ 'monitor-generation',
311
+ finite(monitor.nextGeneration) > finite(monitor.initialGeneration),
312
+ `${finite(monitor.initialGeneration)} -> ${finite(monitor.nextGeneration)}`
313
+ ),
314
+ check(
315
+ 'monitor-pixels',
316
+ Boolean(monitor.initialHash)
317
+ && Boolean(monitor.nextHash)
318
+ && monitor.initialHash !== monitor.nextHash,
319
+ `${String(monitor.initialHash || '-').slice(0, 12)} -> ${String(monitor.nextHash || '-').slice(0, 12)}`
320
+ ),
321
+ check(
322
+ 'modifier-native-ack',
323
+ finite(modifier.appliedAcks) === expectedModifierAcks
324
+ && (!Array.isArray(modifier.errors) || modifier.errors.length === 0),
325
+ `${finite(modifier.appliedAcks)}/${expectedModifierAcks} native ACK`
326
+ ),
327
+ check(
328
+ 'active-transport',
329
+ Boolean(String(transport.active || '').trim()),
330
+ [
331
+ String(transport.active || '-'),
332
+ String(transport.protocol || '-'),
333
+ String(transport.state || '-')
334
+ ].join('/')
335
+ )
336
+ ];
337
+
338
+ const failed = checks.filter(item => !item.ok);
339
+ const bottleneckByCheck = {
340
+ 'diagnostic-run': 'diagnostic-run',
341
+ 'diagnostic-cleanup': 'diagnostic-cleanup',
342
+ 'first-frame': 'capture-startup',
343
+ 'monitor-first-frame': 'monitor-restart',
344
+ 'native-profile': 'capture-fallback',
345
+ 'telemetry-current': 'native-telemetry-stale',
346
+ 'hardware-videotoolbox': 'videotoolbox-software',
347
+ 'screencapturekit-fps': 'screencapturekit',
348
+ 'videotoolbox-fps': 'videotoolbox',
349
+ 'stdout-fps': 'native-stdout',
350
+ 'delivered-fps': 'agent-or-transport',
351
+ 'single-helper': 'duplicate-helper',
352
+ 'helper-drained': 'stale-helper',
353
+ 'two-monitors': 'monitor-topology',
354
+ 'monitor-generation': 'monitor-generation',
355
+ 'monitor-pixels': 'monitor-binding',
356
+ 'modifier-native-ack': 'remote-input',
357
+ 'active-transport': 'transport-telemetry'
358
+ };
359
+ return {
360
+ ok: failed.length === 0,
361
+ idleOptimized,
362
+ bottleneck: failed.length > 0 ? bottleneckByCheck[failed[0].id] || failed[0].id : 'none',
363
+ checks
364
+ };
365
+ }