livedesk 0.1.430 → 0.1.432

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/bin/livedesk.js CHANGED
@@ -5,7 +5,7 @@ import net from 'node:net';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { execFile, spawn } from 'node:child_process';
8
- import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { randomBytes } from 'node:crypto';
10
10
  import os from 'node:os';
11
11
  import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
@@ -62,17 +62,12 @@ function prepareUpdateNeutralCwd(kind, operationId) {
62
62
  `${safeUpdatePathSegment(kind, 'update')}-${safeUpdatePathSegment(operationId)}`
63
63
  );
64
64
  mkdirSync(neutralCwd, { recursive: true });
65
- for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) {
66
- const shadowPath = ['package.json', join('node_modules', 'livedesk', 'package.json')]
67
- .map(relativePath => join(ancestor, relativePath))
68
- .find(candidate => existsSync(candidate));
69
- if (shadowPath) {
70
- throw new Error(
71
- `LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
72
- );
73
- }
74
- const parent = dirname(ancestor);
75
- if (parent === ancestor) break;
65
+ const unexpectedEntry = readdirSync(neutralCwd)[0];
66
+ if (unexpectedEntry) {
67
+ const shadowPath = join(neutralCwd, unexpectedEntry);
68
+ throw new Error(
69
+ `LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
70
+ );
76
71
  }
77
72
  return neutralCwd;
78
73
  }
@@ -94,6 +89,7 @@ function buildNeutralNpxEnvironment(baseEnv, neutralCwd) {
94
89
  }
95
90
  env.INIT_CWD = neutralCwd;
96
91
  env.npm_config_local_prefix = neutralCwd;
92
+ env.npm_config_workspaces = 'false';
97
93
  env.npm_config_include_workspace_root = 'false';
98
94
  return env;
99
95
  }
@@ -1037,7 +1033,7 @@ function resolveNpxInvocation(npxArgs) {
1037
1033
  function buildHubRestartBootstrapScript() {
1038
1034
  return [
1039
1035
  'const { execFile, spawn } = require("node:child_process");',
1040
- 'const { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } = require("node:fs");',
1036
+ 'const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } = require("node:fs");',
1041
1037
  'const { dirname, join } = require("node:path");',
1042
1038
  'const net = require("node:net");',
1043
1039
  'const waitPid = Number(process.env.LIVEDESK_RESTART_WAIT_PID || 0);',
@@ -1077,8 +1073,8 @@ function buildHubRestartBootstrapScript() {
1077
1073
  'const healthTimeoutMs = terminationFailureOperationId === operationId ? numberFromEnv("LIVEDESK_TEST_HUB_RESTART_TERMINATION_HEALTH_TIMEOUT_MS", 1000, 250) : configuredHealthTimeoutMs;',
1078
1074
  'const pollMs = numberFromEnv("LIVEDESK_RESTART_HEALTH_POLL_MS", 250, 50);',
1079
1075
  'const stabilityMs = numberFromEnv("LIVEDESK_RESTART_STABILITY_MS", 5000, 5000);',
1080
- 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk Hub update neutral working directory is unavailable"); mkdirSync(neutralCwd, { recursive: true }); for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) { const shadow = [join(ancestor, "package.json"), join(ancestor, "node_modules", "livedesk", "package.json")].find(existsSync); if (shadow) throw new Error(`LiveDesk Hub update neutral working directory is shadowed by ${shadow}: ${neutralCwd}`); const parent = dirname(ancestor); if (parent === ancestor) break; } };',
1081
- 'const buildLaunchEnv = () => { const env = { ...process.env }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_include_workspace_root = "false"; return env; };',
1076
+ 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk Hub update neutral working directory is unavailable"); mkdirSync(neutralCwd, { recursive: true }); const unexpectedEntry = readdirSync(neutralCwd)[0]; if (unexpectedEntry) { const shadow = join(neutralCwd, unexpectedEntry); throw new Error(`LiveDesk Hub update neutral working directory is shadowed by ${shadow}: ${neutralCwd}`); } };',
1077
+ 'const buildLaunchEnv = () => { const env = { ...process.env }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_workspaces = "false"; env.npm_config_include_workspace_root = "false"; return env; };',
1082
1078
  'const requestJson = async path => { const response = await fetch(new URL(path, healthBaseUrl), { cache: "no-store", signal: AbortSignal.timeout(Math.min(2000, healthTimeoutMs)) }); if (!response.ok) throw new Error(`HTTP ${response.status} from ${path}`); return response.json(); };',
1083
1079
  'const probeRemotePort = () => new Promise((resolve, reject) => { const socket = net.createConnection({ host: remoteHost, port: remotePort }); const timer = setTimeout(() => socket.destroy(new Error("TCP probe timed out")), Math.min(1500, healthTimeoutMs)); timer.unref?.(); socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(); }); socket.once("error", error => { clearTimeout(timer); reject(error); }); });',
1084
1080
  'const probeReplacement = async requiredVersion => { const [status, health] = await Promise.all([requestJson("/api/runtime/status"), requestJson("/api/health")]); const runtimePid = Number(status?.runtimePid || 0); if (status?.role !== "hub" || status?.runtimeStarted !== true || runtimePid <= 1) throw new Error("replacement runtime status is not ready"); if (previousRuntimePid > 1 && runtimePid === previousRuntimePid) throw new Error(`old runtime pid=${runtimePid} is still answering`); if (requiredVersion && String(status?.appVersion || "") !== requiredVersion) throw new Error(`version mismatch expected=${requiredVersion} actual=${status?.appVersion || "unknown"}`); if (health?.ok !== true || health?.product !== "LiveDesk") throw new Error("replacement health response is invalid"); await probeRemotePort(); return { runtimePid, appVersion: String(status?.appVersion || "") }; };',
@@ -1192,7 +1188,18 @@ async function runManager(args, resolvedRole = null) {
1192
1188
  || prepareUpdateNeutralCwd('hub', operationId)
1193
1189
  );
1194
1190
  const packageTarget = `livedesk@${requestedVersion}`;
1195
- const nextArgs = ['-y', '--prefer-online', packageTarget, '--force-role', 'hub', '--no-open', ...args];
1191
+ const nextArgs = [
1192
+ '-y',
1193
+ '--prefer-online',
1194
+ '--prefix',
1195
+ neutralCwd,
1196
+ '--workspaces=false',
1197
+ packageTarget,
1198
+ '--force-role',
1199
+ 'hub',
1200
+ '--no-open',
1201
+ ...args
1202
+ ];
1196
1203
  const restartInvocation = resolveNpxInvocation(nextArgs);
1197
1204
  const fallbackInvocation = {
1198
1205
  command: process.execPath,
@@ -7,6 +7,7 @@ import {
7
7
  mkdirSync,
8
8
  openSync,
9
9
  readFileSync,
10
+ readdirSync,
10
11
  renameSync,
11
12
  rmSync,
12
13
  statSync,
@@ -138,18 +139,12 @@ function prepareNeutralCwd(cwd) {
138
139
  if (!configuredCwd) throw new Error('LiveDesk update neutral working directory is unavailable');
139
140
  const neutralCwd = resolve(configuredCwd);
140
141
  mkdirSync(neutralCwd, { recursive: true });
141
- for (let ancestor = neutralCwd; ; ancestor = dirname(ancestor)) {
142
- const shadowPath = [
143
- join(ancestor, 'package.json'),
144
- join(ancestor, 'node_modules', 'livedesk', 'package.json')
145
- ].find(candidate => existsSync(candidate));
146
- if (shadowPath) {
147
- throw new Error(
148
- `LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
149
- );
150
- }
151
- const parent = dirname(ancestor);
152
- if (parent === ancestor) break;
142
+ const unexpectedEntry = readdirSync(neutralCwd)[0];
143
+ if (unexpectedEntry) {
144
+ const shadowPath = join(neutralCwd, unexpectedEntry);
145
+ throw new Error(
146
+ `LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`
147
+ );
153
148
  }
154
149
  return neutralCwd;
155
150
  }
@@ -171,10 +166,23 @@ export function buildLegacyClientUpdateNpxEnvironment(baseEnv, neutralCwd) {
171
166
  }
172
167
  env.INIT_CWD = neutralCwd;
173
168
  env.npm_config_local_prefix = neutralCwd;
169
+ env.npm_config_workspaces = 'false';
174
170
  env.npm_config_include_workspace_root = 'false';
175
171
  return env;
176
172
  }
177
173
 
174
+ function buildExactNpxArgs(neutralCwd, preference, version, args = []) {
175
+ return [
176
+ '-y',
177
+ preference,
178
+ '--prefix',
179
+ neutralCwd,
180
+ '--workspaces=false',
181
+ `livedesk@${version}`,
182
+ ...args
183
+ ];
184
+ }
185
+
178
186
  function positivePid(value) {
179
187
  const parsed = Number(value);
180
188
  return Number.isInteger(parsed) && parsed > 1 ? parsed : 0;
@@ -1011,11 +1019,11 @@ export function prefetchExactNpxRestore(config, env = process.env, options = {})
1011
1019
  if (!version) {
1012
1020
  throw new Error('LiveDesk cannot prefetch an exact restore without the current product version.');
1013
1021
  }
1022
+ const neutralCwd = prepareNeutralCwd(config?.neutralCwd);
1014
1023
  const invocation = resolveNpxInvocation(
1015
- ['-y', '--prefer-online', `livedesk@${version}`, '--version'],
1024
+ buildExactNpxArgs(neutralCwd, '--prefer-online', version, ['--version']),
1016
1025
  env
1017
1026
  );
1018
- const neutralCwd = prepareNeutralCwd(config?.neutralCwd);
1019
1027
  const result = (options.spawnSyncImpl || spawnSync)(
1020
1028
  invocation.command,
1021
1029
  invocation.args,
@@ -1067,12 +1075,12 @@ export function spawnReplacement(version, preference, env = process.env, restart
1067
1075
  const isLocalRestore = env.LIVEDESK_CLIENT_UPDATE_ATTEMPT_KIND === 'restore'
1068
1076
  && localRestoreLauncher
1069
1077
  && existsSync(localRestoreLauncher);
1070
- const invocation = isLocalRestore
1071
- ? { command: process.execPath, args: [localRestoreLauncher, ...roleArgs] }
1072
- : resolveNpxInvocation(['-y', preference, `livedesk@${version}`, ...roleArgs], env);
1073
1078
  const neutralCwd = isLocalRestore
1074
1079
  ? ''
1075
1080
  : prepareNeutralCwd(env.LIVEDESK_UPDATE_NEUTRAL_CWD);
1081
+ const invocation = isLocalRestore
1082
+ ? { command: process.execPath, args: [localRestoreLauncher, ...roleArgs] }
1083
+ : resolveNpxInvocation(buildExactNpxArgs(neutralCwd, preference, version, roleArgs), env);
1076
1084
  const replacementEnv = isLocalRestore
1077
1085
  ? { ...env }
1078
1086
  : buildLegacyClientUpdateNpxEnvironment(env, neutralCwd);
@@ -58,16 +58,10 @@ function getClientUpdateNeutralCwd(operationId) {
58
58
  async function prepareClientUpdateNeutralCwd(operationId) {
59
59
  const neutralCwd = path.resolve(getClientUpdateNeutralCwd(operationId));
60
60
  await fs.mkdir(neutralCwd, { recursive: true });
61
- for (let ancestor = neutralCwd; ; ancestor = path.dirname(ancestor)) {
62
- const shadowPath = [
63
- path.join(ancestor, 'package.json'),
64
- path.join(ancestor, 'node_modules', 'livedesk', 'package.json')
65
- ].find(candidate => existsSync(candidate));
66
- if (shadowPath) {
67
- throw new Error(`LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`);
68
- }
69
- const parent = path.dirname(ancestor);
70
- if (parent === ancestor) break;
61
+ const unexpectedEntry = (await fs.readdir(neutralCwd))[0];
62
+ if (unexpectedEntry) {
63
+ const shadowPath = path.join(neutralCwd, unexpectedEntry);
64
+ throw new Error(`LiveDesk update neutral working directory is shadowed by ${shadowPath}: ${neutralCwd}`);
71
65
  }
72
66
  return neutralCwd;
73
67
  }
@@ -89,6 +83,7 @@ function buildClientUpdateNpxEnvironment(baseEnv, neutralCwd) {
89
83
  }
90
84
  env.INIT_CWD = neutralCwd;
91
85
  env.npm_config_local_prefix = neutralCwd;
86
+ env.npm_config_workspaces = 'false';
92
87
  env.npm_config_include_workspace_root = 'false';
93
88
  return env;
94
89
  }
@@ -1583,13 +1578,13 @@ function buildClientUpdateBootstrapScript() {
1583
1578
  'const terminateTree = async child => { const pid = Number(child?.pid || 0); if (pid <= 1) return; if (process.platform === "win32") { await new Promise(resolve => { const killer = spawn("taskkill.exe", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" }); let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timer); resolve(); }; const timer = setTimeout(() => { try { killer.kill("SIGKILL"); } catch {} finish(); }, 10000); killer.once("error", finish); killer.once("exit", finish); }); if (isAlive(pid)) { try { child.kill("SIGKILL"); } catch {} } return; } try { process.kill(-pid, "SIGTERM"); } catch { try { child.kill("SIGTERM"); } catch {} } const gracefulDeadline = Date.now() + 2000; while (Date.now() < gracefulDeadline && (isGroupAlive(pid) || isAlive(pid))) await sleep(100); if (isGroupAlive(pid) || isAlive(pid)) { try { process.kill(-pid, "SIGKILL"); } catch { try { child.kill("SIGKILL"); } catch {} } } };',
1584
1579
  'const writeHandoffStarted = () => withStateLock(() => { if (deadlineExpired()) throw new Error("The absolute LiveDesk Client update deadline expired before handoff."); const previous = readState(); if (previous.operationId !== operationId) throw new Error("LiveDesk update handoff was superseded before exact-package launch."); const now = new Date().toISOString(); const next = { ...previous, operationId, stage: "handoff-started", starterPid: process.pid, updateDeadlineEpochMs, restartVerified: false, error: "", updatedAt: now }; const temporary = statePath + "." + process.pid + ".handoff-started.tmp"; fs.writeFileSync(temporary, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 }); fs.renameSync(temporary, statePath); });',
1585
1580
  'const monitorPreflight = child => { let settled = false; const finish = (error, preserveFailure = false) => { if (settled) return; settled = true; clearInterval(poll); clearTimeout(timeout); child.unref(); if (preserveFailure) process.exitCode = 1; else if (error) writeFailure(error); }; const inspect = () => { const state = readState(); if (state.operationId !== operationId) return; if (state.stage === "preflight-ready" || state.stage === "waiting-for-shutdown") finish(); else if (state.stage === "failed") finish(null, true); }; const cancelTimedOutHandoff = async () => { const error = new Error(deadlineExpired() ? "The absolute LiveDesk Client update deadline expired before exact-package preflight." : "Timed out waiting for exact-package update preflight."); while (!settled) { inspect(); if (settled) return; let outcome; try { outcome = writeCancellation(error); } catch (lockError) { finish(lockError); return; } if (outcome === "ready" || outcome === "failed") { inspect(); if (settled) return; } else if (outcome === "cancelled" || outcome === "superseded") { await terminateTree(child); finish(null, true); return; } await sleep(25); } }; const poll = setInterval(inspect, 100); const timeoutMs = Math.max(1, Math.min(Math.max(1000, Number(process.env.LIVEDESK_CLIENT_UPDATE_HANDOFF_TIMEOUT_MS || 140000)), updateDeadlineEpochMs - Date.now())); const timeout = setTimeout(() => { void cancelTimedOutHandoff(); }, timeoutMs); child.once("exit", (code, signal) => { inspect(); if (!settled) finish(new Error("Exact-package update supervisor exited before preflight (code=" + (code ?? "none") + ", signal=" + (signal || "none") + ").")); }); inspect(); };',
1586
- 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk update neutral working directory is unavailable"); fs.mkdirSync(neutralCwd, { recursive: true }); for (let ancestor = neutralCwd; ; ancestor = path.dirname(ancestor)) { const shadow = [path.join(ancestor, "package.json"), path.join(ancestor, "node_modules", "livedesk", "package.json")].find(fs.existsSync); if (shadow) throw new Error("LiveDesk update neutral working directory is shadowed by " + shadow + ": " + neutralCwd); const parent = path.dirname(ancestor); if (parent === ancestor) break; } };',
1581
+ 'const prepareNeutralCwd = () => { if (!neutralCwd) throw new Error("LiveDesk update neutral working directory is unavailable"); fs.mkdirSync(neutralCwd, { recursive: true }); const unexpectedEntry = fs.readdirSync(neutralCwd)[0]; if (unexpectedEntry) { const shadow = path.join(neutralCwd, unexpectedEntry); throw new Error("LiveDesk update neutral working directory is shadowed by " + shadow + ": " + neutralCwd); } };',
1587
1582
  'if (!operationId || !versionPattern.test(targetProductVersion) || deadlineExpired()) { writeFailure(new Error("Invalid or expired LiveDesk exact-package update handoff."), true); } else { try { prepareNeutralCwd(); writeHandoffStarted();',
1588
- 'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_include_workspace_root = "false";',
1583
+ 'const env = { ...process.env, LIVEDESK_UPDATE_STARTER_PID: String(process.pid) }; const isolated = new Set(["init_cwd", "npm_config_local_prefix", "npm_config_workspace", "npm_config_workspaces", "npm_config_include_workspace_root", "npm_package_json", "npm_lifecycle_event", "npm_lifecycle_script"]); for (const key of Object.keys(env)) if (isolated.has(key.toLowerCase())) delete env[key]; env.INIT_CWD = neutralCwd; env.npm_config_local_prefix = neutralCwd; env.npm_config_workspaces = "false"; env.npm_config_include_workspace_root = "false";',
1589
1584
  'const nodeDir = path.dirname(process.execPath);',
1590
1585
  'const npxCli = [env.LIVEDESK_NPX_CLI_PATH, env.npm_execpath ? path.join(path.dirname(env.npm_execpath), "npx-cli.js") : "", env.LIVEDESK_NPX_EXECUTABLE ? path.join(path.dirname(env.LIVEDESK_NPX_EXECUTABLE), "node_modules", "npm", "bin", "npx-cli.js") : "", path.join(nodeDir, "node_modules", "npm", "bin", "npx-cli.js")].find(value => value && fs.existsSync(value));',
1591
1586
  'const npx = env.LIVEDESK_NPX_EXECUTABLE || (process.platform === "win32" ? "npx.cmd" : "npx");',
1592
- 'const args = ["-y", "--prefer-online", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];',
1587
+ 'const args = ["-y", "--prefer-online", "--prefix", neutralCwd, "--workspaces=false", "livedesk@" + targetProductVersion, "--internal-legacy-client-update"];',
1593
1588
  'const quoteCmd = value => "\\"" + String(value).replaceAll("%", "%%").replaceAll("\\"", "\\"\\"") + "\\"";',
1594
1589
  'const invocation = npxCli ? { command: process.execPath, args: [npxCli, ...args] } : process.platform === "win32" ? { command: env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", "call " + quoteCmd(npx) + " " + args.map(quoteCmd).join(" ")] } : { command: npx, args };',
1595
1590
  'if (!npxCli && path.isAbsolute(npx) && !fs.existsSync(npx)) { writeFailure(new Error("LiveDesk npx executable was not found: " + npx)); } else { try { const child = spawn(invocation.command, invocation.args, { cwd: neutralCwd, env, detached: true, stdio: "ignore", windowsHide: true }); child.once("error", writeFailure); child.once("spawn", () => monitorPreflight(child)); } catch (error) { writeFailure(error); } }',
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.188",
3
+ "version": "0.1.190",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,10 +41,10 @@
41
41
  "ws": "^8.18.3"
42
42
  },
43
43
  "optionalDependencies": {
44
- "@livedesk/fast-linux-x64": "0.1.395",
45
- "@livedesk/fast-osx-arm64": "0.1.395",
46
- "@livedesk/fast-osx-x64": "0.1.395",
47
- "@livedesk/fast-win-x64": "0.1.395"
44
+ "@livedesk/fast-linux-x64": "0.1.397",
45
+ "@livedesk/fast-osx-arm64": "0.1.397",
46
+ "@livedesk/fast-osx-x64": "0.1.397",
47
+ "@livedesk/fast-win-x64": "0.1.397"
48
48
  },
49
49
  "publishConfig": {
50
50
  "access": "public"
@@ -49,7 +49,20 @@ function normalizeAccountAvatarUrl(value) {
49
49
  }
50
50
  }
51
51
 
52
+ function readAccessTokenProfile(accessToken) {
53
+ const token = normalizeString(accessToken, 8192);
54
+ const payloadSegment = token.split('.')[1] || '';
55
+ if (!payloadSegment) return {};
56
+ try {
57
+ const payload = JSON.parse(Buffer.from(payloadSegment, 'base64url').toString('utf8'));
58
+ return payload && typeof payload === 'object' ? payload : {};
59
+ } catch {
60
+ return {};
61
+ }
62
+ }
63
+
52
64
  function readClientAccountProfile(sessionOrUser) {
65
+ const tokenProfile = readAccessTokenProfile(sessionOrUser?.access_token);
53
66
  const user = sessionOrUser?.user && typeof sessionOrUser.user === 'object'
54
67
  ? sessionOrUser.user
55
68
  : sessionOrUser && typeof sessionOrUser === 'object'
@@ -58,12 +71,19 @@ function readClientAccountProfile(sessionOrUser) {
58
71
  const metadata = user.user_metadata && typeof user.user_metadata === 'object'
59
72
  ? user.user_metadata
60
73
  : {};
61
- const email = normalizeString(user.email, 320);
74
+ const tokenMetadata = tokenProfile.user_metadata && typeof tokenProfile.user_metadata === 'object'
75
+ ? tokenProfile.user_metadata
76
+ : {};
77
+ const email = normalizeString(user.email || tokenProfile.email, 320);
62
78
  const name = normalizeString(
63
79
  metadata.full_name
64
80
  || metadata.name
65
81
  || metadata.preferred_username
82
+ || tokenMetadata.full_name
83
+ || tokenMetadata.name
84
+ || tokenMetadata.preferred_username
66
85
  || user.name
86
+ || tokenProfile.name
67
87
  || email
68
88
  || user.id,
69
89
  160
@@ -71,6 +91,8 @@ function readClientAccountProfile(sessionOrUser) {
71
91
  const avatarUrl = normalizeAccountAvatarUrl(
72
92
  metadata.avatar_url
73
93
  || metadata.picture
94
+ || tokenMetadata.avatar_url
95
+ || tokenMetadata.picture
74
96
  || user.avatar_url
75
97
  || user.picture
76
98
  );
@@ -79,7 +101,11 @@ function readClientAccountProfile(sessionOrUser) {
79
101
 
80
102
  function mergeClientAccountProfile(session, sourceUser) {
81
103
  const profile = readClientAccountProfile(sourceUser);
104
+ const existingUserMetadata = session?.user?.user_metadata && typeof session.user.user_metadata === 'object'
105
+ ? session.user.user_metadata
106
+ : {};
82
107
  const userMetadata = {
108
+ ...existingUserMetadata,
83
109
  ...(profile.name ? { full_name: profile.name } : {}),
84
110
  ...(profile.avatarUrl ? { avatar_url: profile.avatarUrl } : {})
85
111
  };
@@ -1275,7 +1301,11 @@ export function createClientRuntimeServer(options = {}) {
1275
1301
  setImmediate(() => {
1276
1302
  void (async () => {
1277
1303
  try {
1278
- const normalizedSession = mergeClientAccountProfile(saved.session, savedSession?.user);
1304
+ // Older persisted sessions kept only id/email on `user`, while
1305
+ // Supabase still carries Google name/avatar claims in the
1306
+ // access-token payload. Recover those display-only claims
1307
+ // locally before any best-effort network hydration.
1308
+ const normalizedSession = mergeClientAccountProfile(saved.session, savedSession);
1279
1309
  const hydratedSession = await hydrateClientAccountProfile(normalizedSession);
1280
1310
  if (!writeSavedSession(hydratedSession)) throw new Error('refresh-token-required');
1281
1311
  state.auth.persisted = true;
@@ -71,7 +71,7 @@ function encodePowerShell(value) {
71
71
 
72
72
  function buildLegacyPackageSupervisorStarterSource() {
73
73
  const source = [
74
- "const{spawn}=require('node:child_process'),f=require('node:fs'),p=require('node:path'),c=JSON.parse(Buffer.from(process.env.C,'base64')),e={...process.env,LIVEDESK_UPDATE_STARTER_PID:String(process.pid),LIVEDESK_UPDATE_ORIGINAL_CWD:process.cwd()},w=p.join(require('node:os').tmpdir(),'livedesk-update-cwd','client-'+c[0]),u=e.LIVEDESK_NPX_EXECUTABLE,s=process.platform,j=process.execPath,n=u||(s==='win32'?'npx.cmd':'npx'),x=[e.LIVEDESK_NPX_CLI_PATH,e.npm_execpath&&p.join(p.dirname(e.npm_execpath),'npx-cli.js'),u&&p.join(p.dirname(u),'node_modules/npm/bin/npx-cli.js'),p.join(p.dirname(j),'node_modules/npm/bin/npx-cli.js')].find(v=>v&&f.existsSync(v)),a=['-y','--prefer-online','livedesk@'+c[6],'--internal-legacy-client-update'],z=String.fromCharCode(32),q=v=>'\"'+String(v).replaceAll('\"','\"\"')+'\"';if(!(+c[7]>0))process.exit(1);c[7]=String(Date.now()+Number(c[7]));e.C=Buffer.from(JSON.stringify(c)).toString('base64');f.mkdirSync(w,{recursive:true});if(f.readdirSync(w)[0])process.exit(1);let/**/t=w;for(;;t=p.dirname(t)){if(['package.json','node_modules/livedesk/package.json'].some(v=>f.existsSync(p.join(t,v))))process.exit(1);if(p.dirname(t)===t)break}Object.keys(e).forEach(k=>/^(INIT_CWD|npm_config_(local_prefix|workspaces?|include_workspace_root))$/i.test(k)&&Reflect.deleteProperty(e,k));Object.assign(e,{INIT_CWD:w,npm_config_local_prefix:w,npm_config_include_workspace_root:'false',LIVEDESK_UPDATE_NEUTRAL_CWD:w});e.i=x?{c:j,a:[x,...a]}:s==='win32'?{c:e.ComSpec||'cmd.exe',a:['/d','/s','/c','call'+z+q(n)+z+a.map(q).join(z)]}:{c:n,a};e.r=spawn(e.i.c,e.i.a,{cwd:w,env:e,detached:true,stdio:'ignore',windowsHide:true});",
74
+ "const{spawn}=require('node:child_process'),f=require('node:fs'),p=require('node:path'),c=JSON.parse(Buffer.from(process.env.C,'base64')),e={...process.env,LIVEDESK_UPDATE_STARTER_PID:String(process.pid),LIVEDESK_UPDATE_ORIGINAL_CWD:process.cwd()},w=p.join(require('node:os').tmpdir(),'livedesk-update-cwd','client-'+c[0]),u=e.LIVEDESK_NPX_EXECUTABLE,s=process.platform,j=process.execPath,n=u||(s==='win32'?'npx.cmd':'npx'),x=[e.LIVEDESK_NPX_CLI_PATH,e.npm_execpath&&p.join(p.dirname(e.npm_execpath),'npx-cli.js'),u&&p.join(p.dirname(u),'node_modules/npm/bin/npx-cli.js'),p.join(p.dirname(j),'node_modules/npm/bin/npx-cli.js')].find(v=>v&&f.existsSync(v)),a=['-y','--prefer-online','--prefix',w,'--workspaces=false','livedesk@'+c[6],'--internal-legacy-client-update'],z=String.fromCharCode(32),q=v=>'\"'+String(v).replaceAll('\"','\"\"')+'\"';if(!(+c[7]>0))process.exit(1);c[7]=String(Date.now()+Number(c[7]));e.C=Buffer.from(JSON.stringify(c)).toString('base64');f.mkdirSync(w,{recursive:true});if(f.readdirSync(w)[0])process.exit(1);Object.keys(e).forEach(k=>/^(INIT_CWD|npm_config_(local_prefix|workspaces?|include_workspace_root))$/i.test(k)&&Reflect.deleteProperty(e,k));Object.assign(e,{INIT_CWD:w,npm_config_local_prefix:w,npm_config_workspaces:'false',npm_config_include_workspace_root:'false',LIVEDESK_UPDATE_NEUTRAL_CWD:w});e.i=x?{c:j,a:[x,...a]}:s==='win32'?{c:e.ComSpec||'cmd.exe',a:['/d','/s','/c','call'+z+q(n)+z+a.map(q).join(z)]}:{c:n,a};e.r=spawn(e.i.c,e.i.a,{cwd:w,env:e,detached:true,stdio:'ignore',windowsHide:true});",
75
75
  "e.r.on('error',()=>process.exit(1));e.r.unref();"
76
76
  ].join('');
77
77
  if (source.includes(' ')) throw new Error('LiveDesk legacy starter source must not contain spaces.');
@@ -685,7 +685,9 @@ function normalizeRemoteInputEvent(value = {}) {
685
685
  const captureGeneration = Number(input.captureGeneration ?? input.CaptureGeneration);
686
686
  return {
687
687
  type,
688
- monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : 0,
688
+ // A missing monitor is not equivalent to the primary display. Control
689
+ // input must prove the exact monitor owned by its capture generation.
690
+ monitorIndex: Number.isFinite(monitorIndex) ? normalizeMonitorIndex(monitorIndex) : null,
689
691
  normalizedX: Number.isFinite(normalizedX) ? Math.max(0, Math.min(1, normalizedX)) : undefined,
690
692
  normalizedY: Number.isFinite(normalizedY) ? Math.max(0, Math.min(1, normalizedY)) : undefined,
691
693
  button: safeString(input.button || input.Button, 24),
@@ -2144,9 +2146,12 @@ export function createRemoteHub(options = {}) {
2144
2146
  byteLength: frame.byteLength
2145
2147
  });
2146
2148
  streamState.latestFrame = frame;
2147
- streamState.lastFrameAt = frame.receivedAt;
2148
- streamState.lastFrameSeq = frame.frameSeq;
2149
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2149
+ streamState.lastFrameAt = frame.receivedAt;
2150
+ streamState.lastFrameSeq = frame.frameSeq;
2151
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
2152
+ // A synthetic PNG is a complete independently decodable surface and
2153
+ // therefore key-frame equivalent for Control readiness.
2154
+ streamState.readyFrameReceived = true;
2150
2155
  device.lastSeenAt = frame.receivedAt;
2151
2156
  device.counters.liveFramesReceived += 1;
2152
2157
  emitRemoteEvent('RemoteFrameReceived', device, {
@@ -3337,6 +3342,13 @@ export function createRemoteHub(options = {}) {
3337
3342
  || result.InputType
3338
3343
  || pending?.inputType,
3339
3344
  48),
3345
+ monitorIndex: Number(
3346
+ message.monitorIndex
3347
+ ?? message.MonitorIndex
3348
+ ?? result.monitorIndex
3349
+ ?? result.MonitorIndex
3350
+ ?? pending?.monitorIndex
3351
+ ?? 0) || 0,
3340
3352
  issuedAtEpochMs: Number(
3341
3353
  message.issuedAtEpochMs
3342
3354
  ?? message.IssuedAtEpochMs
@@ -3602,13 +3614,35 @@ export function createRemoteHub(options = {}) {
3602
3614
  : 0;
3603
3615
  }
3604
3616
 
3605
- function readFrameStageMs(message, property) {
3606
- const value = Number(message?.[property]);
3607
- return Number.isFinite(value) && value >= 0
3608
- ? Math.round(value)
3609
- : 0;
3610
- }
3611
-
3617
+ function readFrameStageMs(message, property) {
3618
+ const value = Number(message?.[property]);
3619
+ return Number.isFinite(value) && value >= 0
3620
+ ? Math.round(value)
3621
+ : 0;
3622
+ }
3623
+
3624
+ function readOptionalFrameStageMs(message, property) {
3625
+ const rawValue = message?.[property];
3626
+ if (rawValue === undefined || rawValue === null || rawValue === '') {
3627
+ return undefined;
3628
+ }
3629
+ const value = Number(rawValue);
3630
+ return Number.isFinite(value) && value >= 0
3631
+ ? Math.round(value)
3632
+ : undefined;
3633
+ }
3634
+
3635
+ function hasFrameCaptureTiming(message) {
3636
+ if (message?.captureTimingAvailable === false) {
3637
+ return false;
3638
+ }
3639
+ if (message?.captureTimingAvailable === true) {
3640
+ return true;
3641
+ }
3642
+ return ['captureP95Ms', 'captureAverageMs', 'captureStageMs']
3643
+ .some(property => readOptionalFrameStageMs(message, property) !== undefined);
3644
+ }
3645
+
3612
3646
  function computeSameContentStreak(previousFrame, contentHash) {
3613
3647
  if (!contentHash || !previousFrame?.contentHash || previousFrame.contentHash !== contentHash) {
3614
3648
  return 0;
@@ -3682,7 +3716,7 @@ export function createRemoteHub(options = {}) {
3682
3716
  byteLength,
3683
3717
  transport,
3684
3718
  contentHash,
3685
- captureMs: readFrameCaptureMs(message),
3719
+ captureMs: readFrameCaptureMs(message),
3686
3720
  captureStageMs: readFrameStageMs(message, 'captureStageMs'),
3687
3721
  convertMs: readFrameStageMs(message, 'convertMs'),
3688
3722
  compressMs: readFrameStageMs(message, 'compressMs'),
@@ -3774,6 +3808,7 @@ export function createRemoteHub(options = {}) {
3774
3808
  lastFrameAt: '',
3775
3809
  lastFrameSeq: 0,
3776
3810
  framesReceived: 0,
3811
+ readyFrameReceived: false,
3777
3812
  latestFrame: null,
3778
3813
  stoppedAt: '',
3779
3814
  stopReason: ''
@@ -3991,6 +4026,8 @@ export function createRemoteHub(options = {}) {
3991
4026
  && (activeCaptureGeneration <= 0
3992
4027
  || (frameCaptureGeneration > 0
3993
4028
  && frameCaptureGeneration === activeCaptureGeneration));
4029
+ const readyFrameReceivedBeforeFrame = streamState.readyFrameReceived === true;
4030
+ const captureTimingAvailable = hasFrameCaptureTiming(message);
3994
4031
  device.latestLiveFrame = {
3995
4032
  streamId,
3996
4033
  frameSeq,
@@ -4037,10 +4074,11 @@ export function createRemoteHub(options = {}) {
4037
4074
  transferProtocolVersion: transfer.transferProtocolVersion,
4038
4075
  handshake: transfer.handshake,
4039
4076
  fps: Number.isFinite(Number(message.fps)) ? Number(message.fps) : streamState.fps,
4040
- requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
4041
- effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
4042
- captureAverageMs: readFrameStageMs(message, 'captureAverageMs'),
4043
- captureP95Ms: readFrameStageMs(message, 'captureP95Ms'),
4077
+ requestedFps: Number.isFinite(Number(message.requestedFps)) ? Number(message.requestedFps) : Number(streamState.fps || 0),
4078
+ effectiveFps: Number.isFinite(Number(message.effectiveFps)) ? Number(message.effectiveFps) : Number(message.fps || streamState.fps || 0),
4079
+ captureTimingAvailable,
4080
+ captureAverageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureAverageMs') : undefined,
4081
+ captureP95Ms: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureP95Ms') : undefined,
4044
4082
  slowFrameCount: Number.isFinite(Number(message.slowFrameCount)) ? Number(message.slowFrameCount) : 0,
4045
4083
  captureHelperRestarts: Number.isFinite(Number(message.captureHelperRestarts)) ? Number(message.captureHelperRestarts) : 0,
4046
4084
  capturedAt,
@@ -4048,8 +4086,8 @@ export function createRemoteHub(options = {}) {
4048
4086
  byteLength,
4049
4087
  transport,
4050
4088
  contentHash,
4051
- captureMs: readFrameCaptureMs(message),
4052
- captureStageMs: readFrameStageMs(message, 'captureStageMs'),
4089
+ captureMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureMs') : undefined,
4090
+ captureStageMs: captureTimingAvailable ? readOptionalFrameStageMs(message, 'captureStageMs') : undefined,
4053
4091
  convertMs: readFrameStageMs(message, 'convertMs'),
4054
4092
  compressMs: readFrameStageMs(message, 'compressMs'),
4055
4093
  captureSourceSerial: Number.isFinite(Number(message.captureSourceSerial)) ? Number(message.captureSourceSerial) : 0,
@@ -4073,9 +4111,12 @@ export function createRemoteHub(options = {}) {
4073
4111
  mimeType: device.latestLiveFrame.mimeType,
4074
4112
  byteLength: device.latestLiveFrame.byteLength
4075
4113
  });
4076
- streamState.lastFrameAt = device.lastSeenAt;
4077
- streamState.lastFrameSeq = frameSeq;
4078
- streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4114
+ streamState.lastFrameAt = device.lastSeenAt;
4115
+ streamState.lastFrameSeq = frameSeq;
4116
+ streamState.framesReceived = (streamState.framesReceived || 0) + 1;
4117
+ streamState.readyFrameReceived = readyFrameReceivedBeforeFrame
4118
+ || (currentGenerationVerified
4119
+ && liveFrameSatisfiesReadiness(streamState, device.latestLiveFrame));
4079
4120
  streamState.mode = transfer.mode;
4080
4121
  streamState.frameMode = transfer.frameMode;
4081
4122
  streamState.frameProfile = transfer.frameProfile;
@@ -4093,7 +4134,9 @@ export function createRemoteHub(options = {}) {
4093
4134
  streamState.monitorCount = device.latestLiveFrame.monitorCount;
4094
4135
  ensureDeviceLiveStreams(device).set(streamId, streamState);
4095
4136
  device.counters.liveFramesReceived += 1;
4096
- if (streamState.framesReceived === 1 && liveStreamHasCurrentFrame(streamState)) {
4137
+ if (!readyFrameReceivedBeforeFrame
4138
+ && streamState.readyFrameReceived === true
4139
+ && liveStreamHasCurrentFrame(streamState)) {
4097
4140
  emitRemoteEvent('RemoteLiveStreamReady', device, {
4098
4141
  streamId,
4099
4142
  commandId: streamState.commandId,
@@ -5292,6 +5335,15 @@ export function createRemoteHub(options = {}) {
5292
5335
  activeCaptureGeneration: Number(controlStream.captureGeneration || 0)
5293
5336
  };
5294
5337
  }
5338
+ const activeMonitorIndex = normalizeMonitorIndex(controlStream.monitorIndex);
5339
+ if (!Number.isInteger(normalized.monitorIndex)
5340
+ || normalized.monitorIndex !== activeMonitorIndex) {
5341
+ return {
5342
+ ok: false,
5343
+ error: 'STALE_CONTROL_MONITOR',
5344
+ activeMonitorIndex
5345
+ };
5346
+ }
5295
5347
 
5296
5348
  const inputSocket = device.inputSocket;
5297
5349
  if (inputSocket && !inputSocket.destroyed) {
@@ -5311,7 +5363,8 @@ export function createRemoteHub(options = {}) {
5311
5363
  inputSocket: true,
5312
5364
  sessionId: device.sessionId,
5313
5365
  commandId: controlStream.commandId,
5314
- captureGeneration: controlStream.captureGeneration
5366
+ captureGeneration: controlStream.captureGeneration,
5367
+ monitorIndex: activeMonitorIndex
5315
5368
  };
5316
5369
  }
5317
5370
 
@@ -5332,6 +5385,7 @@ export function createRemoteHub(options = {}) {
5332
5385
  commandId: fallback.commandId,
5333
5386
  inputSeq: normalized.inputSeq,
5334
5387
  inputType: normalized.type,
5388
+ monitorIndex: normalized.monitorIndex,
5335
5389
  issuedAtEpochMs: normalized.issuedAtEpochMs,
5336
5390
  hubReceivedAtEpochMs: normalized.hubReceivedAtEpochMs,
5337
5391
  hubForwardedAtEpochMs,
@@ -5348,6 +5402,7 @@ export function createRemoteHub(options = {}) {
5348
5402
  deliveryCommandId: fallback.commandId,
5349
5403
  commandId: controlStream.commandId,
5350
5404
  captureGeneration: controlStream.captureGeneration,
5405
+ monitorIndex: activeMonitorIndex,
5351
5406
  inputSeq: normalized.inputSeq
5352
5407
  }
5353
5408
  : {
@@ -5855,12 +5910,31 @@ export function createRemoteHub(options = {}) {
5855
5910
  return Number.isFinite(startedAt) && Date.now() - startedAt < LIVE_STREAM_REPLACEMENT_PENDING_MS;
5856
5911
  }
5857
5912
 
5913
+ function liveStreamRequiresReadyKeyFrame(activeLiveStream) {
5914
+ if (safeString(activeLiveStream?.streamPurpose, 24).toLowerCase() !== 'control') {
5915
+ return false;
5916
+ }
5917
+ const codec = safeString(activeLiveStream?.codec, 80).toLowerCase();
5918
+ const mode = safeString(activeLiveStream?.frameMode || activeLiveStream?.mode, 80).toLowerCase();
5919
+ return codec.includes('h264') || mode === 'mode3-h264-hw';
5920
+ }
5921
+
5922
+ function liveFrameSatisfiesReadiness(activeLiveStream, frame) {
5923
+ return !liveStreamRequiresReadyKeyFrame(activeLiveStream)
5924
+ || frame?.isKeyFrame === true
5925
+ || safeString(frame?.chunkType, 20).toLowerCase() === 'key';
5926
+ }
5927
+
5858
5928
  function liveStreamHasCurrentFrame(activeLiveStream) {
5859
5929
  if (!activeLiveStream?.active
5860
5930
  || activeLiveStream.open !== true
5861
5931
  || Number(activeLiveStream.framesReceived || 0) < 1) {
5862
5932
  return false;
5863
5933
  }
5934
+ if (liveStreamRequiresReadyKeyFrame(activeLiveStream)
5935
+ && activeLiveStream.readyFrameReceived !== true) {
5936
+ return false;
5937
+ }
5864
5938
  const latestFrame = activeLiveStream.latestFrame;
5865
5939
  if (!latestFrame || latestFrame.currentGenerationVerified !== true) {
5866
5940
  return false;
@@ -5984,6 +6058,7 @@ export function createRemoteHub(options = {}) {
5984
6058
  lastFrameAt: '',
5985
6059
  lastFrameSeq: 0,
5986
6060
  framesReceived: 0,
6061
+ readyFrameReceived: false,
5987
6062
  streamPurpose,
5988
6063
  captureGeneration
5989
6064
  });
@@ -6201,8 +6276,9 @@ export function createRemoteHub(options = {}) {
6201
6276
  stoppedAt: '',
6202
6277
  stopReason: '',
6203
6278
  lastFrameAt: '',
6204
- lastFrameSeq: 0,
6279
+ lastFrameSeq: 0,
6205
6280
  framesReceived: 0,
6281
+ readyFrameReceived: false,
6206
6282
  streamPurpose,
6207
6283
  captureGeneration,
6208
6284
  latestFrame: null,
package/hub/src/server.js CHANGED
@@ -2045,8 +2045,13 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2045
2045
  fps: Number(frame.fps || 0) || 0,
2046
2046
  requestedFps: Number(frame.requestedFps || 0) || 0,
2047
2047
  effectiveFps: Number(frame.effectiveFps || 0) || 0,
2048
- captureAverageMs: Number(frame.captureAverageMs || 0) || 0,
2049
- captureP95Ms: Number(frame.captureP95Ms || 0) || 0,
2048
+ captureTimingAvailable: frame.captureTimingAvailable === true,
2049
+ captureAverageMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureAverageMs))
2050
+ ? Number(frame.captureAverageMs)
2051
+ : null,
2052
+ captureP95Ms: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureP95Ms))
2053
+ ? Number(frame.captureP95Ms)
2054
+ : null,
2050
2055
  slowFrameCount: Number(frame.slowFrameCount || 0) || 0,
2051
2056
  captureHelperRestarts: Number(frame.captureHelperRestarts || 0) || 0,
2052
2057
  capturedAt: frame.capturedAt || '',
@@ -2055,8 +2060,12 @@ function buildRemoteFrameBinaryPacket(frameEvent, diagnostics = {}) {
2055
2060
  hubPacketEpochMs,
2056
2061
  byteLength: Number(frameEvent.byteLength || payload.length) || payload.length,
2057
2062
  contentHash: frame.contentHash || '',
2058
- captureMs: Number(frame.captureMs || 0) || 0,
2059
- captureStageMs: Number(frame.captureStageMs || 0) || 0,
2063
+ captureMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureMs))
2064
+ ? Number(frame.captureMs)
2065
+ : null,
2066
+ captureStageMs: frame.captureTimingAvailable === true && Number.isFinite(Number(frame.captureStageMs))
2067
+ ? Number(frame.captureStageMs)
2068
+ : null,
2060
2069
  convertMs: Number(frame.convertMs || 0) || 0,
2061
2070
  compressMs: Number(frame.compressMs || 0) || 0,
2062
2071
  captureSourceSerial: Number(frame.captureSourceSerial || 0) || 0,
@@ -2157,7 +2166,8 @@ function broadcastRemoteBinaryFrame(frameEvent) {
2157
2166
  || Number(frame.monitorIndex || 0) !== expectedBinding.monitorIndex) {
2158
2167
  continue;
2159
2168
  }
2160
- if (!expectedBinding.readySent) {
2169
+ const requiresReadyKeyFrame = String(expectedBinding.streamPurpose || '').toLowerCase() === 'control' && isH264;
2170
+ if (!expectedBinding.readySent && (!requiresReadyKeyFrame || isKeyFrame)) {
2161
2171
  expectedBinding.readySent = sendJson(client, {
2162
2172
  type: 'RemoteFrameStreamReady',
2163
2173
  deviceId,