livedesk 0.1.288 → 0.1.290

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
@@ -1,12 +1,30 @@
1
- # LiveDesk
2
-
3
- Local screen wall Hub and remote client launcher.
4
-
5
- ## Hub
1
+ # LiveDesk
2
+
3
+ One-command LiveDesk application. The same launcher resolves this computer's
4
+ Hub or Client role, then starts only the runtime required by that role.
5
+
6
+ ## Unified launch
7
+
8
+ ```powershell
9
+ npx -y --prefer-online livedesk@latest
10
+ ```
11
+
12
+ The first interactive launch asks whether this computer is a Hub or Client.
13
+ The selected role and stable device identity are cached in
14
+ `~/.livedesk/device-role.json` and `~/.livedesk/device.json`. When a Supabase
15
+ device role is available, it takes precedence over the offline cache.
16
+
17
+ For development and migration checks, the role can be forced for one run:
18
+
19
+ ```powershell
20
+ npx -y --prefer-online livedesk@latest --force-role hub
21
+ npx -y --prefer-online livedesk@latest --force-role client
22
+ ```
23
+
24
+ ## Hub
6
25
 
7
26
  ```powershell
8
- npx -y --prefer-online livedesk@latest
9
- npx -y --prefer-online livedesk@latest hub
27
+ npx -y --prefer-online livedesk@latest hub
10
28
  ```
11
29
 
12
30
  This starts the LiveDesk Hub, opens the local screen wall, and accepts clients.
@@ -23,16 +41,17 @@ Pass `--no-clean` to disable the startup cleanup explicitly. If a child Hub
23
41
  still loses a startup race with `EADDRINUSE`, the launcher performs one cleanup
24
42
  and startup retry, then exits without looping.
25
43
 
26
- ## Client
44
+ ## Client compatibility
27
45
 
28
46
  ```powershell
29
- npx -y --prefer-online livedesk@latest client
30
- npx -y --prefer-online livedesk@latest client 3
31
- ```
32
-
33
- The client signs in with Google, discovers the active Hub, and connects to the wall.
34
- On Windows, enable **Start with Windows** on the connection page to reconnect
35
- automatically after reboot.
47
+ npx -y --prefer-online livedesk@latest client 3
48
+ ```
49
+
50
+ The `client` form remains as a legacy compatibility alias. It records the
51
+ Client role and uses the same unified runtime lock. The client signs in with
52
+ Google, discovers the active Hub, and connects to the wall. On Windows, enable
53
+ **Start with Windows** on the connection page to reconnect automatically after
54
+ reboot; the generated startup command uses the unified launcher.
36
55
 
37
56
  ## Frame modes
38
57
 
package/bin/livedesk.js CHANGED
@@ -4,10 +4,14 @@ import { createRequire } from 'node:module';
4
4
  import net from 'node:net';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { execFile, spawn } from 'node:child_process';
7
+ import { execFile, spawn } from 'node:child_process';
8
8
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
9
- import { randomBytes } from 'node:crypto';
10
- import os from 'node:os';
9
+ import { randomBytes } from 'node:crypto';
10
+ import os from 'node:os';
11
+ import { createInterface } from 'node:readline/promises';
12
+ import { stdin as input, stdout as output } from 'node:process';
13
+ import { resolveDeviceRole, writeRoleCache } from '../bootstrap/device-role.js';
14
+ import { acquireRuntimeLock } from '../bootstrap/runtime-lock.js';
11
15
 
12
16
  const require = createRequire(import.meta.url);
13
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -22,21 +26,21 @@ const HUB_UPDATE_POLL_MS = 250;
22
26
  const MANAGER_STATE_DIR = join(os.homedir(), '.livedesk');
23
27
  const MANAGER_STATE_PATH = join(MANAGER_STATE_DIR, 'manager.json');
24
28
 
25
- function printHelp() {
26
- process.stdout.write(`
27
- LiveDesk
28
-
29
- Usage:
30
- npx -y --prefer-online livedesk@latest
31
- npx -y --prefer-online livedesk@latest hub
32
- npx -y --prefer-online livedesk@latest client
33
- npx -y --prefer-online livedesk@latest client 3
34
-
35
- Commands:
36
- hub Start the local LiveDesk Hub. Default.
37
- client Connect this workstation to the Hub.
38
-
39
- Hub options:
29
+ function printHelp() {
30
+ process.stdout.write(`
31
+ LiveDesk
32
+
33
+ Usage:
34
+ npx -y --prefer-online livedesk@latest
35
+ npx -y --prefer-online livedesk@latest --force-role hub
36
+ npx -y --prefer-online livedesk@latest --force-role client
37
+
38
+ Commands:
39
+ No command Resolve the signed-in device role and start the matching runtime.
40
+ hub Legacy alias for --force-role hub.
41
+ client Legacy alias for --force-role client.
42
+
43
+ Hub options:
40
44
  --no-open Do not open the browser automatically.
41
45
  --url <url> Browser URL to open. Default: ${DEFAULT_MANAGER_URL}
42
46
  --host <host> Hub HTTP host. Default: 127.0.0.1
@@ -45,11 +49,15 @@ Hub options:
45
49
  Client connection port. Default: 5197.
46
50
  --no-clean Do not stop existing processes on the Hub ports.
47
51
 
48
- Common:
49
- --help Show this help.
50
- --version Show version.
51
- `.trimStart());
52
- }
52
+ Common:
53
+ --force-role <hub|client>
54
+ Force a runtime role for development or migration checks.
55
+ --no-role-prompt
56
+ Do not ask for a role on first run; fail if no role is known.
57
+ --help Show this help.
58
+ --version Show version.
59
+ `.trimStart());
60
+ }
53
61
 
54
62
  function printClientHelp() {
55
63
  process.stdout.write(`
@@ -91,6 +99,48 @@ function readClientVersion() {
91
99
  return '';
92
100
  }
93
101
  }
102
+
103
+ function parseTopLevelArgs(args) {
104
+ const forwarded = [];
105
+ let forcedRole = '';
106
+ let noRolePrompt = false;
107
+ let noSingleInstance = false;
108
+ for (let index = 0; index < args.length; index += 1) {
109
+ const arg = args[index];
110
+ if (arg === '--force-role' || arg === '--role') {
111
+ forcedRole = String(args[index + 1] || '').trim().toLowerCase();
112
+ index += 1;
113
+ continue;
114
+ }
115
+ if (arg === '--no-role-prompt') {
116
+ noRolePrompt = true;
117
+ continue;
118
+ }
119
+ if (arg === '--no-single-instance') {
120
+ noSingleInstance = true;
121
+ continue;
122
+ }
123
+ forwarded.push(arg);
124
+ }
125
+ return { forwarded, forcedRole, noRolePrompt, noSingleInstance };
126
+ }
127
+
128
+ async function promptForDeviceRole(identity) {
129
+ if (!input.isTTY || !output.isTTY) {
130
+ return '';
131
+ }
132
+ const readline = createInterface({ input, output });
133
+ try {
134
+ console.log(`\nLiveDesk first run on ${identity.deviceName || os.hostname()}.`);
135
+ console.log('Choose how this computer will be used:');
136
+ console.log(' 1. Hub Manage and control other computers');
137
+ console.log(' 2. Client Share this computer with an existing Hub');
138
+ const answer = String(await readline.question('Select 1 or 2: ')).trim().toLowerCase();
139
+ return answer === '1' || answer === 'hub' ? 'hub' : answer === '2' || answer === 'client' ? 'client' : '';
140
+ } finally {
141
+ readline.close();
142
+ }
143
+ }
94
144
 
95
145
  function parseManagerArgs(args) {
96
146
  const forwarded = [];
@@ -532,7 +582,7 @@ function buildHubRestartBootstrapScript() {
532
582
  ].join('\n');
533
583
  }
534
584
 
535
- async function runManager(args) {
585
+ async function runManager(args, resolvedRole = null) {
536
586
  const options = parseManagerArgs(args);
537
587
  const httpPort = normalizePort(
538
588
  options.port || process.env.LIVEDESK_HUB_HTTP_PORT || process.env.PORT,
@@ -571,7 +621,12 @@ async function runManager(args) {
571
621
  LIVEDESK_HUB_UPDATE_REQUEST_PATH: updateRequestPath,
572
622
  REMOTE_HUB_PORT: String(remotePort),
573
623
  REMOTE_HUB_PAIR_TOKEN: pairToken,
574
- LIVEDESK_WEB_DIST: existsSync(resolve(packagedWebDist, 'index.html')) ? packagedWebDist : (process.env.LIVEDESK_WEB_DIST || '')
624
+ LIVEDESK_WEB_DIST: existsSync(resolve(packagedWebDist, 'index.html')) ? packagedWebDist : (process.env.LIVEDESK_WEB_DIST || ''),
625
+ LIVEDESK_RUNTIME_ROLE: 'hub',
626
+ LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
627
+ LIVEDESK_DEVICE_NAME: String(resolvedRole?.deviceName || process.env.LIVEDESK_DEVICE_NAME || os.hostname()),
628
+ LIVEDESK_ROLE_CACHE_PATH: join(MANAGER_STATE_DIR, 'device-role.json'),
629
+ LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
575
630
  };
576
631
 
577
632
  let startupRetryCount = 0;
@@ -581,7 +636,7 @@ async function runManager(args) {
581
636
 
582
637
  const restartWithLatest = () => {
583
638
  const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx';
584
- const nextArgs = ['-y', '--prefer-online', 'livedesk@latest', 'hub', ...args];
639
+ const nextArgs = ['-y', '--prefer-online', 'livedesk@latest', '--force-role', 'hub', ...args];
585
640
  const command = process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : npxCommand;
586
641
  const commandArgs = process.platform === 'win32'
587
642
  ? ['/d', '/s', '/c', `call ${quoteWindowsCommandArg(npxCommand)} ${nextArgs.map(quoteWindowsCommandArg).join(' ')}`]
@@ -697,14 +752,21 @@ async function runManager(args) {
697
752
  }
698
753
  }
699
754
 
700
- function runClient(args) {
701
- const clientEntry = require.resolve('@livedesk/client/bin/livedesk-client.js');
702
- const child = spawn(process.execPath, [clientEntry, ...args], {
703
- env: {
704
- ...process.env,
705
- LIVEDESK_NPM_LAUNCHER_NAME: 'livedesk',
706
- LIVEDESK_NPM_LAUNCHER_VERSION: readVersion()
707
- },
755
+ function runClient(args, resolvedRole = null) {
756
+ const clientEntry = require.resolve('@livedesk/client/bin/livedesk-client.js');
757
+ const hasDeviceId = args.some(arg => arg === '--device-id' || arg.startsWith('--device-id='));
758
+ const clientArgs = hasDeviceId || !resolvedRole?.deviceId
759
+ ? args
760
+ : [...args, '--device-id', resolvedRole.deviceId];
761
+ const child = spawn(process.execPath, [clientEntry, ...clientArgs], {
762
+ env: {
763
+ ...process.env,
764
+ LIVEDESK_NPM_LAUNCHER_NAME: 'livedesk',
765
+ LIVEDESK_NPM_LAUNCHER_VERSION: readVersion(),
766
+ LIVEDESK_RUNTIME_ROLE: 'client',
767
+ LIVEDESK_DEVICE_ID: String(resolvedRole?.deviceId || process.env.LIVEDESK_DEVICE_ID || ''),
768
+ LIVEDESK_ROLE_SOURCE: String(resolvedRole?.source || process.env.LIVEDESK_ROLE_SOURCE || '')
769
+ },
708
770
  stdio: 'inherit',
709
771
  windowsHide: false
710
772
  });
@@ -721,10 +783,12 @@ function runClient(args) {
721
783
  });
722
784
  }
723
785
 
724
- async function main() {
725
- const args = process.argv.slice(2);
726
- const command = String(args[0] || 'hub').toLowerCase();
727
- if (command === '--help' || command === '-h' || command === 'help') {
786
+ async function main() {
787
+ const rawArgs = process.argv.slice(2);
788
+ const topLevel = parseTopLevelArgs(rawArgs);
789
+ const args = topLevel.forwarded;
790
+ const command = String(args[0] || '').toLowerCase();
791
+ if (command === '--help' || command === '-h' || command === 'help') {
728
792
  printHelp();
729
793
  return;
730
794
  }
@@ -732,24 +796,70 @@ async function main() {
732
796
  console.log(readVersion());
733
797
  return;
734
798
  }
735
- if (command === 'client') {
736
- if (args.slice(1).some(arg => arg === '--help' || arg === '-h' || arg === 'help')) {
737
- printClientHelp();
738
- return;
739
- }
740
- runClient(args.slice(1));
741
- return;
742
- }
743
- if (command === 'hub' || command === 'manager') {
799
+ const explicitRoleCommand = command === 'hub' || command === 'manager' || command === 'client';
800
+ let resolvedRole;
801
+ let runtimeArgs = args;
802
+ if (explicitRoleCommand) {
803
+ const role = command === 'client' ? 'client' : 'hub';
804
+ resolvedRole = await resolveDeviceRole({ forcedRole: role });
805
+ writeRoleCache({ ...resolvedRole, source: 'local-cache', isOfflineFallback: false });
806
+ runtimeArgs = args.slice(1);
807
+ if (command === 'client') {
808
+ console.warn('[LiveDesk] Legacy client command detected. Use `npx -y --prefer-online livedesk@latest` for automatic role selection.');
809
+ }
810
+ } else {
811
+ resolvedRole = await resolveDeviceRole({
812
+ forcedRole: topLevel.forcedRole || process.env.LIVEDESK_FORCE_ROLE,
813
+ promptRole: topLevel.noRolePrompt ? undefined : promptForDeviceRole
814
+ });
815
+ if (!resolvedRole.role) {
816
+ if (resolvedRole.disabled) {
817
+ throw new Error(`LiveDesk device ${resolvedRole.deviceId} is disabled for this account.`);
818
+ }
819
+ throw new Error('LiveDesk device role is not assigned. Run once interactively to choose Hub or Client, or use --force-role hub|client.');
820
+ }
821
+ }
822
+
823
+ if (topLevel.noSingleInstance || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1') {
824
+ console.warn('[LiveDesk] Single-instance lock disabled for this run.');
825
+ }
826
+ const lock = topLevel.noSingleInstance || process.env.LIVEDESK_DISABLE_SINGLE_INSTANCE === '1'
827
+ ? { acquired: true, release: () => undefined }
828
+ : acquireRuntimeLock({
829
+ role: resolvedRole.role,
830
+ openExisting: existing => {
831
+ const port = existing.role === 'client' ? 5198 : DEFAULT_MANAGER_HTTP_PORT;
832
+ openBrowser(`http://127.0.0.1:${port}`);
833
+ console.log(`[LiveDesk] An existing ${existing.role || 'runtime'} is already running (pid ${existing.pid}). Opened its local page.`);
834
+ }
835
+ });
836
+ if (!lock.acquired) {
837
+ return;
838
+ }
839
+ process.once('exit', lock.release);
840
+
841
+ if (command === 'client') {
842
+ if (args.slice(1).some(arg => arg === '--help' || arg === '-h' || arg === 'help')) {
843
+ printClientHelp();
844
+ return;
845
+ }
846
+ runClient(runtimeArgs, resolvedRole);
847
+ return;
848
+ }
849
+ if (command === 'hub' || command === 'manager') {
744
850
  if (args.slice(1).some(arg => arg === '--help' || arg === '-h' || arg === 'help')) {
745
851
  printHelp();
746
852
  return;
747
853
  }
748
- await runManager(args.slice(1));
749
- return;
750
- }
751
- await runManager(args);
752
- }
854
+ await runManager(runtimeArgs, resolvedRole);
855
+ return;
856
+ }
857
+ if (resolvedRole.role === 'client') {
858
+ runClient(runtimeArgs, resolvedRole);
859
+ return;
860
+ }
861
+ await runManager(runtimeArgs, resolvedRole);
862
+ }
753
863
 
754
864
  main().catch(error => {
755
865
  console.error(error?.message || error);
@@ -0,0 +1,265 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ export const DEVICE_ROLES = Object.freeze(['hub', 'client']);
7
+ export const DEFAULT_SUPABASE_URL = 'https://otbyfkjxrkngvjziawki.supabase.co';
8
+ export const DEFAULT_SUPABASE_PUBLISHABLE_KEY = 'sb_publishable_NpUs0RDJH2YnllsqTKO6TQ_1jTdSsNQ';
9
+
10
+ function safeString(value, maxLength = 256) {
11
+ return String(value ?? '').replace(/[\r\n\t]/g, ' ').trim().slice(0, maxLength);
12
+ }
13
+
14
+ function normalizeRole(value) {
15
+ const role = safeString(value, 20).toLowerCase();
16
+ return DEVICE_ROLES.includes(role) ? role : '';
17
+ }
18
+
19
+ function readJson(path) {
20
+ try {
21
+ return JSON.parse(readFileSync(path, 'utf8'));
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ function writeJson(path, value) {
28
+ mkdirSync(dirname(path), { recursive: true });
29
+ writeFileSync(path, JSON.stringify(value, null, 2));
30
+ }
31
+
32
+ function normalizeDeviceId(value) {
33
+ return safeString(value, 128).replace(/[^a-zA-Z0-9_.:-]/g, '-');
34
+ }
35
+
36
+ export function getRoleStatePaths(options = {}) {
37
+ const stateDir = options.stateDir || join(os.homedir(), '.livedesk');
38
+ const legacyClientStateDir = options.legacyClientStateDir || join(os.homedir(), '.livedesk-client');
39
+ return {
40
+ stateDir,
41
+ rolePath: join(stateDir, 'device-role.json'),
42
+ identityPath: join(stateDir, 'device.json'),
43
+ managerPath: join(stateDir, 'manager.json'),
44
+ legacyClientStateDir,
45
+ legacyClientIdentityPath: join(legacyClientStateDir, 'device.json'),
46
+ legacyClientAuthPath: join(legacyClientStateDir, 'auth.json'),
47
+ legacyClientPinPath: join(legacyClientStateDir, 'pin.json')
48
+ };
49
+ }
50
+
51
+ export function getStableDeviceIdentity(options = {}) {
52
+ const paths = getRoleStatePaths(options);
53
+ const existing = readJson(paths.identityPath);
54
+ const legacy = readJson(paths.legacyClientIdentityPath);
55
+ const deviceId = normalizeDeviceId(existing?.deviceId || legacy?.deviceId) || `livedesk-${randomUUID()}`;
56
+ const identity = {
57
+ deviceId,
58
+ deviceName: safeString(existing?.deviceName || legacy?.deviceName || os.hostname(), 160) || os.hostname(),
59
+ platform: process.platform,
60
+ arch: process.arch,
61
+ osVersion: safeString(os.release(), 160),
62
+ createdAt: existing?.createdAt || legacy?.createdAt || new Date().toISOString()
63
+ };
64
+
65
+ if (!existing || normalizeDeviceId(existing.deviceId) !== deviceId) {
66
+ writeJson(paths.identityPath, identity);
67
+ }
68
+ return identity;
69
+ }
70
+
71
+ export function readRoleCache(options = {}) {
72
+ const paths = getRoleStatePaths(options);
73
+ const cache = readJson(paths.rolePath);
74
+ const role = normalizeRole(cache?.role);
75
+ if (!role) {
76
+ return null;
77
+ }
78
+ return {
79
+ role,
80
+ deviceId: normalizeDeviceId(cache.deviceId),
81
+ deviceName: safeString(cache.deviceName, 160),
82
+ assignedHubId: safeString(cache.assignedHubId, 128),
83
+ roleVersion: Number.isInteger(cache.roleVersion) ? cache.roleVersion : 0,
84
+ source: cache.source === 'supabase' ? 'supabase' : 'local-cache',
85
+ verifiedAt: safeString(cache.verifiedAt, 64),
86
+ isOfflineFallback: cache.isOfflineFallback === true
87
+ };
88
+ }
89
+
90
+ export function writeRoleCache(resolved, options = {}) {
91
+ const role = normalizeRole(resolved?.role);
92
+ const deviceId = normalizeDeviceId(resolved?.deviceId);
93
+ if (!role || !deviceId) {
94
+ throw new Error('A valid device role and device id are required to write the LiveDesk role cache.');
95
+ }
96
+ const paths = getRoleStatePaths(options);
97
+ writeJson(paths.rolePath, {
98
+ deviceId,
99
+ deviceName: safeString(resolved.deviceName, 160),
100
+ role,
101
+ assignedHubId: safeString(resolved.assignedHubId, 128) || null,
102
+ roleVersion: Number.isInteger(resolved.roleVersion) ? resolved.roleVersion : 0,
103
+ source: resolved.source === 'supabase' ? 'supabase' : 'local-cache',
104
+ verifiedAt: safeString(resolved.verifiedAt, 64) || new Date().toISOString(),
105
+ isOfflineFallback: resolved.isOfflineFallback === true
106
+ });
107
+ }
108
+
109
+ function readSavedClientAccessToken(paths) {
110
+ const state = readJson(paths.legacyClientAuthPath);
111
+ const raw = state?.['livedesk.client.supabase.auth'];
112
+ if (typeof raw !== 'string' || !raw.trim()) {
113
+ return '';
114
+ }
115
+ const session = readJsonValue(raw);
116
+ return safeString(session?.access_token, 4096);
117
+ }
118
+
119
+ function readJsonValue(value) {
120
+ try {
121
+ return JSON.parse(value);
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export async function querySupabaseDeviceRole({
128
+ deviceId,
129
+ accessToken,
130
+ fetchImpl = globalThis.fetch,
131
+ supabaseUrl = process.env.LIVEDESK_SUPABASE_URL || DEFAULT_SUPABASE_URL,
132
+ publishableKey = process.env.LIVEDESK_SUPABASE_PUBLISHABLE_KEY || DEFAULT_SUPABASE_PUBLISHABLE_KEY
133
+ } = {}) {
134
+ const normalizedDeviceId = normalizeDeviceId(deviceId);
135
+ const token = safeString(accessToken, 4096);
136
+ if (!normalizedDeviceId || !token || typeof fetchImpl !== 'function') {
137
+ return { state: 'unavailable', reason: 'supabase-session-unavailable' };
138
+ }
139
+
140
+ const baseUrl = String(supabaseUrl || DEFAULT_SUPABASE_URL).replace(/\/+$/, '');
141
+ const query = new URLSearchParams({
142
+ select: 'device_id,device_name,role,assigned_hub_id,role_version,enabled',
143
+ device_id: `eq.${normalizedDeviceId}`,
144
+ limit: '1'
145
+ });
146
+ try {
147
+ const response = await fetchImpl(`${baseUrl}/rest/v1/livedesk_devices?${query.toString()}`, {
148
+ headers: {
149
+ apikey: publishableKey,
150
+ Authorization: `Bearer ${token}`,
151
+ Accept: 'application/json'
152
+ }
153
+ });
154
+ if (!response.ok) {
155
+ return { state: 'unavailable', reason: `supabase-device-query-${response.status}` };
156
+ }
157
+ const rows = await response.json();
158
+ const record = Array.isArray(rows) ? rows[0] : null;
159
+ if (!record || record.enabled === false) {
160
+ return { state: 'missing', reason: record ? 'device-disabled' : 'device-not-registered' };
161
+ }
162
+ const role = normalizeRole(record.role);
163
+ if (!role) {
164
+ return { state: 'unavailable', reason: 'invalid-server-role' };
165
+ }
166
+ return {
167
+ state: 'resolved',
168
+ role,
169
+ deviceId: normalizeDeviceId(record.device_id) || normalizedDeviceId,
170
+ deviceName: safeString(record.device_name, 160),
171
+ assignedHubId: safeString(record.assigned_hub_id, 128),
172
+ roleVersion: Number.isInteger(record.role_version) ? record.role_version : 0
173
+ };
174
+ } catch (error) {
175
+ return { state: 'unavailable', reason: safeString(error?.message || error, 240) };
176
+ }
177
+ }
178
+
179
+ export async function resolveDeviceRole({
180
+ forcedRole = '',
181
+ promptRole,
182
+ fetchImpl = globalThis.fetch,
183
+ accessToken = '',
184
+ ...pathOptions
185
+ } = {}) {
186
+ const paths = getRoleStatePaths(pathOptions);
187
+ const identity = getStableDeviceIdentity(pathOptions);
188
+ const forced = normalizeRole(forcedRole);
189
+ if (forced) {
190
+ return {
191
+ role: forced,
192
+ deviceId: identity.deviceId,
193
+ deviceName: identity.deviceName,
194
+ roleVersion: 0,
195
+ source: 'forced',
196
+ isOfflineFallback: false,
197
+ identity
198
+ };
199
+ }
200
+
201
+ const token = safeString(accessToken, 4096)
202
+ || safeString(process.env.LIVEDESK_SUPABASE_ACCESS_TOKEN, 4096)
203
+ || readSavedClientAccessToken(paths);
204
+ const remote = await querySupabaseDeviceRole({ deviceId: identity.deviceId, accessToken: token, fetchImpl });
205
+ if (remote.state === 'resolved') {
206
+ const resolved = {
207
+ ...remote,
208
+ identity,
209
+ source: 'supabase',
210
+ isOfflineFallback: false,
211
+ verifiedAt: new Date().toISOString()
212
+ };
213
+ writeRoleCache(resolved, pathOptions);
214
+ return resolved;
215
+ }
216
+
217
+ if (remote.state === 'missing' && remote.reason === 'device-disabled') {
218
+ return {
219
+ role: '',
220
+ deviceId: identity.deviceId,
221
+ deviceName: identity.deviceName,
222
+ roleVersion: 0,
223
+ source: 'supabase',
224
+ isOfflineFallback: false,
225
+ disabled: true,
226
+ identity
227
+ };
228
+ }
229
+
230
+ const cached = readRoleCache(pathOptions);
231
+ if (cached && (!cached.deviceId || cached.deviceId === identity.deviceId)) {
232
+ return { ...cached, deviceId: identity.deviceId, deviceName: cached.deviceName || identity.deviceName, identity, isOfflineFallback: true };
233
+ }
234
+
235
+ const managerState = readJson(paths.managerPath);
236
+ if (safeString(managerState?.pairToken, 128)) {
237
+ const resolved = { role: 'hub', deviceId: identity.deviceId, deviceName: identity.deviceName, roleVersion: 0, source: 'local-cache', isOfflineFallback: true, identity };
238
+ writeRoleCache(resolved, pathOptions);
239
+ return resolved;
240
+ }
241
+
242
+ if (existsSync(paths.legacyClientAuthPath) || existsSync(paths.legacyClientPinPath)) {
243
+ const resolved = { role: 'client', deviceId: identity.deviceId, deviceName: identity.deviceName, roleVersion: 0, source: 'local-cache', isOfflineFallback: true, identity };
244
+ writeRoleCache(resolved, pathOptions);
245
+ return resolved;
246
+ }
247
+
248
+ const selectedRole = normalizeRole(typeof promptRole === 'function' ? await promptRole(identity) : '');
249
+ if (selectedRole) {
250
+ const resolved = { role: selectedRole, deviceId: identity.deviceId, deviceName: identity.deviceName, roleVersion: 0, source: 'local-cache', isOfflineFallback: false, identity };
251
+ writeRoleCache(resolved, pathOptions);
252
+ return resolved;
253
+ }
254
+
255
+ return {
256
+ role: '',
257
+ deviceId: identity.deviceId,
258
+ deviceName: identity.deviceName,
259
+ roleVersion: 0,
260
+ source: 'local-cache',
261
+ isOfflineFallback: remote.state === 'unavailable',
262
+ needsSelection: true,
263
+ identity
264
+ };
265
+ }
@@ -0,0 +1,59 @@
1
+ import { existsSync, openSync, readFileSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ function readLock(path) {
6
+ try {
7
+ return JSON.parse(readFileSync(path, 'utf8'));
8
+ } catch {
9
+ return null;
10
+ }
11
+ }
12
+
13
+ function isProcessAlive(pid) {
14
+ if (!Number.isInteger(pid) || pid <= 1) {
15
+ return false;
16
+ }
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ } catch (error) {
21
+ return error?.code !== 'ESRCH';
22
+ }
23
+ }
24
+
25
+ export function getRuntimeLockPath(stateDir = join(os.homedir(), '.livedesk')) {
26
+ return join(stateDir, 'livedesk-runtime.lock');
27
+ }
28
+
29
+ export function acquireRuntimeLock({ stateDir, role = '', openExisting } = {}) {
30
+ const path = getRuntimeLockPath(stateDir);
31
+ const payload = {
32
+ pid: process.pid,
33
+ role: String(role || '').trim(),
34
+ startedAt: new Date().toISOString()
35
+ };
36
+
37
+ try {
38
+ const descriptor = openSync(path, 'wx');
39
+ writeFileSync(descriptor, JSON.stringify(payload, null, 2));
40
+ closeSync(descriptor);
41
+ let released = false;
42
+ const release = () => {
43
+ if (released) return;
44
+ released = true;
45
+ try { unlinkSync(path); } catch { /* already released */ }
46
+ };
47
+ return { acquired: true, path, payload, release };
48
+ } catch (error) {
49
+ const existing = readLock(path);
50
+ if (!existing || !isProcessAlive(Number(existing.pid))) {
51
+ try { unlinkSync(path); } catch { /* another process may repair it */ }
52
+ return acquireRuntimeLock({ stateDir, role, openExisting });
53
+ }
54
+ if (typeof openExisting === 'function') {
55
+ openExisting(existing);
56
+ }
57
+ return { acquired: false, path, existing, error };
58
+ }
59
+ }