livedesk 0.1.394 → 0.1.396

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
@@ -15,6 +15,7 @@ import { createRoleBootstrapServer } from '../bootstrap/role-bootstrap-server.js
15
15
  import { parseLsofPids } from '../bootstrap/port-inspection.js';
16
16
  import { isKnownLiveDeskClientAgentProcess } from '../bootstrap/client-process-identity.js';
17
17
  import { runLegacyClientUpdateSupervisorCli } from '../bootstrap/legacy-client-update.js';
18
+ import { transferSavedClientSessionToHub } from '../bootstrap/hub-auth-handoff.js';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -1514,14 +1515,31 @@ async function runManager(args, resolvedRole = null) {
1514
1515
  });
1515
1516
  };
1516
1517
 
1517
- startHubProcess();
1518
- updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
1519
-
1520
- if (options.openBrowserOnStart) {
1521
- void waitForManager(openUrl).then(ok => {
1522
- if (ok) {
1523
- openBrowser(openUrl);
1524
- } else {
1518
+ startHubProcess();
1519
+ updatePollId = setInterval(pollUpdateRequest, HUB_UPDATE_POLL_MS);
1520
+
1521
+ const hubReady = waitForManager(hubProbeBaseUrl);
1522
+ const hubStartup = hubReady.then(async ok => {
1523
+ if (!ok) {
1524
+ return false;
1525
+ }
1526
+ const handoff = await transferSavedClientSessionToHub(hubProbeBaseUrl, MANAGER_STATE_DIR);
1527
+ if (handoff.ok) {
1528
+ reportLauncherUpdate('[LiveDesk Hub] Saved Client sign-in adopted by the Hub.');
1529
+ if (!handoff.hostTargetOk) {
1530
+ reportLauncherUpdate('[LiveDesk Hub] Sign-in was adopted; host publication will continue through lease renewal.', true);
1531
+ }
1532
+ } else if (!handoff.skipped) {
1533
+ reportLauncherUpdate(`[LiveDesk Hub] Saved Client sign-in could not be adopted: ${handoff.reason}. Continue sign-in in the Hub page.`, true);
1534
+ }
1535
+ return true;
1536
+ });
1537
+
1538
+ if (options.openBrowserOnStart) {
1539
+ void hubStartup.then(ok => {
1540
+ if (ok) {
1541
+ openBrowser(openUrl);
1542
+ } else {
1525
1543
  console.warn(`LiveDesk Hub did not answer at ${openUrl} yet. Open it manually when it is ready.`);
1526
1544
  }
1527
1545
  });
@@ -0,0 +1,97 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { normalizeRuntimeAuthSession } from '../runtime-core/src/auth-session.js';
4
+
5
+ const CLIENT_AUTH_STORAGE_KEY = 'livedesk.client.supabase.auth';
6
+ const MINIMUM_SESSION_LIFETIME_SECONDS = 30;
7
+
8
+ function parseStoredSession(value) {
9
+ if (value && typeof value === 'object') {
10
+ return value;
11
+ }
12
+ if (typeof value !== 'string' || !value.trim()) {
13
+ return null;
14
+ }
15
+ try {
16
+ return JSON.parse(value);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Contract:
24
+ * - Reads only the unified local Client auth store.
25
+ * - Returns only a refreshable access session that will remain valid long
26
+ * enough for the new local Hub to verify it.
27
+ * - Never logs or serializes token values outside the loopback handoff.
28
+ */
29
+ export async function readSavedClientSessionForHub(
30
+ stateDir,
31
+ nowSeconds = Math.floor(Date.now() / 1000)
32
+ ) {
33
+ try {
34
+ const authState = JSON.parse(await readFile(join(stateDir, 'auth.json'), 'utf8'));
35
+ const candidate = parseStoredSession(authState?.[CLIENT_AUTH_STORAGE_KEY]);
36
+ const normalized = normalizeRuntimeAuthSession(candidate, { requireRefreshToken: true });
37
+ if (!normalized.ok) {
38
+ return null;
39
+ }
40
+ const expiresAt = Number(normalized.session.expires_at || 0);
41
+ if (!Number.isFinite(expiresAt)
42
+ || expiresAt - Number(nowSeconds || 0) <= MINIMUM_SESSION_LIFETIME_SECONDS) {
43
+ return null;
44
+ }
45
+ return normalized.session;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function transferSavedClientSessionToHub(
52
+ baseUrl,
53
+ stateDir,
54
+ fetchImpl = fetch,
55
+ nowSeconds = Math.floor(Date.now() / 1000)
56
+ ) {
57
+ const session = await readSavedClientSessionForHub(stateDir, nowSeconds);
58
+ if (!session) {
59
+ return { ok: false, skipped: true, reason: 'no-valid-saved-client-session' };
60
+ }
61
+
62
+ try {
63
+ const response = await fetchImpl(new URL('/api/auth/session', baseUrl), {
64
+ method: 'POST',
65
+ headers: {
66
+ 'Content-Type': 'application/json',
67
+ Accept: 'application/json'
68
+ },
69
+ body: JSON.stringify({
70
+ accessToken: session.access_token,
71
+ refreshToken: session.refresh_token,
72
+ expiresAt: session.expires_at
73
+ }),
74
+ signal: AbortSignal.timeout(5_000)
75
+ });
76
+ const data = await response.json().catch(() => null);
77
+ if (!response.ok || data?.authenticated !== true) {
78
+ return {
79
+ ok: false,
80
+ skipped: false,
81
+ reason: String(data?.error || `hub-auth-session-http-${response.status}`)
82
+ };
83
+ }
84
+ return {
85
+ ok: true,
86
+ skipped: false,
87
+ authenticated: true,
88
+ hostTargetOk: data?.hostTarget?.ok !== false
89
+ };
90
+ } catch (error) {
91
+ return {
92
+ ok: false,
93
+ skipped: false,
94
+ reason: error instanceof Error ? error.message : String(error)
95
+ };
96
+ }
97
+ }
@@ -21,6 +21,7 @@ const packageRoot = resolve(__dirname, '..');
21
21
  const nodeAgentPath = join(__dirname, 'livedesk-client-node.js');
22
22
  const FAST_PREFLIGHT_TIMEOUT_MS = 5000;
23
23
  const DEFAULT_MANAGER = '127.0.0.1:5197';
24
+ const DEFAULT_HUB_CLIENT_PORT = 5197;
24
25
  const DEFAULT_AUTH_CALLBACK_HOST = '127.0.0.1';
25
26
  const DEFAULT_AUTH_CALLBACK_PORT = 5179;
26
27
  const ENDPOINT_PROBE_TIMEOUT_MS = 900;
@@ -112,30 +113,50 @@ function writeLocalRoleCache(role, deviceId, roleVersion = 0, assignedHubId = ''
112
113
  return true;
113
114
  }
114
115
 
115
- function spawnUnifiedRoleRestart(role) {
116
- if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) {
117
- console.log(`[LiveDesk] Desktop supervisor will restart the runtime as ${role}.`);
118
- return false;
119
- }
120
- const unifiedEntry = String(process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY || '').trim();
121
- const child = unifiedEntry && existsSync(unifiedEntry)
122
- ? spawn(process.execPath, [unifiedEntry, '--force-role', role, '--no-open'], {
123
- env: {
124
- ...process.env,
125
- LIVEDESK_ROLE_TRANSITION: '1',
126
- LIVEDESK_RESTART_WAIT_PID: String(process.ppid),
127
- LIVEDESK_SKIP_BROWSER_OPEN: '1'
128
- },
129
- detached: true,
130
- stdio: 'ignore',
131
- windowsHide: true
132
- })
133
- : process.platform === 'win32'
134
- ? spawn('npx.cmd', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], { detached: true, stdio: 'ignore', windowsHide: true })
135
- : spawn('npx', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], { detached: true, stdio: 'ignore' });
136
- child.unref();
137
- return true;
138
- }
116
+ export function buildUnifiedRoleRestartEnvironment(role, baseEnv = process.env, runtimePid = process.pid) {
117
+ const normalizedRole = String(role || '').trim().toLowerCase();
118
+ const waitPid = Number(runtimePid);
119
+ if (normalizedRole !== 'hub' && normalizedRole !== 'client') {
120
+ throw new Error(`Unsupported LiveDesk role restart: ${role}`);
121
+ }
122
+ if (!Number.isInteger(waitPid) || waitPid <= 1) {
123
+ throw new Error(`Invalid LiveDesk runtime PID for role restart: ${runtimePid}`);
124
+ }
125
+ return {
126
+ ...baseEnv,
127
+ LIVEDESK_ROLE_TRANSITION: '1',
128
+ // The replacement must wait only for this Client runtime. Waiting for
129
+ // npm, npx, PowerShell, or another terminal parent can deadlock the
130
+ // takeover even after Supabase has selected this device as Hub.
131
+ LIVEDESK_RESTART_WAIT_PID: String(waitPid),
132
+ LIVEDESK_ROLE_TAKEOVER: normalizedRole === 'hub' ? '1' : '',
133
+ LIVEDESK_SKIP_BROWSER_OPEN: '1'
134
+ };
135
+ }
136
+
137
+ function spawnUnifiedRoleRestart(role) {
138
+ if (isTruthy(process.env.LIVEDESK_DESKTOP_HOST)) {
139
+ console.log(`[LiveDesk] Desktop supervisor will restart the runtime as ${role}.`);
140
+ return false;
141
+ }
142
+ const unifiedEntry = String(process.env.LIVEDESK_UNIFIED_LAUNCHER_ENTRY || '').trim();
143
+ const restartEnvironment = buildUnifiedRoleRestartEnvironment(role);
144
+ const spawnOptions = {
145
+ env: restartEnvironment,
146
+ detached: true,
147
+ stdio: 'ignore',
148
+ windowsHide: true
149
+ };
150
+ const child = unifiedEntry && existsSync(unifiedEntry)
151
+ ? spawn(process.execPath, [unifiedEntry, '--force-role', role, '--no-open'], {
152
+ ...spawnOptions
153
+ })
154
+ : process.platform === 'win32'
155
+ ? spawn('npx.cmd', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions)
156
+ : spawn('npx', ['-y', '--prefer-online', 'livedesk@latest', '--force-role', role, '--no-open'], spawnOptions);
157
+ child.unref();
158
+ return true;
159
+ }
139
160
 
140
161
  function printHelp() {
141
162
  process.stdout.write(`
@@ -349,15 +370,64 @@ function parseLauncherArgs(argv) {
349
370
  };
350
371
  }
351
372
 
352
- function normalizePort(value) {
373
+ function normalizePort(value) {
353
374
  const port = Number(String(value || '').trim());
354
375
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
355
376
  return 0;
356
377
  }
357
378
  return port;
358
- }
359
-
360
- function normalizeSlotNumber(value) {
379
+ }
380
+
381
+ export function resolveHubClientPort(env = process.env) {
382
+ return normalizePort(env.REMOTE_HUB_PORT || env.LIVEDESK_HUB_REMOTE_PORT) || DEFAULT_HUB_CLIENT_PORT;
383
+ }
384
+
385
+ export function preflightHubClientPort(port = resolveHubClientPort()) {
386
+ const normalizedPort = normalizePort(port);
387
+ if (!normalizedPort) {
388
+ return Promise.resolve({ ok: false, port: Number(port) || 0, code: 'invalid-hub-client-port' });
389
+ }
390
+ return new Promise(resolvePreflight => {
391
+ const probe = net.createServer();
392
+ let completed = false;
393
+ const finish = result => {
394
+ if (completed) return;
395
+ completed = true;
396
+ resolvePreflight({ port: normalizedPort, ...result });
397
+ };
398
+ probe.unref?.();
399
+ probe.once('error', error => {
400
+ finish({
401
+ ok: false,
402
+ code: error?.code === 'EADDRINUSE' ? 'hub-client-port-in-use' : 'hub-client-port-unavailable',
403
+ systemCode: String(error?.code || '')
404
+ });
405
+ });
406
+ probe.listen({ host: '0.0.0.0', port: normalizedPort, exclusive: true }, () => {
407
+ probe.close(error => {
408
+ if (error) {
409
+ finish({ ok: false, code: 'hub-client-port-unavailable', systemCode: String(error?.code || '') });
410
+ return;
411
+ }
412
+ finish({ ok: true, code: 'ok' });
413
+ });
414
+ });
415
+ });
416
+ }
417
+
418
+ function hubClientPortPreflightError(preflight) {
419
+ const port = Number(preflight?.port || resolveHubClientPort());
420
+ return {
421
+ ok: false,
422
+ code: String(preflight?.code || 'hub-client-port-unavailable'),
423
+ port,
424
+ error: preflight?.code === 'hub-client-port-in-use'
425
+ ? `LiveDesk cannot switch this computer to Hub because TCP ${port} is already in use. Change or stop the owning service, then try Switch to Hub again. The current Hub was not changed.`
426
+ : `LiveDesk cannot switch this computer to Hub because TCP ${port} is unavailable. Check the local port configuration, then try again. The current Hub was not changed.`
427
+ };
428
+ }
429
+
430
+ function normalizeSlotNumber(value) {
361
431
  const number = Number(String(value || '').trim());
362
432
  if (!Number.isInteger(number) || number < 1 || number > 999) {
363
433
  return '';
@@ -2628,12 +2698,18 @@ async function startConnectionChoiceServer(supabase, options = {}) {
2628
2698
  payload = Object.fromEntries(params.entries());
2629
2699
  }
2630
2700
  const nextRole = String(payload.role || '').trim().toLowerCase();
2631
- if (nextRole !== 'hub') {
2701
+ if (nextRole !== 'hub') {
2632
2702
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
2633
2703
  res.end(JSON.stringify({ ok: false, error: 'client-can-only-transition-to-hub' }));
2634
- return;
2635
- }
2636
- const session = await refreshSessionIfNeeded(supabase);
2704
+ return;
2705
+ }
2706
+ const portPreflight = await preflightHubClientPort();
2707
+ if (!portPreflight.ok) {
2708
+ res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
2709
+ res.end(JSON.stringify(hubClientPortPreflightError(portPreflight)));
2710
+ return;
2711
+ }
2712
+ const session = await refreshSessionIfNeeded(supabase);
2637
2713
  const expectedRoleVersion = Number(dashboardState.roleVersion || 0);
2638
2714
  const { data, error } = await supabase.rpc('set_livedesk_device_role', {
2639
2715
  p_device_id: deviceId,
@@ -2928,6 +3004,10 @@ async function chooseClientConnection(supabase, options = {}) {
2928
3004
  if (!supabase) {
2929
3005
  return { ok: false, error: 'supabase-session-required' };
2930
3006
  }
3007
+ const portPreflight = await preflightHubClientPort();
3008
+ if (!portPreflight.ok) {
3009
+ return hubClientPortPreflightError(portPreflight);
3010
+ }
2931
3011
  const session = await refreshSessionIfNeeded(supabase);
2932
3012
  if (!session?.access_token) {
2933
3013
  return { ok: false, error: 'supabase-session-required' };
@@ -739,6 +739,7 @@ export function createClientRuntimeServer(options = {}) {
739
739
  osVersion: os.release(),
740
740
  pid: process.pid,
741
741
  cpuUsagePercent: Math.round(cpuUsagePercent * 10) / 10,
742
+ cpuLogicalCores: Math.max(1, os.cpus().length),
742
743
  memoryTotalBytes: totalMemoryBytes,
743
744
  memoryUsedBytes: Math.max(0, totalMemoryBytes - freeMemoryBytes),
744
745
  uptimeSeconds: Math.max(0, Math.floor(os.uptime())),
package/hub/src/server.js CHANGED
@@ -136,6 +136,7 @@ const hubWakeBaseUrl = String(process.env.LIVEDESK_WAKE_URL || 'https://livedesk
136
136
  const HUB_WAKE_NOTIFY_RETRY_MS = 60_000;
137
137
  let hubHostTargetRenewTimer = null;
138
138
  let hubHostTargetRenewInFlight = false;
139
+ let roleTransitionTakeoverPending = process.env.LIVEDESK_ROLE_TAKEOVER === '1';
139
140
  let hubHostTargetLeaseState = {
140
141
  state: 'idle',
141
142
  lastAttemptAt: '',
@@ -2913,7 +2914,7 @@ app.post('/api/auth/session', async (req, res) => {
2913
2914
  runtimeAccessTokenExpiresAt = normalizeSessionExpiryMs(expiresAt);
2914
2915
  runtimeManager.setAuthenticated(true, user.id);
2915
2916
  const hostTarget = runtimeRole === 'hub'
2916
- ? await publishHubHostTarget({ reason: 'session-received' })
2917
+ ? await publishHubHostTargetWithPendingRoleTakeover('session-received')
2917
2918
  : { ok: true, active: false };
2918
2919
  if (runtimeRole === 'hub') {
2919
2920
  startHubHostTargetLeaseRenewal();
@@ -3147,6 +3148,21 @@ async function publishHubHostTarget({ takeover = false, reason = 'renewal' } = {
3147
3148
  }
3148
3149
  }
3149
3150
 
3151
+ async function publishHubHostTargetWithPendingRoleTakeover(reason = 'renewal') {
3152
+ const takeover = roleTransitionTakeoverPending;
3153
+ const result = await publishHubHostTarget({
3154
+ takeover,
3155
+ reason: takeover ? `${reason}-role-takeover` : reason
3156
+ });
3157
+ if (takeover && result.ok) {
3158
+ // One explicit Client -> Hub selection authorizes one ownership handoff.
3159
+ // Normal renewals must never keep takeover authority after publication.
3160
+ roleTransitionTakeoverPending = false;
3161
+ delete process.env.LIVEDESK_ROLE_TAKEOVER;
3162
+ }
3163
+ return result;
3164
+ }
3165
+
3150
3166
  async function clearHubHostTarget(reason = 'shutdown') {
3151
3167
  if (hubHostTargetRenewTimer) {
3152
3168
  clearInterval(hubHostTargetRenewTimer);
@@ -3194,7 +3210,7 @@ function startHubHostTargetLeaseRenewal() {
3194
3210
  return;
3195
3211
  }
3196
3212
  hubHostTargetRenewTimer = setInterval(() => {
3197
- void publishHubHostTarget({ reason: 'renewal' });
3213
+ void publishHubHostTargetWithPendingRoleTakeover('renewal');
3198
3214
  }, HUB_HOST_TARGET_RENEW_MS);
3199
3215
  hubHostTargetRenewTimer.unref?.();
3200
3216
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "livedesk",
3
- "version": "0.1.394",
3
+ "version": "0.1.396",
4
4
  "livedeskClientVersion": "0.1.173",
5
5
  "buildFlavor": "production",
6
6
  "description": "LiveDesk Hub and client launcher",