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 +26 -8
- package/bootstrap/hub-auth-handoff.js +97 -0
- package/client/bin/livedesk-client.js +112 -32
- package/client/src/runtime/client-runtime-server.js +1 -0
- package/hub/src/server.js +18 -2
- package/package.json +1 -1
- package/web/dist/assets/{index-NPl8KozM.js → index-CxlGjz9d.js} +1 -1
- package/web/dist/index.html +1 -1
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
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
3213
|
+
void publishHubHostTargetWithPendingRoleTakeover('renewal');
|
|
3198
3214
|
}, HUB_HOST_TARGET_RENEW_MS);
|
|
3199
3215
|
hubHostTargetRenewTimer.unref?.();
|
|
3200
3216
|
}
|