livedesk 0.1.289 → 0.1.291
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 +34 -15
- package/bin/livedesk.js +162 -52
- package/bootstrap/device-role.js +265 -0
- package/bootstrap/runtime-lock.js +59 -0
- package/hub/src/server.js +87 -8
- package/package.json +6 -5
- package/web/dist/assets/icons-BB-TINRK.js +1 -0
- package/web/dist/assets/index-DHNb4hDm.css +1 -0
- package/web/dist/assets/index-DhntEyu-.js +16 -0
- package/web/dist/assets/{react-CgbB2ESH.js → react-DGa3YnsB.js} +1 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/icons-Dm0ih4lI.js +0 -1
- package/web/dist/assets/index-Cr8lwSwF.js +0 -16
- package/web/dist/assets/index-DKDdHcsF.css +0 -1
package/README.md
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
|
-
# LiveDesk
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
automatically after
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
hub
|
|
37
|
-
client
|
|
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
|
-
--
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
|
726
|
-
const
|
|
727
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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(
|
|
749
|
-
return;
|
|
750
|
-
}
|
|
751
|
-
|
|
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
|
+
}
|