@livedesk/client 0.1.179 → 0.1.181

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,8 +51,9 @@ the matching callback URL to Supabase Auth redirect URLs.
51
51
  The client registers the device, sends status heartbeats, can return
52
52
  Hub-requested thumbnails, can stream a focused view-only live screen, and
53
53
  can receive safe task-only instructions. The C# RemoteFast engine also supports
54
- keyboard/mouse control on Windows, macOS, and Linux/X11, file transfer, and
55
- Windows system-audio loopback playback. macOS control requires Accessibility
54
+ keyboard/mouse control on Windows, macOS, and Linux/X11, file transfer, and
55
+ system-audio playback from Windows, macOS, and Linux. Linux captures the default
56
+ output through PipeWire `pw-record` or PulseAudio `parec`. macOS control requires Accessibility
56
57
  permission for the terminal app that started LiveDesk; screen streaming and
57
58
  system-audio capture require Screen Recording permission. LiveDesk checks these
58
59
  permissions without opening repeated system dialogs and prints the matching
@@ -13,6 +13,10 @@ import { formatClientVersionBanner, readClientPackageVersion } from './client-ve
13
13
  import { createClientRuntimeServer } from '../src/runtime/client-runtime-server.js';
14
14
  import { installAgentTerminationHandlers } from '../src/runtime/agent-process-lifecycle.js';
15
15
  import { createHubWakeListener } from '../src/runtime/hub-wake-listener.js';
16
+ import {
17
+ inspectLinuxVideoAcceleration,
18
+ installLinuxVideoAcceleration
19
+ } from '../src/runtime/linux-video-acceleration.js';
16
20
  import { normalizeRuntimeAuthSession } from '../../runtime-core/src/auth-session.js';
17
21
  import { startRoleTransitionSupervisor } from '../../runtime-core/src/role-transition-supervisor.js';
18
22
 
@@ -48,10 +52,13 @@ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
48
52
  const CLIENT_HUB_TARGET_CACHE_STORAGE_KEY = 'livedesk.client.last-hub-target';
49
53
  const DEVICE_ROLE_CACHE_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'device-role.json');
50
54
  const FAST_PREFLIGHT_CACHE_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'fast-preflight.json');
55
+ const CLIENT_SLOT_PATH = join(UNIFIED_CLIENT_STATE_DIR, 'device-slot.json');
51
56
  const CLIENT_UPDATE_STATE_PATH = process.env.LIVEDESK_CLIENT_UPDATE_STATE_PATH
52
57
  || join(UNIFIED_CLIENT_STATE_DIR, 'client-update.json');
53
58
  let activeAgentProcess = null;
54
59
  let roleRestartRequest = null;
60
+ let agentRestartRequest = null;
61
+ let linuxVideoAccelerationStatus = null;
55
62
  let discoveryWakeController = new AbortController();
56
63
  let networkChangeMonitor = null;
57
64
  const sessionRefreshesInFlight = new WeakMap();
@@ -436,10 +443,48 @@ function normalizeSlotNumber(value) {
436
443
  if (!Number.isInteger(number) || number < 1 || number > 999) {
437
444
  return '';
438
445
  }
439
- return String(number);
440
- }
441
-
442
- function upsertForwardedOption(args, flag, value) {
446
+ return String(number);
447
+ }
448
+
449
+ function readSavedDeviceSlot(deviceId = '') {
450
+ try {
451
+ const state = JSON.parse(readFileSync(CLIENT_SLOT_PATH, 'utf8'));
452
+ const savedDeviceId = String(state?.deviceId || '').trim();
453
+ const expectedDeviceId = String(deviceId || '').trim();
454
+ if (savedDeviceId && expectedDeviceId && savedDeviceId !== expectedDeviceId) {
455
+ return '';
456
+ }
457
+ return normalizeSlotNumber(state?.slotNumber);
458
+ } catch {
459
+ return '';
460
+ }
461
+ }
462
+
463
+ function writeSavedDeviceSlot(deviceId, slotNumber) {
464
+ const normalizedDeviceId = String(deviceId || '').trim();
465
+ const normalizedSlot = normalizeSlotNumber(slotNumber);
466
+ if (!normalizedDeviceId || !normalizedSlot) {
467
+ throw new Error('device-id-and-slot-required');
468
+ }
469
+ mkdirSync(dirname(CLIENT_SLOT_PATH), { recursive: true });
470
+ const temporaryPath = `${CLIENT_SLOT_PATH}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
471
+ try {
472
+ writeFileSync(temporaryPath, JSON.stringify({
473
+ deviceId: normalizedDeviceId,
474
+ slotNumber: Number(normalizedSlot),
475
+ updatedAt: new Date().toISOString()
476
+ }, null, 2), 'utf8');
477
+ try { chmodSync(temporaryPath, 0o600); } catch { /* Windows profile ACLs remain authoritative. */ }
478
+ renameSync(temporaryPath, CLIENT_SLOT_PATH);
479
+ try { chmodSync(CLIENT_SLOT_PATH, 0o600); } catch { /* Windows profile ACLs remain authoritative. */ }
480
+ } catch (error) {
481
+ rmSync(temporaryPath, { force: true });
482
+ throw error;
483
+ }
484
+ return Number(normalizedSlot);
485
+ }
486
+
487
+ function upsertForwardedOption(args, flag, value) {
443
488
  if (!value) {
444
489
  return args;
445
490
  }
@@ -2975,6 +3020,7 @@ async function chooseClientConnection(supabase, options = {}) {
2975
3020
  appVersion: process.env.LIVEDESK_NPM_LAUNCHER_VERSION || readPackageVersion(),
2976
3021
  deviceId: options.deviceId,
2977
3022
  engine: options.engine,
3023
+ slot: options.slot,
2978
3024
  savedSession: options.savedSession,
2979
3025
  initialChoice: options.initialChoice,
2980
3026
  initialChoiceMessage: options.initialChoiceMessage,
@@ -3033,7 +3079,41 @@ async function chooseClientConnection(supabase, options = {}) {
3033
3079
  requestLocalDiscoveryWake('role-change');
3034
3080
  try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop observes the role request */ }
3035
3081
  return { ok: true, restarting: true, role, roleVersion };
3036
- },
3082
+ },
3083
+ videoAcceleration: linuxVideoAccelerationStatus,
3084
+ installVideoAcceleration: async () => {
3085
+ const result = await installLinuxVideoAcceleration(
3086
+ linuxVideoAccelerationStatus || inspectLinuxVideoAcceleration()
3087
+ );
3088
+ linuxVideoAccelerationStatus = result;
3089
+ return result;
3090
+ },
3091
+ onVideoAccelerationReady: result => {
3092
+ if (!result?.ready || process.platform !== 'linux') return;
3093
+ agentRestartRequest = {
3094
+ reason: 'linux-video-acceleration-ready',
3095
+ requestedAt: new Date().toISOString()
3096
+ };
3097
+ requestLocalDiscoveryWake('linux-video-acceleration-ready');
3098
+ try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop restarts with the new ffmpeg preference */ }
3099
+ },
3100
+ assignSlot: async request => {
3101
+ const result = await requestHubSlotAssignment(request);
3102
+ if (result?.ok !== true) {
3103
+ return result;
3104
+ }
3105
+ const slotNumber = writeSavedDeviceSlot(request.deviceId, result.slotNumber);
3106
+ agentRestartRequest = {
3107
+ reason: 'slot-updated',
3108
+ slotNumber,
3109
+ requestedAt: new Date().toISOString()
3110
+ };
3111
+ requestLocalDiscoveryWake('slot-updated');
3112
+ setTimeout(() => {
3113
+ try { activeAgentProcess?.kill(); } catch { /* the lifecycle loop restarts with the saved slot */ }
3114
+ }, 150);
3115
+ return { ...result, slotNumber, saved: true, restarting: true };
3116
+ },
3037
3117
  onRestart: () => process.kill(process.pid, 'SIGTERM'),
3038
3118
  onShutdown: () => process.kill(process.pid, 'SIGTERM'),
3039
3119
  onDisconnect: () => activeAgentProcess?.kill(),
@@ -3075,7 +3155,7 @@ async function chooseClientConnection(supabase, options = {}) {
3075
3155
  return { ...choice, connectionPage };
3076
3156
  }
3077
3157
 
3078
- function parseManagerEndpoint(value) {
3158
+ function parseManagerEndpoint(value) {
3079
3159
  let text = String(value || '').trim().replace(/^tcp:\/\//i, '');
3080
3160
  const separator = text.lastIndexOf(':');
3081
3161
  if (separator <= 0) {
@@ -3086,9 +3166,71 @@ function parseManagerEndpoint(value) {
3086
3166
  if (!host || !Number.isFinite(port)) {
3087
3167
  return null;
3088
3168
  }
3089
- return { host, port };
3090
- }
3091
-
3169
+ return { host, port };
3170
+ }
3171
+
3172
+ function requestHubSlotAssignment({ manager, pairToken, deviceId, slotNumber, timeoutMs = 5000 }) {
3173
+ const endpoint = parseManagerEndpoint(manager);
3174
+ const normalizedPairToken = String(pairToken || '').trim();
3175
+ const normalizedDeviceId = String(deviceId || '').trim();
3176
+ const normalizedSlot = normalizeSlotNumber(slotNumber);
3177
+ if (!endpoint) {
3178
+ return Promise.resolve({ ok: false, error: 'hub-endpoint-unavailable' });
3179
+ }
3180
+ if (!normalizedPairToken) {
3181
+ return Promise.resolve({ ok: false, error: 'hub-pair-token-unavailable' });
3182
+ }
3183
+ if (!normalizedDeviceId) {
3184
+ return Promise.resolve({ ok: false, error: 'device-id-required' });
3185
+ }
3186
+ if (!normalizedSlot) {
3187
+ return Promise.resolve({ ok: false, error: 'invalid-slot-number' });
3188
+ }
3189
+
3190
+ return new Promise(resolveAssignment => {
3191
+ const socket = net.createConnection(endpoint);
3192
+ let settled = false;
3193
+ let buffer = '';
3194
+ const settle = result => {
3195
+ if (settled) return;
3196
+ settled = true;
3197
+ socket.removeAllListeners();
3198
+ socket.destroy();
3199
+ resolveAssignment(result);
3200
+ };
3201
+ socket.setEncoding('utf8');
3202
+ socket.setTimeout(timeoutMs);
3203
+ socket.once('connect', () => {
3204
+ socket.write(`${JSON.stringify({
3205
+ type: 'slot.assign',
3206
+ pairToken: normalizedPairToken,
3207
+ deviceId: normalizedDeviceId,
3208
+ slotNumber: Number(normalizedSlot)
3209
+ })}\n`);
3210
+ });
3211
+ socket.on('data', chunk => {
3212
+ buffer += chunk;
3213
+ const newlineIndex = buffer.indexOf('\n');
3214
+ if (newlineIndex < 0) return;
3215
+ try {
3216
+ const response = JSON.parse(buffer.slice(0, newlineIndex).trim());
3217
+ if (response?.type !== 'slot.assignment') {
3218
+ settle({ ok: false, error: response?.error || 'unexpected-hub-response' });
3219
+ return;
3220
+ }
3221
+ settle(response);
3222
+ } catch {
3223
+ settle({ ok: false, error: 'invalid-hub-response' });
3224
+ }
3225
+ });
3226
+ socket.once('timeout', () => settle({ ok: false, error: 'hub-slot-request-timeout' }));
3227
+ socket.once('error', error => settle({ ok: false, error: error?.message || 'hub-slot-request-failed' }));
3228
+ socket.once('close', () => {
3229
+ if (!settled) settle({ ok: false, error: 'hub-slot-response-missing' });
3230
+ });
3231
+ });
3232
+ }
3233
+
3092
3234
  export function canConnectToEndpoint(endpoint, timeoutMs = ENDPOINT_PROBE_TIMEOUT_MS) {
3093
3235
  const parsedEndpoint = parseManagerEndpoint(endpoint);
3094
3236
  if (!parsedEndpoint) {
@@ -3734,7 +3876,7 @@ function buildClientAgentEnvironment(prepared) {
3734
3876
  return env;
3735
3877
  }
3736
3878
 
3737
- function buildFastEnvironment(prepared) {
3879
+ function buildFastEnvironment(prepared) {
3738
3880
  const env = buildClientAgentEnvironment(prepared);
3739
3881
  env.LIVEDESK_NODE_EXECUTABLE = process.execPath;
3740
3882
  env.LIVEDESK_CLIENT_PACKAGE_VERSION = readPackageVersion();
@@ -3743,7 +3885,11 @@ function buildFastEnvironment(prepared) {
3743
3885
  const configuredPaths = env.LIVEDESK_FFMPEG_PATHS
3744
3886
  ? env.LIVEDESK_FFMPEG_PATHS.split(separator).filter(Boolean)
3745
3887
  : [];
3746
- const configuredSingle = env.LIVEDESK_FFMPEG ? [env.LIVEDESK_FFMPEG] : [];
3888
+ const configuredSingle = env.LIVEDESK_FFMPEG ? [env.LIVEDESK_FFMPEG] : [];
3889
+ const systemHardwareFfmpeg = process.platform === 'linux'
3890
+ && linuxVideoAccelerationStatus?.ready
3891
+ ? [linuxVideoAccelerationStatus.ffmpegPath]
3892
+ : [];
3747
3893
  const merged = [];
3748
3894
  const addMerged = path => {
3749
3895
  const value = String(path || '').trim();
@@ -3759,18 +3905,26 @@ function buildFastEnvironment(prepared) {
3759
3905
  if (bundledFfmpegPaths[0]) {
3760
3906
  env.LIVEDESK_FFMPEG = bundledFfmpegPaths[0];
3761
3907
  }
3762
- } else {
3763
- configuredPaths.forEach(addMerged);
3764
- configuredSingle.forEach(addMerged);
3765
- bundledFfmpegPaths.forEach(addMerged);
3766
- if (!env.LIVEDESK_FFMPEG && merged[0]) {
3767
- env.LIVEDESK_FFMPEG = merged[0];
3768
- }
3769
- }
3908
+ } else {
3909
+ configuredPaths.forEach(addMerged);
3910
+ configuredSingle.forEach(addMerged);
3911
+ systemHardwareFfmpeg.forEach(addMerged);
3912
+ bundledFfmpegPaths.forEach(addMerged);
3913
+ if (!env.LIVEDESK_FFMPEG && merged[0]) {
3914
+ env.LIVEDESK_FFMPEG = merged[0];
3915
+ }
3916
+ }
3770
3917
 
3771
- if (merged.length > 0) {
3772
- env.LIVEDESK_FFMPEG_PATHS = merged.join(separator);
3773
- }
3918
+ if (merged.length > 0) {
3919
+ env.LIVEDESK_FFMPEG_PATHS = merged.join(separator);
3920
+ }
3921
+ if (process.platform === 'linux'
3922
+ && linuxVideoAccelerationStatus?.ready
3923
+ && linuxVideoAccelerationStatus.driver
3924
+ && linuxVideoAccelerationStatus.driver !== 'auto'
3925
+ && !env.LIBVA_DRIVER_NAME) {
3926
+ env.LIBVA_DRIVER_NAME = linuxVideoAccelerationStatus.driver;
3927
+ }
3774
3928
 
3775
3929
  if (process.platform === 'darwin' || process.platform === 'linux' || env.LIVEDESK_DEBUG_FFMPEG === '1') {
3776
3930
  const describe = path => path.includes('ffmpeg-static')
@@ -4008,12 +4162,28 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4008
4162
  return;
4009
4163
  }
4010
4164
 
4011
- console.log(formatClientVersionBanner());
4165
+ console.log(formatClientVersionBanner());
4166
+ linuxVideoAccelerationStatus = inspectLinuxVideoAcceleration();
4167
+ if (process.platform === 'linux') {
4168
+ const accelerationLabel = linuxVideoAccelerationStatus.ready
4169
+ ? `${linuxVideoAccelerationStatus.encoder} via ${linuxVideoAccelerationStatus.ffmpegPath}`
4170
+ : linuxVideoAccelerationStatus.message || linuxVideoAccelerationStatus.state;
4171
+ console.log(`[LiveDesk Client] Linux video acceleration: ${accelerationLabel}`);
4172
+ }
4012
4173
 
4013
- let connectionPage = null;
4014
- while (true) {
4015
- const prepared = await prepareLoginConnection(parsed, connectionPage);
4016
- connectionPage = prepared.connectionPage || connectionPage;
4174
+ let connectionPage = null;
4175
+ while (true) {
4176
+ const savedSlot = readSavedDeviceSlot(parsed.deviceId);
4177
+ if (savedSlot) {
4178
+ parsed.slot = savedSlot;
4179
+ }
4180
+ const prepared = await prepareLoginConnection(parsed, connectionPage);
4181
+ connectionPage = prepared.connectionPage || connectionPage;
4182
+ prepared.connectionPage?.update({
4183
+ manager: prepared.manager,
4184
+ pairToken: prepared.pair,
4185
+ slotNumber: Number(prepared.slot || 0)
4186
+ });
4017
4187
  const roleRestartBeforeAgent = connectionPage?.getRoleRestartRequest?.() || roleRestartRequest;
4018
4188
  if (roleRestartBeforeAgent?.role) {
4019
4189
  if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) process.exit(43);
@@ -4114,8 +4284,23 @@ export async function runClientRuntime(argv = process.argv.slice(2)) {
4114
4284
  await spawnUnifiedRoleRestart(requestedRoleRestart.role);
4115
4285
  connectionPage?.close?.();
4116
4286
  process.exit(0);
4117
- }
4118
- if (result?.signal) {
4287
+ }
4288
+ if (agentRestartRequest) {
4289
+ const restart = agentRestartRequest;
4290
+ agentRestartRequest = null;
4291
+ connectionPage?.update({
4292
+ agent: {
4293
+ requestedEngine: prepared.engine,
4294
+ state: 'reconnecting'
4295
+ },
4296
+ message: restart.reason === 'slot-updated'
4297
+ ? `Slot ${String(restart.slotNumber || prepared.slot || '').padStart(3, '0')} saved. Restarting the LiveDesk Agent.`
4298
+ : 'Linux hardware video is ready. Restarting the LiveDesk Agent.'
4299
+ });
4300
+ console.log(`[LiveDesk Client] Restarting Agent after ${restart.reason}.`);
4301
+ continue;
4302
+ }
4303
+ if (result?.signal) {
4119
4304
  connectionPage?.close?.();
4120
4305
  process.kill(process.pid, result.signal);
4121
4306
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livedesk/client",
3
- "version": "0.1.179",
3
+ "version": "0.1.181",
4
4
  "description": "LiveDesk local remote client",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,10 +39,10 @@
39
39
  "ws": "^8.18.3"
40
40
  },
41
41
  "optionalDependencies": {
42
- "@livedesk/fast-linux-x64": "0.1.385",
43
- "@livedesk/fast-osx-arm64": "0.1.385",
44
- "@livedesk/fast-osx-x64": "0.1.385",
45
- "@livedesk/fast-win-x64": "0.1.385"
42
+ "@livedesk/fast-linux-x64": "0.1.387",
43
+ "@livedesk/fast-osx-arm64": "0.1.387",
44
+ "@livedesk/fast-osx-x64": "0.1.387",
45
+ "@livedesk/fast-win-x64": "0.1.387"
46
46
  },
47
47
  "publishConfig": {
48
48
  "access": "public"
@@ -43,6 +43,13 @@ function normalizePort(value, fallback = DEFAULT_PORT) {
43
43
  return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallback;
44
44
  }
45
45
 
46
+ function normalizeSlotNumber(value) {
47
+ const slotNumber = Number(String(value ?? '').trim());
48
+ return Number.isInteger(slotNumber) && slotNumber >= 1 && slotNumber <= 999
49
+ ? slotNumber
50
+ : 0;
51
+ }
52
+
46
53
  function readCpuTotals() {
47
54
  return os.cpus().reduce((totals, cpu) => {
48
55
  const times = Object.values(cpu.times || {}).map(Number);
@@ -543,8 +550,10 @@ export function createClientRuntimeServer(options = {}) {
543
550
  let networkRefreshTimer;
544
551
  const csrfToken = randomBytes(32).toString('hex');
545
552
  const state = {
546
- route: '/computer',
553
+ route: '/computer',
547
554
  manager: '',
555
+ pairToken: '',
556
+ slotNumber: normalizeSlotNumber(options.slot),
548
557
  assignedHubId: '',
549
558
  endpointCandidates: [],
550
559
  roleVersion: 0,
@@ -552,6 +561,9 @@ export function createClientRuntimeServer(options = {}) {
552
561
  message: 'Sign in with Google or enter a Hub PIN to start this Client.',
553
562
  startup: false,
554
563
  agent: { requestedEngine: normalizeString(options.engine, 40), state: 'waiting', pid: null, engine: '' },
564
+ videoAcceleration: options.videoAcceleration && typeof options.videoAcceleration === 'object'
565
+ ? { ...options.videoAcceleration, busy: false }
566
+ : null,
555
567
  lastError: '',
556
568
  auth: {
557
569
  lastAttemptAt: '',
@@ -719,8 +731,9 @@ export function createClientRuntimeServer(options = {}) {
719
731
  runtimeId: `client-${snapshot.runtimePid}-${snapshot.startedAt}`,
720
732
  runtimePid: snapshot.runtimePid,
721
733
  startedAt: snapshot.startedAt,
722
- route: state.route,
734
+ route: state.route,
723
735
  manager: state.manager,
736
+ slotNumber: state.slotNumber,
724
737
  assignedHubId: state.assignedHubId,
725
738
  endpointCandidates: state.endpointCandidates,
726
739
  roleVersion: state.roleVersion,
@@ -731,8 +744,9 @@ export function createClientRuntimeServer(options = {}) {
731
744
  message: state.message,
732
745
  lastError: state.lastError,
733
746
  auth: { ...state.auth },
734
- startup: state.startup,
735
- agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
747
+ startup: state.startup,
748
+ agent: { ...state.agent, pid: runtime.getSnapshot().agentPid },
749
+ videoAcceleration: state.videoAcceleration ? { ...state.videoAcceleration } : null,
736
750
  system: {
737
751
  platform: process.platform,
738
752
  arch: process.arch,
@@ -951,13 +965,121 @@ export function createClientRuntimeServer(options = {}) {
951
965
  setTimeout(() => options.onRestart?.(), 150);
952
966
  return;
953
967
  }
954
- if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
955
- runtime.emit('runtime.shutdown.requested', { role: 'client' });
968
+ if (pathname === '/api/runtime/shutdown' && req.method === 'POST') {
969
+ runtime.emit('runtime.shutdown.requested', { role: 'client' });
956
970
  respondJson(200, { ok: true, shuttingDown: true, role: 'client' });
957
- setTimeout(() => options.onShutdown?.(), 150);
958
- return;
959
- }
960
- if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
971
+ setTimeout(() => options.onShutdown?.(), 150);
972
+ return;
973
+ }
974
+ if (pathname === '/api/client/slot' && req.method === 'POST') {
975
+ const body = parseJsonBody(await readBody(req));
976
+ const slotNumber = normalizeSlotNumber(body.slotNumber ?? body.slot);
977
+ if (!slotNumber) {
978
+ respondJson(400, { ok: false, error: 'invalid-slot-number', slotNumber: state.slotNumber });
979
+ return;
980
+ }
981
+ if (!state.manager || !state.pairToken || state.agent.state !== 'running') {
982
+ respondJson(409, { ok: false, error: 'hub-not-connected', slotNumber: state.slotNumber });
983
+ return;
984
+ }
985
+ if (typeof options.assignSlot !== 'function') {
986
+ respondJson(409, { ok: false, error: 'slot-assignment-unavailable', slotNumber: state.slotNumber });
987
+ return;
988
+ }
989
+ runtime.emit('client.slot.assignment-requested', {
990
+ deviceId,
991
+ previousSlotNumber: state.slotNumber,
992
+ slotNumber
993
+ });
994
+ try {
995
+ const result = await options.assignSlot({
996
+ manager: state.manager,
997
+ pairToken: state.pairToken,
998
+ deviceId,
999
+ slotNumber
1000
+ });
1001
+ if (result?.ok !== true) {
1002
+ const error = normalizeString(result?.error, 120) || 'slot-assignment-failed';
1003
+ runtime.emit('client.slot.assignment-rejected', {
1004
+ deviceId,
1005
+ slotNumber,
1006
+ error,
1007
+ conflictDeviceId: normalizeString(result?.conflict?.deviceId, 160)
1008
+ });
1009
+ respondJson(error === 'slot-taken' ? 409 : 502, {
1010
+ ...(result && typeof result === 'object' ? result : {}),
1011
+ ok: false,
1012
+ error,
1013
+ slotNumber: state.slotNumber
1014
+ });
1015
+ return;
1016
+ }
1017
+ state.slotNumber = slotNumber;
1018
+ state.message = `Slot ${String(slotNumber).padStart(3, '0')} saved. Reconnecting this Client.`;
1019
+ runtime.emit('client.slot.assigned', { deviceId, slotNumber });
1020
+ respondJson(200, { ...getState(), ...result, ok: true, slotNumber });
1021
+ } catch (error) {
1022
+ const message = normalizeString(error?.message || error, 240) || 'slot-assignment-failed';
1023
+ runtime.emit('client.slot.assignment-failed', { deviceId, slotNumber, error: message });
1024
+ respondJson(502, { ok: false, error: message, slotNumber: state.slotNumber });
1025
+ }
1026
+ return;
1027
+ }
1028
+ if (pathname === '/api/client/video-acceleration/install' && req.method === 'POST') {
1029
+ if (process.platform !== 'linux') {
1030
+ respondJson(409, { ok: false, error: 'linux-video-acceleration-only' });
1031
+ return;
1032
+ }
1033
+ if (state.videoAcceleration?.busy) {
1034
+ respondJson(409, { ok: false, error: 'video-acceleration-install-in-progress', ...getState() });
1035
+ return;
1036
+ }
1037
+ if (typeof options.installVideoAcceleration !== 'function') {
1038
+ respondJson(409, { ok: false, error: 'video-acceleration-install-unavailable' });
1039
+ return;
1040
+ }
1041
+ state.videoAcceleration = {
1042
+ ...(state.videoAcceleration || {}),
1043
+ supported: true,
1044
+ state: 'installing',
1045
+ busy: true,
1046
+ ready: false,
1047
+ error: '',
1048
+ message: 'Installing Linux hardware video support…'
1049
+ };
1050
+ runtime.emit('client.video-acceleration.installing', {
1051
+ packageManager: normalizeString(state.videoAcceleration.packageManager?.id, 40)
1052
+ });
1053
+ try {
1054
+ const result = await options.installVideoAcceleration();
1055
+ state.videoAcceleration = {
1056
+ ...(result && typeof result === 'object' ? result : {}),
1057
+ busy: false
1058
+ };
1059
+ const ok = state.videoAcceleration.ready === true;
1060
+ runtime.emit(ok ? 'client.video-acceleration.ready' : 'client.video-acceleration.failed', {
1061
+ encoder: normalizeString(state.videoAcceleration.encoder, 80),
1062
+ driver: normalizeString(state.videoAcceleration.driver, 80),
1063
+ error: normalizeString(state.videoAcceleration.error, 240)
1064
+ });
1065
+ respondJson(ok ? 200 : 409, { ...getState(), ok });
1066
+ if (ok && state.videoAcceleration.restartAgent) {
1067
+ setTimeout(() => options.onVideoAccelerationReady?.(state.videoAcceleration), 150);
1068
+ }
1069
+ } catch (error) {
1070
+ state.videoAcceleration = {
1071
+ ...(state.videoAcceleration || {}),
1072
+ state: 'error',
1073
+ busy: false,
1074
+ ready: false,
1075
+ error: normalizeString(error?.message || error, 320)
1076
+ };
1077
+ runtime.emit('client.video-acceleration.failed', { error: state.videoAcceleration.error });
1078
+ respondJson(409, { ok: false, ...getState(), error: state.videoAcceleration.error });
1079
+ }
1080
+ return;
1081
+ }
1082
+ if (pathname === '/api/runtime/switch-role' && req.method === 'POST') {
961
1083
  const body = parseJsonBody(await readBody(req));
962
1084
  if (String(body.role || '').trim().toLowerCase() !== 'hub') {
963
1085
  respondJson(400, { ok: false, error: 'client-can-only-transition-to-hub' });
@@ -1077,6 +1199,8 @@ export function createClientRuntimeServer(options = {}) {
1077
1199
  if (patch.agent.state === 'running') runtime.update({ state: RuntimeState.RUNNING }, 'agent-running');
1078
1200
  }
1079
1201
  if (patch.manager) state.manager = normalizeString(patch.manager, 256);
1202
+ if (patch.pairToken) state.pairToken = normalizeString(patch.pairToken, 256);
1203
+ if (normalizeSlotNumber(patch.slotNumber)) state.slotNumber = normalizeSlotNumber(patch.slotNumber);
1080
1204
  if (patch.assignedHubId) state.assignedHubId = normalizeString(patch.assignedHubId, 160);
1081
1205
  if (patch.message) state.message = normalizeString(patch.message, 1000);
1082
1206
  if (Number.isInteger(patch.roleVersion)) state.roleVersion = patch.roleVersion;
@@ -0,0 +1,390 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
3
+ import { delimiter, join, resolve } from 'node:path';
4
+
5
+ const PROBE_TIMEOUT_MS = 6_000;
6
+ const INTEL_VENDOR_ID = '0x8086';
7
+ const AMD_VENDOR_ID = '0x1002';
8
+
9
+ function cleanText(value, maxLength = 320) {
10
+ return String(value ?? '').replace(/[\r\n\t]+/g, ' ').trim().slice(0, maxLength);
11
+ }
12
+
13
+ function executableExists(path) {
14
+ try {
15
+ accessSync(path, constants.X_OK);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function uniquePaths(values) {
23
+ const result = [];
24
+ for (const value of values) {
25
+ const candidate = String(value || '').trim();
26
+ if (!candidate) continue;
27
+ let normalized = candidate;
28
+ try {
29
+ normalized = realpathSync(candidate);
30
+ } catch {
31
+ normalized = resolve(candidate);
32
+ }
33
+ if (!result.includes(normalized)) result.push(normalized);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ export function findExecutable(name, options = {}) {
39
+ const platform = options.platform || process.platform;
40
+ const env = options.env || process.env;
41
+ const exists = options.executableExists || executableExists;
42
+ const executableName = platform === 'win32' && !String(name).toLowerCase().endsWith('.exe')
43
+ ? `${name}.exe`
44
+ : String(name);
45
+ const candidates = String(env.PATH || '')
46
+ .split(delimiter)
47
+ .filter(Boolean)
48
+ .map(entry => join(entry, executableName));
49
+ return candidates.find(candidate => exists(candidate)) || '';
50
+ }
51
+
52
+ function findLinuxRenderDevices(options = {}) {
53
+ if (Array.isArray(options.renderDevices)) {
54
+ return uniquePaths(options.renderDevices);
55
+ }
56
+ const driRoot = options.driRoot || '/dev/dri';
57
+ try {
58
+ return uniquePaths(readdirSync(driRoot)
59
+ .filter(name => /^renderD\d+$/.test(name))
60
+ .map(name => join(driRoot, name))
61
+ .filter(executable => existsSync(executable)));
62
+ } catch {
63
+ return [];
64
+ }
65
+ }
66
+
67
+ function resolveSystemFfmpeg(options = {}) {
68
+ const env = options.env || process.env;
69
+ const exists = options.executableExists || executableExists;
70
+ const explicit = String(env.LIVEDESK_SYSTEM_FFMPEG || '').trim();
71
+ const candidates = uniquePaths([
72
+ explicit,
73
+ '/usr/bin/ffmpeg',
74
+ '/usr/local/bin/ffmpeg',
75
+ '/snap/bin/ffmpeg',
76
+ findExecutable('ffmpeg', { ...options, env, platform: 'linux', executableExists: exists })
77
+ ]);
78
+ return candidates.find(candidate => exists(candidate) && !candidate.includes('/node_modules/')) || '';
79
+ }
80
+
81
+ function readGpuVendor(renderDevice, options = {}) {
82
+ if (options.gpuVendor) return String(options.gpuVendor).trim().toLowerCase();
83
+ try {
84
+ return readFileSync(`/sys/class/drm/${renderDevice.split('/').pop()}/device/vendor`, 'utf8').trim().toLowerCase();
85
+ } catch {
86
+ return '';
87
+ }
88
+ }
89
+
90
+ export function detectLinuxPackageManager(options = {}) {
91
+ const managers = [
92
+ { id: 'apt', command: 'apt-get' },
93
+ { id: 'dnf', command: 'dnf' },
94
+ { id: 'pacman', command: 'pacman' },
95
+ { id: 'zypper', command: 'zypper' }
96
+ ];
97
+ for (const manager of managers) {
98
+ const path = findExecutable(manager.command, { ...options, platform: 'linux' });
99
+ if (path) return { ...manager, path };
100
+ }
101
+ return null;
102
+ }
103
+
104
+ function packagesFor(managerId, gpuVendor) {
105
+ if (managerId === 'apt') {
106
+ if (gpuVendor === INTEL_VENDOR_ID) return ['ffmpeg', 'vainfo', 'intel-media-va-driver', 'i965-va-driver'];
107
+ if (gpuVendor === AMD_VENDOR_ID) return ['ffmpeg', 'vainfo', 'mesa-va-drivers'];
108
+ return ['ffmpeg', 'vainfo'];
109
+ }
110
+ if (managerId === 'dnf') {
111
+ return ['ffmpeg', 'libva-utils'];
112
+ }
113
+ if (managerId === 'pacman') {
114
+ if (gpuVendor === INTEL_VENDOR_ID) return ['ffmpeg', 'libva-utils', 'intel-media-driver', 'libva-intel-driver'];
115
+ if (gpuVendor === AMD_VENDOR_ID) return ['ffmpeg', 'libva-utils', 'libva-mesa-driver'];
116
+ return ['ffmpeg', 'libva-utils'];
117
+ }
118
+ if (managerId === 'zypper') {
119
+ return ['ffmpeg', 'libva-utils'];
120
+ }
121
+ return [];
122
+ }
123
+
124
+ export function buildLinuxVideoInstallPlan(status, options = {}) {
125
+ const manager = options.packageManager || status?.packageManager;
126
+ if (!manager?.id || !manager?.path) return null;
127
+ const packages = packagesFor(manager.id, status?.gpuVendor || '');
128
+ if (packages.length === 0) return null;
129
+ let args;
130
+ if (manager.id === 'apt' || manager.id === 'dnf') {
131
+ args = ['install', '-y', ...packages];
132
+ } else if (manager.id === 'pacman') {
133
+ args = ['-S', '--needed', '--noconfirm', ...packages];
134
+ } else if (manager.id === 'zypper') {
135
+ args = ['--non-interactive', 'install', ...packages];
136
+ } else {
137
+ return null;
138
+ }
139
+ return {
140
+ manager,
141
+ packages,
142
+ args,
143
+ displayCommand: `sudo ${manager.command} ${args.join(' ')}`
144
+ };
145
+ }
146
+
147
+ export function probeLinuxVideoAcceleration(ffmpegPath, renderDevice, options = {}) {
148
+ const run = options.spawnSyncImpl || spawnSync;
149
+ const env = options.env || process.env;
150
+ const drivers = Array.isArray(options.drivers) && options.drivers.length > 0
151
+ ? options.drivers
152
+ : ['', 'iHD', 'i965'];
153
+ let lastError = '';
154
+ for (const driver of drivers) {
155
+ const probeEnv = { ...env };
156
+ if (driver) probeEnv.LIBVA_DRIVER_NAME = driver;
157
+ else delete probeEnv.LIBVA_DRIVER_NAME;
158
+ const result = run(ffmpegPath, [
159
+ '-hide_banner', '-loglevel', 'error',
160
+ '-vaapi_device', renderDevice,
161
+ '-f', 'lavfi',
162
+ '-i', 'color=c=black:s=64x64:r=1',
163
+ '-vf', 'format=nv12,hwupload',
164
+ '-frames:v', '1',
165
+ '-an',
166
+ '-c:v', 'h264_vaapi',
167
+ '-f', 'h264',
168
+ 'pipe:1'
169
+ ], {
170
+ env: probeEnv,
171
+ encoding: null,
172
+ timeout: options.timeoutMs || PROBE_TIMEOUT_MS,
173
+ maxBuffer: 2 * 1024 * 1024,
174
+ windowsHide: true
175
+ });
176
+ const outputBytes = Buffer.isBuffer(result.stdout) ? result.stdout.length : 0;
177
+ if (result.status === 0 && outputBytes > 0) {
178
+ return {
179
+ ok: true,
180
+ encoder: 'h264_vaapi',
181
+ driver: driver || 'auto',
182
+ outputBytes
183
+ };
184
+ }
185
+ lastError = cleanText(
186
+ result.error?.message
187
+ || (Buffer.isBuffer(result.stderr) ? result.stderr.toString('utf8') : result.stderr)
188
+ || `ffmpeg exited with ${result.status ?? 'no status'}`
189
+ );
190
+ }
191
+ return { ok: false, error: lastError || 'VAAPI hardware encoding probe failed.' };
192
+ }
193
+
194
+ function baseStatus(platform) {
195
+ return {
196
+ platform,
197
+ supported: platform === 'linux',
198
+ state: platform === 'linux' ? 'checking' : 'not-applicable',
199
+ ready: false,
200
+ busy: false,
201
+ canInstall: false,
202
+ encoder: '',
203
+ driver: '',
204
+ ffmpegPath: '',
205
+ renderDevice: '',
206
+ gpuVendor: '',
207
+ packageManager: null,
208
+ manualCommand: '',
209
+ message: '',
210
+ error: '',
211
+ checkedAt: new Date().toISOString()
212
+ };
213
+ }
214
+
215
+ export function inspectLinuxVideoAcceleration(options = {}) {
216
+ const platform = options.platform || process.platform;
217
+ const status = baseStatus(platform);
218
+ if (platform !== 'linux') return status;
219
+
220
+ const renderDevices = findLinuxRenderDevices(options);
221
+ const renderDevice = renderDevices[0] || '';
222
+ const packageManager = options.packageManager === undefined
223
+ ? detectLinuxPackageManager(options)
224
+ : options.packageManager;
225
+ const gpuVendor = renderDevice ? readGpuVendor(renderDevice, options) : '';
226
+ Object.assign(status, { renderDevice, gpuVendor, packageManager });
227
+ const installPlan = buildLinuxVideoInstallPlan(status, { packageManager });
228
+ status.canInstall = Boolean(installPlan);
229
+ status.manualCommand = installPlan?.displayCommand || '';
230
+
231
+ if (!renderDevice) {
232
+ status.state = 'unavailable';
233
+ status.message = 'No Linux hardware video device was found.';
234
+ return status;
235
+ }
236
+
237
+ const ffmpegPath = options.ffmpegPath === undefined
238
+ ? resolveSystemFfmpeg(options)
239
+ : String(options.ffmpegPath || '');
240
+ status.ffmpegPath = ffmpegPath;
241
+ if (!ffmpegPath) {
242
+ status.state = 'setup-required';
243
+ status.message = 'Hardware video setup is available.';
244
+ return status;
245
+ }
246
+
247
+ const probe = (options.probeImpl || probeLinuxVideoAcceleration)(ffmpegPath, renderDevice, options);
248
+ if (!probe?.ok) {
249
+ status.state = 'setup-required';
250
+ status.message = 'Hardware video needs setup.';
251
+ status.error = cleanText(probe?.error || 'VAAPI hardware encoding probe failed.');
252
+ return status;
253
+ }
254
+
255
+ status.state = 'ready';
256
+ status.ready = true;
257
+ status.canInstall = false;
258
+ status.encoder = cleanText(probe.encoder || 'h264_vaapi', 80);
259
+ status.driver = cleanText(probe.driver || 'auto', 80);
260
+ status.message = 'VAAPI hardware video is ready.';
261
+ status.error = '';
262
+ return status;
263
+ }
264
+
265
+ function runChild(command, args, options = {}) {
266
+ const launch = options.spawnImpl || spawn;
267
+ return new Promise((resolvePromise, reject) => {
268
+ let child;
269
+ try {
270
+ child = launch(command, args, {
271
+ env: options.env || process.env,
272
+ stdio: 'inherit',
273
+ windowsHide: true
274
+ });
275
+ } catch (error) {
276
+ reject(error);
277
+ return;
278
+ }
279
+ child.once('error', reject);
280
+ child.once('exit', (code, signal) => resolvePromise({ code: code ?? 1, signal: signal || '' }));
281
+ });
282
+ }
283
+
284
+ export async function installLinuxVideoAcceleration(currentStatus, options = {}) {
285
+ if ((options.platform || process.platform) !== 'linux') {
286
+ return { ...baseStatus(options.platform || process.platform), state: 'not-applicable', error: 'linux-required' };
287
+ }
288
+ const plan = buildLinuxVideoInstallPlan(currentStatus, options);
289
+ if (!plan) {
290
+ return {
291
+ ...currentStatus,
292
+ state: 'error',
293
+ busy: false,
294
+ ready: false,
295
+ error: 'No supported Linux package manager was found.'
296
+ };
297
+ }
298
+
299
+ const env = options.env || process.env;
300
+ const find = name => findExecutable(name, { ...options, env, platform: 'linux' });
301
+ const isRoot = options.isRoot ?? (typeof process.getuid === 'function' && process.getuid() === 0);
302
+ const pkexec = find('pkexec');
303
+ const sudo = find('sudo');
304
+ const graphicalSession = Boolean(env.DISPLAY || env.WAYLAND_DISPLAY);
305
+ let command = '';
306
+ let args = [];
307
+ let elevation = '';
308
+ if (isRoot) {
309
+ command = plan.manager.path;
310
+ args = plan.args;
311
+ elevation = 'root';
312
+ } else if (pkexec && graphicalSession) {
313
+ command = pkexec;
314
+ args = [plan.manager.path, ...plan.args];
315
+ elevation = 'pkexec';
316
+ } else if (sudo && (options.stdinIsTTY ?? process.stdin.isTTY)) {
317
+ command = sudo;
318
+ args = [plan.manager.path, ...plan.args];
319
+ elevation = 'sudo';
320
+ } else {
321
+ return {
322
+ ...currentStatus,
323
+ state: 'error',
324
+ busy: false,
325
+ ready: false,
326
+ manualCommand: plan.displayCommand,
327
+ error: 'Administrator approval is unavailable in this session.'
328
+ };
329
+ }
330
+
331
+ options.onProgress?.({
332
+ ...currentStatus,
333
+ state: 'installing',
334
+ busy: true,
335
+ ready: false,
336
+ error: '',
337
+ message: 'Installing Linux hardware video support…',
338
+ elevation
339
+ });
340
+
341
+ let result;
342
+ try {
343
+ result = options.runInstall
344
+ ? await options.runInstall({ command, args, plan, elevation })
345
+ : await runChild(command, args, { ...options, env });
346
+ } catch (error) {
347
+ return {
348
+ ...currentStatus,
349
+ state: 'error',
350
+ busy: false,
351
+ ready: false,
352
+ manualCommand: plan.displayCommand,
353
+ error: cleanText(error?.message || error)
354
+ };
355
+ }
356
+ if (result?.code !== 0) {
357
+ return {
358
+ ...currentStatus,
359
+ state: 'error',
360
+ busy: false,
361
+ ready: false,
362
+ manualCommand: plan.displayCommand,
363
+ error: `Package installation stopped with exit code ${result?.code ?? 'unknown'}.`
364
+ };
365
+ }
366
+
367
+ const refreshed = (options.inspectImpl || inspectLinuxVideoAcceleration)({
368
+ ...options,
369
+ platform: 'linux',
370
+ packageManager: plan.manager,
371
+ ffmpegPath: undefined
372
+ });
373
+ if (!refreshed.ready) {
374
+ return {
375
+ ...refreshed,
376
+ state: 'error',
377
+ busy: false,
378
+ ready: false,
379
+ canInstall: true,
380
+ manualCommand: plan.displayCommand,
381
+ error: refreshed.error || 'Hardware video was installed but the VAAPI test did not pass.'
382
+ };
383
+ }
384
+ return {
385
+ ...refreshed,
386
+ busy: false,
387
+ restartAgent: true,
388
+ message: 'VAAPI hardware video is ready. Restarting the LiveDesk Agent…'
389
+ };
390
+ }