@walkhi/code-relax 0.1.0-beta.68 → 0.1.0-beta.69

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.
Files changed (120) hide show
  1. package/dist/bin/manage-relay-admin.mjs +20 -20
  2. package/dist/bin/migrate-relay-db.mjs +109 -109
  3. package/dist/bin/publish-harmony-release.mjs +46 -46
  4. package/dist/bin/self-relay-admin.mjs +8 -8
  5. package/dist/bin/self-relay-demo.mjs +33 -33
  6. package/dist/bin/self-relay-server.mjs +66 -66
  7. package/dist/bin/set-relay-admin-password.mjs +11 -11
  8. package/dist/build-platform.json +1 -1
  9. package/dist/get-lan-networks.ps1 +40 -40
  10. package/dist/shared/app-server-events.cjs +511 -511
  11. package/dist/shared/p2p-data-channel.cjs +72 -72
  12. package/dist/shared/p2p-diagnostics.cjs +28 -28
  13. package/dist/shared/p2p-ice-policy.cjs +22 -22
  14. package/dist/shared/plan-pricing.cjs +32 -32
  15. package/dist/shared/question-attention.cjs +69 -69
  16. package/dist/shared/release-version.cjs +31 -31
  17. package/dist/src/archived-threads.mjs +22 -22
  18. package/dist/src/background-process.mjs +18 -18
  19. package/dist/src/code-browser.mjs +100 -100
  20. package/dist/src/desktop-launcher.mjs +62 -62
  21. package/dist/src/dots-task-files.mjs +52 -52
  22. package/dist/src/execution-health.mjs +26 -26
  23. package/dist/src/lan-socket-server.mjs +1 -1
  24. package/dist/src/message-images.mjs +9 -9
  25. package/dist/src/plan-pricing.mjs +47 -47
  26. package/dist/src/platform/codex-runtime.mjs +3 -3
  27. package/dist/src/platform/credential-store.mjs +19 -19
  28. package/dist/src/platform/desktop-host.mjs +3 -3
  29. package/dist/src/platform/desktop-permission-mode.mjs +17 -17
  30. package/dist/src/platform/desktop-project-registration.mjs +3 -3
  31. package/dist/src/platform/file-opener.mjs +9 -9
  32. package/dist/src/platform/linux/README.md +3 -3
  33. package/dist/src/platform/linux/codex-runtime.mjs +56 -56
  34. package/dist/src/platform/linux/desktop-installation.mjs +36 -36
  35. package/dist/src/platform/linux/desktop-project-registration.mjs +11 -11
  36. package/dist/src/platform/linux/network-info.mjs +11 -11
  37. package/dist/src/platform/linux/project-locations.mjs +17 -17
  38. package/dist/src/platform/linux/resident-startup.mjs +52 -52
  39. package/dist/src/platform/linux/retained-runtime.mjs +76 -76
  40. package/dist/src/platform/linux/service-host.mjs +18 -18
  41. package/dist/src/platform/macos/README.md +3 -3
  42. package/dist/src/platform/macos/desktop-project-registration.mjs +11 -11
  43. package/dist/src/platform/macos/network-info.mjs +8 -8
  44. package/dist/src/platform/macos/project-locations.mjs +8 -8
  45. package/dist/src/platform/macos/resident-startup.mjs +84 -84
  46. package/dist/src/platform/macos/retained-runtime.mjs +127 -127
  47. package/dist/src/platform/macos/service-host.mjs +18 -18
  48. package/dist/src/platform/macos/system.mjs +29 -29
  49. package/dist/src/platform/network-info.mjs +3 -3
  50. package/dist/src/platform/path-identity.mjs +5 -5
  51. package/dist/src/platform/posix/network-candidates.mjs +23 -23
  52. package/dist/src/platform/posix/runtime-paths.mjs +25 -25
  53. package/dist/src/platform/power-inhibitor.mjs +22 -22
  54. package/dist/src/platform/project-locations.mjs +3 -3
  55. package/dist/src/platform/runtime-paths.mjs +8 -8
  56. package/dist/src/platform/select.mjs +5 -5
  57. package/dist/src/platform/service-host.mjs +3 -3
  58. package/dist/src/platform/user-environment.mjs +3 -3
  59. package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
  60. package/dist/src/platform/windows/background-process.mjs +30 -30
  61. package/dist/src/platform/windows/desktop-helper.mjs +179 -179
  62. package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
  63. package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -7
  64. package/dist/src/platform/windows/keep-awake.ps1 +20 -20
  65. package/dist/src/platform/windows/network-info.mjs +34 -34
  66. package/dist/src/platform/windows/resident-start.js +20 -20
  67. package/dist/src/platform/windows/resident-start.ps1 +7 -7
  68. package/dist/src/platform/windows/resident-startup.ps1 +83 -83
  69. package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
  70. package/dist/src/platform/windows/runtime-paths.mjs +20 -20
  71. package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
  72. package/dist/src/platform/windows/start-hidden-console.ps1 +58 -58
  73. package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
  74. package/dist/src/platform/windows/user-environment.ps1 +41 -41
  75. package/dist/src/project-create.mjs +121 -121
  76. package/dist/src/project-management.mjs +1 -1
  77. package/dist/src/project-remove.mjs +55 -55
  78. package/dist/src/project-update.mjs +59 -59
  79. package/dist/src/relay-config-import.mjs +45 -45
  80. package/dist/src/self-relay/account-connections.mjs +52 -52
  81. package/dist/src/self-relay/admin-auth.mjs +59 -59
  82. package/dist/src/self-relay/admin-cloud.mjs +101 -101
  83. package/dist/src/self-relay/admin-login-page.mjs +7 -7
  84. package/dist/src/self-relay/admin-server.mjs +105 -105
  85. package/dist/src/self-relay/admin-state.mjs +127 -127
  86. package/dist/src/self-relay/admin-workstations-page.mjs +95 -95
  87. package/dist/src/self-relay/admin-workstations.mjs +143 -143
  88. package/dist/src/self-relay/android-releases.mjs +61 -61
  89. package/dist/src/self-relay/client.mjs +61 -61
  90. package/dist/src/self-relay/connection-reporting.mjs +38 -38
  91. package/dist/src/self-relay/connector.mjs +13 -2
  92. package/dist/src/self-relay/debug-allowlist.mjs +33 -33
  93. package/dist/src/self-relay/demo.mjs +6 -6
  94. package/dist/src/self-relay/device-info.mjs +27 -27
  95. package/dist/src/self-relay/device-store.mjs +92 -92
  96. package/dist/src/self-relay/lan-authorizations.mjs +71 -71
  97. package/dist/src/self-relay/lifecycle.mjs +278 -278
  98. package/dist/src/self-relay/long-pairing.mjs +40 -40
  99. package/dist/src/self-relay/mysql-state.mjs +118 -118
  100. package/dist/src/self-relay/phone-connections.mjs +55 -55
  101. package/dist/src/self-relay/server.mjs +14 -3
  102. package/dist/src/self-relay/usage-daily.mjs +55 -55
  103. package/dist/src/self-relay/windows-route-helper.mjs +75 -75
  104. package/dist/src/self-relay/wire.mjs +36 -36
  105. package/dist/src/server.mjs +40 -18
  106. package/dist/src/service-control.mjs +82 -82
  107. package/dist/src/service-health.mjs +33 -33
  108. package/dist/src/service-power.mjs +58 -58
  109. package/dist/src/service-restart.mjs +81 -81
  110. package/dist/src/service-update-worker.mjs +42 -42
  111. package/dist/src/service-update.mjs +80 -80
  112. package/dist/src/shared-recovery.mjs +152 -152
  113. package/dist/src/skill-install.mjs +51 -51
  114. package/dist/src/thread-title-generation.mjs +150 -150
  115. package/dist/src/turn-model-history.mjs +47 -47
  116. package/dist/tools/rtc-route-helper.ps1 +99 -99
  117. package/dist/tools/start-rtc-route-helper.ps1 +15 -15
  118. package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
  119. package/dist/web/vendor/markdown-it.umd.min.js +13 -13
  120. package/package.json +4 -4
@@ -1,40 +1,40 @@
1
- import fs from 'node:fs';
2
- import { randomUUID } from 'node:crypto';
3
- import { validId } from './device-store.mjs';
4
- import { digest, matches, secret } from './wire.mjs';
5
-
6
- // Operator-owned file, separate from device state. Only hashes are retained here.
7
- export function longPairing(file) {
8
- function read() {
9
- if (!file || !fs.existsSync(file)) return [];
10
- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
11
- if (data.version !== 1 || !Array.isArray(data.invitations) || data.invitations.some(item =>
12
- !validId(item.id) || !validId(item.room) || !/^[a-f0-9]{64}$/.test(item.hash) ||
13
- !Number.isSafeInteger(item.expires))) throw new Error('Invalid long pairing invitations');
14
- return data.invitations;
15
- }
16
- function write(invitations) {
17
- if (!file) throw new Error('Long pairing file is not configured');
18
- const temporary = `${file}.${randomUUID()}.tmp`;
19
- try {
20
- fs.writeFileSync(temporary, JSON.stringify({ version: 1, invitations }), { mode: 0o600, flag: 'wx' });
21
- fs.renameSync(temporary, file);
22
- } finally { fs.rmSync(temporary, { force: true }); }
23
- }
24
- return {
25
- list: () => read().map(({ hash, ...item }) => item),
26
- find: code => read().find(item => Date.now() < item.expires && matches(code, item.hash)),
27
- create(room, expires) {
28
- if (!validId(room) || !Number.isSafeInteger(expires) || expires <= Date.now()) throw new Error('Invalid invitation room or expiry');
29
- const invitations = read(), code = secret();
30
- const item = { id: randomUUID(), room, expires, hash: digest(code) };
31
- write([...invitations, item]);
32
- return { id: item.id, room, expires, code };
33
- },
34
- revoke(id) {
35
- const invitations = read();
36
- if (!invitations.some(item => item.id === id)) throw new Error('Invitation not found');
37
- write(invitations.filter(item => item.id !== id));
38
- },
39
- };
40
- }
1
+ import fs from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { validId } from './device-store.mjs';
4
+ import { digest, matches, secret } from './wire.mjs';
5
+
6
+ // Operator-owned file, separate from device state. Only hashes are retained here.
7
+ export function longPairing(file) {
8
+ function read() {
9
+ if (!file || !fs.existsSync(file)) return [];
10
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
11
+ if (data.version !== 1 || !Array.isArray(data.invitations) || data.invitations.some(item =>
12
+ !validId(item.id) || !validId(item.room) || !/^[a-f0-9]{64}$/.test(item.hash) ||
13
+ !Number.isSafeInteger(item.expires))) throw new Error('Invalid long pairing invitations');
14
+ return data.invitations;
15
+ }
16
+ function write(invitations) {
17
+ if (!file) throw new Error('Long pairing file is not configured');
18
+ const temporary = `${file}.${randomUUID()}.tmp`;
19
+ try {
20
+ fs.writeFileSync(temporary, JSON.stringify({ version: 1, invitations }), { mode: 0o600, flag: 'wx' });
21
+ fs.renameSync(temporary, file);
22
+ } finally { fs.rmSync(temporary, { force: true }); }
23
+ }
24
+ return {
25
+ list: () => read().map(({ hash, ...item }) => item),
26
+ find: code => read().find(item => Date.now() < item.expires && matches(code, item.hash)),
27
+ create(room, expires) {
28
+ if (!validId(room) || !Number.isSafeInteger(expires) || expires <= Date.now()) throw new Error('Invalid invitation room or expiry');
29
+ const invitations = read(), code = secret();
30
+ const item = { id: randomUUID(), room, expires, hash: digest(code) };
31
+ write([...invitations, item]);
32
+ return { id: item.id, room, expires, code };
33
+ },
34
+ revoke(id) {
35
+ const invitations = read();
36
+ if (!invitations.some(item => item.id === id)) throw new Error('Invitation not found');
37
+ write(invitations.filter(item => item.id !== id));
38
+ },
39
+ };
40
+ }
@@ -1,118 +1,118 @@
1
- import fs from 'node:fs';
2
- import { createHash } from 'node:crypto';
3
- import { fileURLToPath } from 'node:url';
4
- import mysql from 'mysql2/promise';
5
- import { deviceStore } from './device-store.mjs';
6
- import { relayAdminState } from './admin-state.mjs';
7
- import { relaySchemaVersions } from '../../database/versions.mjs';
8
- import { connectionInfo } from './device-info.mjs';
9
-
10
- const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
11
- const isoTime = value => value.replace(' ', 'T') + 'Z';
12
-
13
- export async function openRelayMysql(configFile, initialMaxRelaySessions) {
14
- const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
15
- if (!config.database || !config.user || !config.password || !(config.socketPath || config.host)) {
16
- throw new Error('Incomplete relay MySQL configuration');
17
- }
18
- const pool = mysql.createPool({ ...config, waitForConnections: true, connectionLimit: 4,
19
- namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
20
- try {
21
- const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
22
- const knownVersions = relaySchemaVersions;
23
- if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
24
- for (const row of versions) {
25
- if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
26
- const source = fs.readFileSync(fileURLToPath(new URL(`../../database/${row.version}.sql`, import.meta.url)));
27
- if (row.sha256 !== createHash('sha256').update(source).digest('hex')) throw new Error(`Unexpected relay database schema: ${row.version}`);
28
- }
29
- const [computers] = await pool.query('SELECT id, host_hash, status, revision, created_at, updated_at FROM computers');
30
- const [clients] = await pool.query('SELECT computer_id, client_id, credential_hash, created_at, updated_at, last_connected_at, platform, device_name FROM mobile_authorizations ORDER BY created_at');
31
- const [official] = await pool.query('SELECT computer_id, environment_id, name, hostname, updated_at FROM official_computers');
32
- const records = new Map(computers.map(row => [row.id, { id: row.id, hostHash: row.host_hash, status: row.status,
33
- revision: Number(row.revision), createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at), clients: [] }]));
34
- for (const row of clients) {
35
- const record = records.get(row.computer_id);
36
- if (!record) throw new Error('Orphan mobile authorization');
37
- record.clients.push({ id: row.client_id, hash: row.credential_hash,
38
- createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
39
- ...(row.last_connected_at ? { lastConnectedAt: isoTime(row.last_connected_at) } : {}),
40
- ...(row.platform ? { platform: row.platform } : {}), ...(row.device_name ? { deviceName: row.device_name } : {}) });
41
- }
42
- for (const row of official) {
43
- const record = records.get(row.computer_id);
44
- if (!record) throw new Error('Orphan official computer');
45
- record.officialComputer = { environmentId: row.environment_id, name: row.name,
46
- hostname: row.hostname, updatedAt: isoTime(row.updated_at) };
47
- }
48
- const validator = deviceStore();
49
- for (const record of records.values()) validator.save(record);
50
-
51
- const [settings] = await pool.query('SELECT max_relay_sessions FROM relay_settings WHERE id = 1');
52
- if (settings.length !== 1) throw new Error('Missing relay settings');
53
- const [rows] = await pool.query('SELECT id, title, message, status, created_at, updated_at, published_at FROM announcements ORDER BY created_at DESC');
54
- const announcements = rows.map(row => ({ id: row.id, title: row.title, message: row.message, status: row.status,
55
- createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
56
- publishedAt: row.published_at ? isoTime(row.published_at) : null }));
57
- const active = announcements.find(item => item.status === 'active');
58
- const initial = { version: 3, maxRelaySessions: settings[0].max_relay_sessions, announcements,
59
- announcement: active ? { id: active.id, title: active.title, message: active.message, publishedAt: active.publishedAt } : null };
60
- const admin = relayAdminState(undefined, initialMaxRelaySessions, { initial, persist: async next => {
61
- const connection = await pool.getConnection();
62
- try {
63
- await connection.beginTransaction();
64
- await connection.execute('UPDATE relay_settings SET max_relay_sessions = ?, updated_at = UTC_TIMESTAMP(3) WHERE id = 1', [next.maxRelaySessions]);
65
- await connection.query('DELETE FROM announcements');
66
- for (const item of next.announcements) await connection.execute(
67
- 'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
68
- [item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
69
- item.publishedAt ? sqlTime(item.publishedAt) : null]);
70
- await connection.commit();
71
- } catch (error) { await connection.rollback(); throw error; }
72
- finally { connection.release(); }
73
- } });
74
- return {
75
- pool,
76
- schemaVersions: versions.map(row => row.version),
77
- devices: {
78
- entries: () => [...records.values()],
79
- async recordConnection(id, clientId, at, info) {
80
- const record = records.get(id), client = record?.clients.find(item => item.id === clientId);
81
- if (!client) return;
82
- const updated = connectionInfo(client, at, info);
83
- await pool.execute('UPDATE mobile_authorizations SET last_connected_at=?,platform=?,device_name=? WHERE computer_id=? AND client_id=?',
84
- [sqlTime(updated.lastConnectedAt), updated.platform || null, updated.deviceName || null, id, clientId]);
85
- records.set(id, { ...record, clients: record.clients.map(item => item.id === clientId ? updated : item) });
86
- return updated;
87
- },
88
- async save(record) {
89
- validator.save(record);
90
- const connection = await pool.getConnection();
91
- try {
92
- await connection.beginTransaction();
93
- await connection.execute(`INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at)
94
- VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE host_hash=VALUES(host_hash),status=VALUES(status),
95
- revision=VALUES(revision),updated_at=VALUES(updated_at)`,
96
- [record.id, record.hostHash, record.status, record.revision, sqlTime(record.createdAt), sqlTime(record.updatedAt)]);
97
- await connection.execute('DELETE FROM mobile_authorizations WHERE computer_id = ?', [record.id]);
98
- for (const client of record.clients) await connection.execute(
99
- 'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at,last_connected_at,platform,device_name) VALUES (?,?,?,?,?,?,?,?)',
100
- [record.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt),
101
- client.lastConnectedAt ? sqlTime(client.lastConnectedAt) : null, client.platform || null, client.deviceName || null]);
102
- if (record.officialComputer) {
103
- const item = record.officialComputer;
104
- await connection.execute(`INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at)
105
- VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE environment_id=VALUES(environment_id),name=VALUES(name),
106
- hostname=VALUES(hostname),updated_at=VALUES(updated_at)`,
107
- [record.id, item.environmentId, item.name, item.hostname, sqlTime(item.updatedAt)]);
108
- }
109
- await connection.commit(); records.set(record.id, record);
110
- } catch (error) { await connection.rollback(); throw error; }
111
- finally { connection.release(); }
112
- },
113
- },
114
- admin,
115
- close: () => pool.end(),
116
- };
117
- } catch (error) { await pool.end(); throw error; }
118
- }
1
+ import fs from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { fileURLToPath } from 'node:url';
4
+ import mysql from 'mysql2/promise';
5
+ import { deviceStore } from './device-store.mjs';
6
+ import { relayAdminState } from './admin-state.mjs';
7
+ import { relaySchemaVersions } from '../../database/versions.mjs';
8
+ import { connectionInfo } from './device-info.mjs';
9
+
10
+ const sqlTime = value => value.replace('T', ' ').replace(/Z$/, '');
11
+ const isoTime = value => value.replace(' ', 'T') + 'Z';
12
+
13
+ export async function openRelayMysql(configFile, initialMaxRelaySessions) {
14
+ const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
15
+ if (!config.database || !config.user || !config.password || !(config.socketPath || config.host)) {
16
+ throw new Error('Incomplete relay MySQL configuration');
17
+ }
18
+ const pool = mysql.createPool({ ...config, waitForConnections: true, connectionLimit: 4,
19
+ namedPlaceholders: false, dateStrings: true, timezone: 'Z', multipleStatements: false });
20
+ try {
21
+ const [versions] = await pool.query('SELECT version, sha256 FROM schema_migrations');
22
+ const knownVersions = relaySchemaVersions;
23
+ if (!versions.some(row => row.version === knownVersions[0]) || versions.length > knownVersions.length) throw new Error('Unexpected relay database schema');
24
+ for (const row of versions) {
25
+ if (!knownVersions.includes(row.version)) throw new Error('Unexpected relay database schema');
26
+ const source = fs.readFileSync(fileURLToPath(new URL(`../../database/${row.version}.sql`, import.meta.url)));
27
+ if (row.sha256 !== createHash('sha256').update(source).digest('hex')) throw new Error(`Unexpected relay database schema: ${row.version}`);
28
+ }
29
+ const [computers] = await pool.query('SELECT id, host_hash, status, revision, created_at, updated_at FROM computers');
30
+ const [clients] = await pool.query('SELECT computer_id, client_id, credential_hash, created_at, updated_at, last_connected_at, platform, device_name FROM mobile_authorizations ORDER BY created_at');
31
+ const [official] = await pool.query('SELECT computer_id, environment_id, name, hostname, updated_at FROM official_computers');
32
+ const records = new Map(computers.map(row => [row.id, { id: row.id, hostHash: row.host_hash, status: row.status,
33
+ revision: Number(row.revision), createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at), clients: [] }]));
34
+ for (const row of clients) {
35
+ const record = records.get(row.computer_id);
36
+ if (!record) throw new Error('Orphan mobile authorization');
37
+ record.clients.push({ id: row.client_id, hash: row.credential_hash,
38
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
39
+ ...(row.last_connected_at ? { lastConnectedAt: isoTime(row.last_connected_at) } : {}),
40
+ ...(row.platform ? { platform: row.platform } : {}), ...(row.device_name ? { deviceName: row.device_name } : {}) });
41
+ }
42
+ for (const row of official) {
43
+ const record = records.get(row.computer_id);
44
+ if (!record) throw new Error('Orphan official computer');
45
+ record.officialComputer = { environmentId: row.environment_id, name: row.name,
46
+ hostname: row.hostname, updatedAt: isoTime(row.updated_at) };
47
+ }
48
+ const validator = deviceStore();
49
+ for (const record of records.values()) validator.save(record);
50
+
51
+ const [settings] = await pool.query('SELECT max_relay_sessions FROM relay_settings WHERE id = 1');
52
+ if (settings.length !== 1) throw new Error('Missing relay settings');
53
+ const [rows] = await pool.query('SELECT id, title, message, status, created_at, updated_at, published_at FROM announcements ORDER BY created_at DESC');
54
+ const announcements = rows.map(row => ({ id: row.id, title: row.title, message: row.message, status: row.status,
55
+ createdAt: isoTime(row.created_at), updatedAt: isoTime(row.updated_at),
56
+ publishedAt: row.published_at ? isoTime(row.published_at) : null }));
57
+ const active = announcements.find(item => item.status === 'active');
58
+ const initial = { version: 3, maxRelaySessions: settings[0].max_relay_sessions, announcements,
59
+ announcement: active ? { id: active.id, title: active.title, message: active.message, publishedAt: active.publishedAt } : null };
60
+ const admin = relayAdminState(undefined, initialMaxRelaySessions, { initial, persist: async next => {
61
+ const connection = await pool.getConnection();
62
+ try {
63
+ await connection.beginTransaction();
64
+ await connection.execute('UPDATE relay_settings SET max_relay_sessions = ?, updated_at = UTC_TIMESTAMP(3) WHERE id = 1', [next.maxRelaySessions]);
65
+ await connection.query('DELETE FROM announcements');
66
+ for (const item of next.announcements) await connection.execute(
67
+ 'INSERT INTO announcements (id,title,message,status,created_at,updated_at,published_at) VALUES (?,?,?,?,?,?,?)',
68
+ [item.id, item.title, item.message, item.status, sqlTime(item.createdAt), sqlTime(item.updatedAt),
69
+ item.publishedAt ? sqlTime(item.publishedAt) : null]);
70
+ await connection.commit();
71
+ } catch (error) { await connection.rollback(); throw error; }
72
+ finally { connection.release(); }
73
+ } });
74
+ return {
75
+ pool,
76
+ schemaVersions: versions.map(row => row.version),
77
+ devices: {
78
+ entries: () => [...records.values()],
79
+ async recordConnection(id, clientId, at, info) {
80
+ const record = records.get(id), client = record?.clients.find(item => item.id === clientId);
81
+ if (!client) return;
82
+ const updated = connectionInfo(client, at, info);
83
+ await pool.execute('UPDATE mobile_authorizations SET last_connected_at=?,platform=?,device_name=? WHERE computer_id=? AND client_id=?',
84
+ [sqlTime(updated.lastConnectedAt), updated.platform || null, updated.deviceName || null, id, clientId]);
85
+ records.set(id, { ...record, clients: record.clients.map(item => item.id === clientId ? updated : item) });
86
+ return updated;
87
+ },
88
+ async save(record) {
89
+ validator.save(record);
90
+ const connection = await pool.getConnection();
91
+ try {
92
+ await connection.beginTransaction();
93
+ await connection.execute(`INSERT INTO computers (id,host_hash,status,revision,created_at,updated_at)
94
+ VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE host_hash=VALUES(host_hash),status=VALUES(status),
95
+ revision=VALUES(revision),updated_at=VALUES(updated_at)`,
96
+ [record.id, record.hostHash, record.status, record.revision, sqlTime(record.createdAt), sqlTime(record.updatedAt)]);
97
+ await connection.execute('DELETE FROM mobile_authorizations WHERE computer_id = ?', [record.id]);
98
+ for (const client of record.clients) await connection.execute(
99
+ 'INSERT INTO mobile_authorizations (computer_id,client_id,credential_hash,created_at,updated_at,last_connected_at,platform,device_name) VALUES (?,?,?,?,?,?,?,?)',
100
+ [record.id, client.id, client.hash, sqlTime(client.createdAt), sqlTime(client.updatedAt),
101
+ client.lastConnectedAt ? sqlTime(client.lastConnectedAt) : null, client.platform || null, client.deviceName || null]);
102
+ if (record.officialComputer) {
103
+ const item = record.officialComputer;
104
+ await connection.execute(`INSERT INTO official_computers (computer_id,environment_id,name,hostname,updated_at)
105
+ VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE environment_id=VALUES(environment_id),name=VALUES(name),
106
+ hostname=VALUES(hostname),updated_at=VALUES(updated_at)`,
107
+ [record.id, item.environmentId, item.name, item.hostname, sqlTime(item.updatedAt)]);
108
+ }
109
+ await connection.commit(); records.set(record.id, record);
110
+ } catch (error) { await connection.rollback(); throw error; }
111
+ finally { connection.release(); }
112
+ },
113
+ },
114
+ admin,
115
+ close: () => pool.end(),
116
+ };
117
+ } catch (error) { await pool.end(); throw error; }
118
+ }
@@ -1,55 +1,55 @@
1
- import { validId } from './device-store.mjs';
2
-
3
- export function validPhoneConnection(value) {
4
- return value && validId(value.connectionId) && validId(value.clientId) && ['lan', 'self-relay'].includes(value.mode);
5
- }
6
-
7
- export function phoneConnectionEvents(pool) {
8
- const recent = new Map();
9
- const lastConnections = new Map();
10
- return {
11
- async record(computerId, event) {
12
- if (!validPhoneConnection(event) || !validId(event.id) ||
13
- !['connected', 'disconnected'].includes(event.state) ||
14
- typeof event.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(event.at) ||
15
- !Number.isFinite(Date.parse(event.at)) || new Date(event.at).toISOString() !== event.at) throw new Error('Invalid phone connection event');
16
- const item = { id: event.id, connectionId: event.connectionId, clientId: event.clientId,
17
- mode: event.mode, state: event.state, at: event.at, receivedAt: new Date().toISOString() };
18
- if (pool) await pool.execute(`INSERT INTO workstation_phone_events
19
- (event_id,computer_id,client_id,connection_id,mode,state,occurred_at,received_at) VALUES (?,?,?,?,?,?,?,?)
20
- ON DUPLICATE KEY UPDATE event_id=event_id`,
21
- [item.id, computerId, item.clientId, item.connectionId, item.mode, item.state,
22
- item.at.slice(0, 23).replace('T', ' '), item.receivedAt.slice(0, 23).replace('T', ' ')]);
23
- else {
24
- const items = recent.get(computerId) || [];
25
- if (!items.some(value => value.id === item.id)) items.push(item);
26
- recent.set(computerId, items.slice(-20));
27
- if (item.state === 'connected') {
28
- const phones = lastConnections.get(computerId) || new Map();
29
- if (!phones.has(item.clientId) || phones.get(item.clientId).at <= item.at) phones.set(item.clientId, item);
30
- lastConnections.set(computerId, phones);
31
- }
32
- }
33
- },
34
- async latest(computerIds) {
35
- if (!computerIds.length) return [];
36
- if (!pool) return computerIds.flatMap(computerId => [...(lastConnections.get(computerId)?.values() || [])]
37
- .map(item => ({ computerId, clientId: item.clientId, mode: item.mode, at: item.at })));
38
- const [rows] = await pool.execute(`SELECT computer_id,client_id,mode,occurred_at FROM (
39
- SELECT computer_id,client_id,mode,occurred_at,
40
- ROW_NUMBER() OVER (PARTITION BY computer_id,client_id ORDER BY occurred_at DESC,sequence_id DESC) AS connection_rank
41
- FROM workstation_phone_events WHERE state='connected' AND computer_id IN (${computerIds.map(() => '?').join(',')})
42
- ) latest WHERE connection_rank=1`, computerIds);
43
- return rows.map(row => ({ computerId: row.computer_id, clientId: row.client_id,
44
- mode: row.mode, at: row.occurred_at.replace(' ', 'T') + 'Z' }));
45
- },
46
- async list(computerId) {
47
- if (!pool) return [...(recent.get(computerId) || [])].reverse();
48
- const [rows] = await pool.execute(`SELECT event_id,client_id,connection_id,mode,state,occurred_at,received_at
49
- FROM workstation_phone_events WHERE computer_id=? ORDER BY sequence_id DESC LIMIT 20`, [computerId]);
50
- return rows.map(row => ({ id: row.event_id, clientId: row.client_id, connectionId: row.connection_id,
51
- mode: row.mode, state: row.state, at: row.occurred_at.replace(' ', 'T') + 'Z',
52
- receivedAt: row.received_at.replace(' ', 'T') + 'Z' }));
53
- },
54
- };
55
- }
1
+ import { validId } from './device-store.mjs';
2
+
3
+ export function validPhoneConnection(value) {
4
+ return value && validId(value.connectionId) && validId(value.clientId) && ['lan', 'self-relay'].includes(value.mode);
5
+ }
6
+
7
+ export function phoneConnectionEvents(pool) {
8
+ const recent = new Map();
9
+ const lastConnections = new Map();
10
+ return {
11
+ async record(computerId, event) {
12
+ if (!validPhoneConnection(event) || !validId(event.id) ||
13
+ !['connected', 'disconnected'].includes(event.state) ||
14
+ typeof event.at !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(event.at) ||
15
+ !Number.isFinite(Date.parse(event.at)) || new Date(event.at).toISOString() !== event.at) throw new Error('Invalid phone connection event');
16
+ const item = { id: event.id, connectionId: event.connectionId, clientId: event.clientId,
17
+ mode: event.mode, state: event.state, at: event.at, receivedAt: new Date().toISOString() };
18
+ if (pool) await pool.execute(`INSERT INTO workstation_phone_events
19
+ (event_id,computer_id,client_id,connection_id,mode,state,occurred_at,received_at) VALUES (?,?,?,?,?,?,?,?)
20
+ ON DUPLICATE KEY UPDATE event_id=event_id`,
21
+ [item.id, computerId, item.clientId, item.connectionId, item.mode, item.state,
22
+ item.at.slice(0, 23).replace('T', ' '), item.receivedAt.slice(0, 23).replace('T', ' ')]);
23
+ else {
24
+ const items = recent.get(computerId) || [];
25
+ if (!items.some(value => value.id === item.id)) items.push(item);
26
+ recent.set(computerId, items.slice(-20));
27
+ if (item.state === 'connected') {
28
+ const phones = lastConnections.get(computerId) || new Map();
29
+ if (!phones.has(item.clientId) || phones.get(item.clientId).at <= item.at) phones.set(item.clientId, item);
30
+ lastConnections.set(computerId, phones);
31
+ }
32
+ }
33
+ },
34
+ async latest(computerIds) {
35
+ if (!computerIds.length) return [];
36
+ if (!pool) return computerIds.flatMap(computerId => [...(lastConnections.get(computerId)?.values() || [])]
37
+ .map(item => ({ computerId, clientId: item.clientId, mode: item.mode, at: item.at })));
38
+ const [rows] = await pool.execute(`SELECT computer_id,client_id,mode,occurred_at FROM (
39
+ SELECT computer_id,client_id,mode,occurred_at,
40
+ ROW_NUMBER() OVER (PARTITION BY computer_id,client_id ORDER BY occurred_at DESC,sequence_id DESC) AS connection_rank
41
+ FROM workstation_phone_events WHERE state='connected' AND computer_id IN (${computerIds.map(() => '?').join(',')})
42
+ ) latest WHERE connection_rank=1`, computerIds);
43
+ return rows.map(row => ({ computerId: row.computer_id, clientId: row.client_id,
44
+ mode: row.mode, at: row.occurred_at.replace(' ', 'T') + 'Z' }));
45
+ },
46
+ async list(computerId) {
47
+ if (!pool) return [...(recent.get(computerId) || [])].reverse();
48
+ const [rows] = await pool.execute(`SELECT event_id,client_id,connection_id,mode,state,occurred_at,received_at
49
+ FROM workstation_phone_events WHERE computer_id=? ORDER BY sequence_id DESC LIMIT 20`, [computerId]);
50
+ return rows.map(row => ({ id: row.event_id, clientId: row.client_id, connectionId: row.connection_id,
51
+ mode: row.mode, state: row.state, at: row.occurred_at.replace(' ', 'T') + 'Z',
52
+ receivedAt: row.received_at.replace(' ', 'T') + 'Z' }));
53
+ },
54
+ };
55
+ }
@@ -471,7 +471,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
471
471
  void meter?.lost(task).catch(error => log({ event: 'usage-error', message: error.message }));
472
472
  }
473
473
  room.usageTasks?.clear(); room.usageArms?.clear();
474
- room.host = undefined; room.hostName = undefined; room.lanCandidates = [];
474
+ room.host = undefined; room.hostName = undefined; room.lanCandidates = []; room.health = null;
475
475
  room.uploadHost?.close(4410, 'Computer offline');
476
476
  for (const client of room.uploadClients?.values() || []) client.ws.close(code === 4432 ? 4432 : 4410, code === 4432 ? 'Computer inbound queue limit exceeded' : 'Computer offline');
477
477
  for (const observer of room.observers.values()) send(observer.ws, { type: 'observing', online: false });
@@ -613,7 +613,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
613
613
  }
614
614
  room.usageTasks?.clear(); room.usageArms?.clear();
615
615
  }
616
- room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates;
616
+ room.host = ws; room.hostName = hostName; room.lanCandidates = lanCandidates; room.health = null;
617
617
  room.adminPhoneConnections = undefined;
618
618
  room.adminLastOnlineAt = ws.adminInfo.connectedAt; room.adminLastHostName = hostName;
619
619
  room.usageTasks ||= new Map(); room.usageArms ||= new Map();
@@ -680,7 +680,7 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
680
680
  const oldObserver = room.observers.get(clientId);
681
681
  if (oldObserver?.ws && oldObserver.ws !== ws) oldObserver.ws.close(4409, 'Metadata connection replaced by same identity');
682
682
  room.observers.set(clientId, { ws, userId });
683
- send(ws, { type: 'observing', online: Boolean(room.host) });
683
+ send(ws, { type: 'observing', online: Boolean(room.host), health: room.host ? room.health || null : null });
684
684
  send(ws, { type: 'debug-permissions', enabled: debugAllowed(debugAllowlist, room.id, room.clients.find(client => client.id === clientId)) });
685
685
  const announcement = adminState.read().announcement;
686
686
  if (announcement) send(ws, { type: 'announcement', announcement });
@@ -716,6 +716,17 @@ export async function startSelfRelay({ port = 0, hostKey = secret(), pairingTtlM
716
716
  await manageDevices(room, ws, message, clientId); return;
717
717
  }
718
718
  if (role === 'host') {
719
+ if (message.type === 'workstation-health') {
720
+ if (room.host !== ws) return;
721
+ const health = message.health;
722
+ if (health !== null && (!health || Array.isArray(health) || typeof health !== 'object' ||
723
+ health.status !== 'ok' || typeof health.serviceInstanceId !== 'string' || JSON.stringify(health).length > 65536))
724
+ return fail(1008, 'Invalid workstation health');
725
+ room.health = health;
726
+ for (const observer of room.observers.values()) send(observer.ws, { type: 'workstation-health', health });
727
+ return;
728
+ }
729
+
719
730
  if (['phone-connection', 'phone-connections'].includes(message.type)) {
720
731
  if (room.host !== ws) return;
721
732
  if (message.type === 'phone-connections') {
@@ -1,55 +1,55 @@
1
- const dayMs = 86400000;
2
- const chinaOffsetMs = 8 * 3600000;
3
-
4
- export function usageDaySlices(start, end) {
5
- const slices = [];
6
- for (let cursor = start; cursor < end;) {
7
- const date = new Date(cursor + chinaOffsetMs).toISOString().slice(0, 10);
8
- const nextDay = Date.parse(`${date}T00:00:00Z`) + dayMs - chinaOffsetMs;
9
- const until = Math.min(end, nextDay);
10
- slices.push({ date, start: cursor, end: until });
11
- cursor = until;
12
- }
13
- return slices;
14
- }
15
-
16
- const readIntervals = value => Array.isArray(value) ? value : JSON.parse(value || '[]');
17
-
18
- export function mergeUsageIntervals(existing, start, end) {
19
- const sorted = [...existing, [start, end]].sort((a, b) => a[0] - b[0]);
20
- const merged = [];
21
- for (const range of sorted) {
22
- const last = merged.at(-1);
23
- if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
24
- else merged.push([...range]);
25
- }
26
- return { intervals: merged, seconds: merged.reduce((sum, [from, until]) => sum + until - from, 0) / 1000 };
27
- }
28
-
29
- export async function recordDailyUsage(db, userId, computerId, free, start, end, now) {
30
- for (const slice of usageDaySlices(start, end)) {
31
- await db.execute(`INSERT IGNORE INTO relay_usage_user_days
32
- (user_id,usage_date,intervals,updated_at) VALUES (?,?,'[]',?)`, [userId, slice.date, now]);
33
- const [userRows] = await db.execute(`SELECT intervals FROM relay_usage_user_days
34
- WHERE user_id=? AND usage_date=? FOR UPDATE`, [userId, slice.date]);
35
- const user = mergeUsageIntervals(readIntervals(userRows[0].intervals), slice.start, slice.end);
36
- await db.execute(`UPDATE relay_usage_user_days SET intervals=?,task_seconds=?,updated_at=?
37
- WHERE user_id=? AND usage_date=?`,
38
- [JSON.stringify(user.intervals), user.seconds, now, userId, slice.date]);
39
-
40
- await db.execute(`INSERT IGNORE INTO relay_usage_computer_days
41
- (computer_id,usage_date,intervals,free_intervals,updated_at) VALUES (?,?,'[]','[]',?)`,
42
- [computerId, slice.date, now]);
43
- const [computerRows] = await db.execute(`SELECT intervals,free_intervals FROM relay_usage_computer_days
44
- WHERE computer_id=? AND usage_date=? FOR UPDATE`, [computerId, slice.date]);
45
- const computer = mergeUsageIntervals(readIntervals(computerRows[0].intervals), slice.start, slice.end);
46
- const freeIntervals = readIntervals(computerRows[0].free_intervals);
47
- const freeComputer = free ? mergeUsageIntervals(freeIntervals, slice.start, slice.end) : null;
48
- await db.execute(`UPDATE relay_usage_computer_days SET intervals=?,task_seconds=?,
49
- free_intervals=?,free_task_seconds=COALESCE(?,free_task_seconds),updated_at=?
50
- WHERE computer_id=? AND usage_date=?`,
51
- [JSON.stringify(computer.intervals), computer.seconds,
52
- JSON.stringify(freeComputer?.intervals || freeIntervals), freeComputer?.seconds ?? null,
53
- now, computerId, slice.date]);
54
- }
55
- }
1
+ const dayMs = 86400000;
2
+ const chinaOffsetMs = 8 * 3600000;
3
+
4
+ export function usageDaySlices(start, end) {
5
+ const slices = [];
6
+ for (let cursor = start; cursor < end;) {
7
+ const date = new Date(cursor + chinaOffsetMs).toISOString().slice(0, 10);
8
+ const nextDay = Date.parse(`${date}T00:00:00Z`) + dayMs - chinaOffsetMs;
9
+ const until = Math.min(end, nextDay);
10
+ slices.push({ date, start: cursor, end: until });
11
+ cursor = until;
12
+ }
13
+ return slices;
14
+ }
15
+
16
+ const readIntervals = value => Array.isArray(value) ? value : JSON.parse(value || '[]');
17
+
18
+ export function mergeUsageIntervals(existing, start, end) {
19
+ const sorted = [...existing, [start, end]].sort((a, b) => a[0] - b[0]);
20
+ const merged = [];
21
+ for (const range of sorted) {
22
+ const last = merged.at(-1);
23
+ if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
24
+ else merged.push([...range]);
25
+ }
26
+ return { intervals: merged, seconds: merged.reduce((sum, [from, until]) => sum + until - from, 0) / 1000 };
27
+ }
28
+
29
+ export async function recordDailyUsage(db, userId, computerId, free, start, end, now) {
30
+ for (const slice of usageDaySlices(start, end)) {
31
+ await db.execute(`INSERT IGNORE INTO relay_usage_user_days
32
+ (user_id,usage_date,intervals,updated_at) VALUES (?,?,'[]',?)`, [userId, slice.date, now]);
33
+ const [userRows] = await db.execute(`SELECT intervals FROM relay_usage_user_days
34
+ WHERE user_id=? AND usage_date=? FOR UPDATE`, [userId, slice.date]);
35
+ const user = mergeUsageIntervals(readIntervals(userRows[0].intervals), slice.start, slice.end);
36
+ await db.execute(`UPDATE relay_usage_user_days SET intervals=?,task_seconds=?,updated_at=?
37
+ WHERE user_id=? AND usage_date=?`,
38
+ [JSON.stringify(user.intervals), user.seconds, now, userId, slice.date]);
39
+
40
+ await db.execute(`INSERT IGNORE INTO relay_usage_computer_days
41
+ (computer_id,usage_date,intervals,free_intervals,updated_at) VALUES (?,?,'[]','[]',?)`,
42
+ [computerId, slice.date, now]);
43
+ const [computerRows] = await db.execute(`SELECT intervals,free_intervals FROM relay_usage_computer_days
44
+ WHERE computer_id=? AND usage_date=? FOR UPDATE`, [computerId, slice.date]);
45
+ const computer = mergeUsageIntervals(readIntervals(computerRows[0].intervals), slice.start, slice.end);
46
+ const freeIntervals = readIntervals(computerRows[0].free_intervals);
47
+ const freeComputer = free ? mergeUsageIntervals(freeIntervals, slice.start, slice.end) : null;
48
+ await db.execute(`UPDATE relay_usage_computer_days SET intervals=?,task_seconds=?,
49
+ free_intervals=?,free_task_seconds=COALESCE(?,free_task_seconds),updated_at=?
50
+ WHERE computer_id=? AND usage_date=?`,
51
+ [JSON.stringify(computer.intervals), computer.seconds,
52
+ JSON.stringify(freeComputer?.intervals || freeIntervals), freeComputer?.seconds ?? null,
53
+ now, computerId, slice.date]);
54
+ }
55
+ }