@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.
- package/dist/bin/manage-relay-admin.mjs +20 -20
- package/dist/bin/migrate-relay-db.mjs +109 -109
- package/dist/bin/publish-harmony-release.mjs +46 -46
- package/dist/bin/self-relay-admin.mjs +8 -8
- package/dist/bin/self-relay-demo.mjs +33 -33
- package/dist/bin/self-relay-server.mjs +66 -66
- package/dist/bin/set-relay-admin-password.mjs +11 -11
- package/dist/build-platform.json +1 -1
- package/dist/get-lan-networks.ps1 +40 -40
- package/dist/shared/app-server-events.cjs +511 -511
- package/dist/shared/p2p-data-channel.cjs +72 -72
- package/dist/shared/p2p-diagnostics.cjs +28 -28
- package/dist/shared/p2p-ice-policy.cjs +22 -22
- package/dist/shared/plan-pricing.cjs +32 -32
- package/dist/shared/question-attention.cjs +69 -69
- package/dist/shared/release-version.cjs +31 -31
- package/dist/src/archived-threads.mjs +22 -22
- package/dist/src/background-process.mjs +18 -18
- package/dist/src/code-browser.mjs +100 -100
- package/dist/src/desktop-launcher.mjs +62 -62
- package/dist/src/dots-task-files.mjs +52 -52
- package/dist/src/execution-health.mjs +26 -26
- package/dist/src/lan-socket-server.mjs +1 -1
- package/dist/src/message-images.mjs +9 -9
- package/dist/src/plan-pricing.mjs +47 -47
- package/dist/src/platform/codex-runtime.mjs +3 -3
- package/dist/src/platform/credential-store.mjs +19 -19
- package/dist/src/platform/desktop-host.mjs +3 -3
- package/dist/src/platform/desktop-permission-mode.mjs +17 -17
- package/dist/src/platform/desktop-project-registration.mjs +3 -3
- package/dist/src/platform/file-opener.mjs +9 -9
- package/dist/src/platform/linux/README.md +3 -3
- package/dist/src/platform/linux/codex-runtime.mjs +56 -56
- package/dist/src/platform/linux/desktop-installation.mjs +36 -36
- package/dist/src/platform/linux/desktop-project-registration.mjs +11 -11
- package/dist/src/platform/linux/network-info.mjs +11 -11
- package/dist/src/platform/linux/project-locations.mjs +17 -17
- package/dist/src/platform/linux/resident-startup.mjs +52 -52
- package/dist/src/platform/linux/retained-runtime.mjs +76 -76
- package/dist/src/platform/linux/service-host.mjs +18 -18
- package/dist/src/platform/macos/README.md +3 -3
- package/dist/src/platform/macos/desktop-project-registration.mjs +11 -11
- package/dist/src/platform/macos/network-info.mjs +8 -8
- package/dist/src/platform/macos/project-locations.mjs +8 -8
- package/dist/src/platform/macos/resident-startup.mjs +84 -84
- package/dist/src/platform/macos/retained-runtime.mjs +127 -127
- package/dist/src/platform/macos/service-host.mjs +18 -18
- package/dist/src/platform/macos/system.mjs +29 -29
- package/dist/src/platform/network-info.mjs +3 -3
- package/dist/src/platform/path-identity.mjs +5 -5
- package/dist/src/platform/posix/network-candidates.mjs +23 -23
- package/dist/src/platform/posix/runtime-paths.mjs +25 -25
- package/dist/src/platform/power-inhibitor.mjs +22 -22
- package/dist/src/platform/project-locations.mjs +3 -3
- package/dist/src/platform/runtime-paths.mjs +8 -8
- package/dist/src/platform/select.mjs +5 -5
- package/dist/src/platform/service-host.mjs +3 -3
- package/dist/src/platform/user-environment.mjs +3 -3
- package/dist/src/platform/windows/IsolatedProcess.cs +79 -79
- package/dist/src/platform/windows/background-process.mjs +30 -30
- package/dist/src/platform/windows/desktop-helper.mjs +179 -179
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +7 -7
- package/dist/src/platform/windows/keep-awake.ps1 +20 -20
- package/dist/src/platform/windows/network-info.mjs +34 -34
- package/dist/src/platform/windows/resident-start.js +20 -20
- package/dist/src/platform/windows/resident-start.ps1 +7 -7
- package/dist/src/platform/windows/resident-startup.ps1 +83 -83
- package/dist/src/platform/windows/resolve-state-root.ps1 +14 -14
- package/dist/src/platform/windows/runtime-paths.mjs +20 -20
- package/dist/src/platform/windows/rustdesk-video-probe.mjs +167 -167
- package/dist/src/platform/windows/start-hidden-console.ps1 +58 -58
- package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
- package/dist/src/platform/windows/user-environment.ps1 +41 -41
- package/dist/src/project-create.mjs +121 -121
- package/dist/src/project-management.mjs +1 -1
- package/dist/src/project-remove.mjs +55 -55
- package/dist/src/project-update.mjs +59 -59
- package/dist/src/relay-config-import.mjs +45 -45
- package/dist/src/self-relay/account-connections.mjs +52 -52
- package/dist/src/self-relay/admin-auth.mjs +59 -59
- package/dist/src/self-relay/admin-cloud.mjs +101 -101
- package/dist/src/self-relay/admin-login-page.mjs +7 -7
- package/dist/src/self-relay/admin-server.mjs +105 -105
- package/dist/src/self-relay/admin-state.mjs +127 -127
- package/dist/src/self-relay/admin-workstations-page.mjs +95 -95
- package/dist/src/self-relay/admin-workstations.mjs +143 -143
- package/dist/src/self-relay/android-releases.mjs +61 -61
- package/dist/src/self-relay/client.mjs +61 -61
- package/dist/src/self-relay/connection-reporting.mjs +38 -38
- package/dist/src/self-relay/connector.mjs +13 -2
- package/dist/src/self-relay/debug-allowlist.mjs +33 -33
- package/dist/src/self-relay/demo.mjs +6 -6
- package/dist/src/self-relay/device-info.mjs +27 -27
- package/dist/src/self-relay/device-store.mjs +92 -92
- package/dist/src/self-relay/lan-authorizations.mjs +71 -71
- package/dist/src/self-relay/lifecycle.mjs +278 -278
- package/dist/src/self-relay/long-pairing.mjs +40 -40
- package/dist/src/self-relay/mysql-state.mjs +118 -118
- package/dist/src/self-relay/phone-connections.mjs +55 -55
- package/dist/src/self-relay/server.mjs +14 -3
- package/dist/src/self-relay/usage-daily.mjs +55 -55
- package/dist/src/self-relay/windows-route-helper.mjs +75 -75
- package/dist/src/self-relay/wire.mjs +36 -36
- package/dist/src/server.mjs +40 -18
- package/dist/src/service-control.mjs +82 -82
- package/dist/src/service-health.mjs +33 -33
- package/dist/src/service-power.mjs +58 -58
- package/dist/src/service-restart.mjs +81 -81
- package/dist/src/service-update-worker.mjs +42 -42
- package/dist/src/service-update.mjs +80 -80
- package/dist/src/shared-recovery.mjs +152 -152
- package/dist/src/skill-install.mjs +51 -51
- package/dist/src/thread-title-generation.mjs +150 -150
- package/dist/src/turn-model-history.mjs +47 -47
- package/dist/tools/rtc-route-helper.ps1 +99 -99
- package/dist/tools/start-rtc-route-helper.ps1 +15 -15
- package/dist/web/vendor/markdown-it.LICENSE.txt +22 -22
- package/dist/web/vendor/markdown-it.umd.min.js +13 -13
- 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
|
+
}
|