@walkhi/code-relax 0.1.0-beta.30 → 0.1.0-beta.32
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 +95 -89
- package/dist/bin/cleanup-accounts.mjs +63 -63
- package/dist/bin/codex-remote.mjs +374 -367
- package/dist/bin/manage-relay-admin.mjs +20 -20
- package/dist/bin/migrate-relay-db.mjs +109 -109
- package/dist/bin/publish-android-release.mjs +63 -58
- package/dist/bin/publish-harmony-release.mjs +46 -0
- package/dist/bin/release-upload-server.mjs +174 -0
- 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/build-platform.json +1 -1
- package/dist/get-lan-networks.ps1 +40 -40
- package/dist/get-shared-service-url.ps1 +6 -6
- package/dist/shared/app-server-events.cjs +422 -412
- package/dist/shared/code-browser.cjs +159 -159
- package/dist/shared/file-changes.cjs +31 -31
- package/dist/shared/file-citations.cjs +117 -117
- package/dist/shared/file-mentions.cjs +92 -92
- package/dist/shared/message-delivery.cjs +54 -54
- package/dist/shared/p2p-data-channel.cjs +72 -72
- package/dist/shared/p2p-diagnostics.cjs +28 -28
- package/dist/shared/p2p-file-channel.cjs +78 -74
- package/dist/shared/plan-pricing.cjs +32 -32
- package/dist/shared/question-attention.cjs +69 -0
- package/dist/shared/server-requests.cjs +219 -219
- package/dist/shared/timeline-sync.cjs +173 -173
- package/dist/skills/code-relax/SKILL.md +22 -22
- package/dist/src/archived-threads.mjs +22 -0
- package/dist/src/background-process.mjs +18 -18
- package/dist/src/bounded-process-log.mjs +63 -63
- package/dist/src/celsius-client.mjs +107 -107
- package/dist/src/code-browser.mjs +100 -100
- package/dist/src/desktop-launcher.mjs +46 -46
- package/dist/src/dots-approval-timeline.mjs +99 -99
- package/dist/src/dots-avatar.mjs +42 -42
- package/dist/src/dots-chat-projection.mjs +50 -50
- package/dist/src/dots-chat-server.mjs +461 -461
- package/dist/src/dots-message-images.mjs +319 -319
- package/dist/src/dots-profile.mjs +77 -77
- package/dist/src/dots-room-activity.mjs +94 -94
- package/dist/src/dots-room-client.mjs +135 -135
- package/dist/src/dots-room-projection.mjs +94 -94
- package/dist/src/dots-schedules.mjs +66 -66
- package/dist/src/dots-summary-tasks.mjs +32 -32
- package/dist/src/dots-task-catalog.mjs +430 -430
- package/dist/src/dots-task-files.mjs +52 -52
- package/dist/src/execution-health.mjs +26 -26
- package/dist/src/file-transfer-range.mjs +22 -22
- package/dist/src/file-upload-session.mjs +130 -130
- package/dist/src/hosted-history-app-server.mjs +103 -103
- package/dist/src/hosted-history-cache.mjs +113 -113
- package/dist/src/lan-socket-server.mjs +265 -263
- package/dist/src/managed-app-server.mjs +244 -244
- package/dist/src/message-images.mjs +9 -9
- package/dist/src/message-upload-files.mjs +74 -74
- package/dist/src/onboarding.mjs +12 -12
- package/dist/src/plan-pricing.mjs +47 -47
- package/dist/src/platform/README.md +12 -12
- package/dist/src/platform/codex-protocol.mjs +46 -46
- package/dist/src/platform/codex-runtime.mjs +3 -3
- package/dist/src/platform/credential-store.mjs +3 -3
- package/dist/src/platform/desktop-host.mjs +3 -3
- package/dist/src/platform/desktop-project-registration.mjs +3 -3
- package/dist/src/platform/desktop-project-store.mjs +208 -149
- package/dist/src/platform/file-opener.mjs +9 -0
- package/dist/src/platform/linux/README.md +3 -3
- package/dist/src/platform/linux/codex-runtime.mjs +56 -0
- package/dist/src/platform/linux/credential-store.mjs +28 -0
- package/dist/src/platform/linux/desktop-host.mjs +83 -0
- package/dist/src/platform/linux/desktop-installation.mjs +36 -0
- package/dist/src/platform/linux/desktop-project-registration.mjs +11 -0
- package/dist/src/platform/linux/network-info.mjs +11 -0
- package/dist/src/platform/linux/process.mjs +42 -0
- package/dist/src/platform/linux/project-locations.mjs +17 -0
- package/dist/src/platform/linux/resident-startup.mjs +52 -0
- package/dist/src/platform/linux/retained-runtime.mjs +76 -0
- package/dist/src/platform/linux/service-host.mjs +18 -0
- package/dist/src/platform/linux/system.mjs +49 -0
- package/dist/src/platform/linux/user-environment.mjs +7 -0
- package/dist/src/platform/macos/README.md +3 -3
- package/dist/src/platform/macos/codex-runtime.mjs +125 -125
- package/dist/src/platform/macos/credential-store.mjs +10 -10
- package/dist/src/platform/macos/desktop-host.mjs +82 -82
- package/dist/src/platform/macos/desktop-project-registration.mjs +11 -10
- package/dist/src/platform/macos/network-info.mjs +8 -28
- 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/macos/user-environment.mjs +7 -7
- package/dist/src/platform/native-package.mjs +58 -46
- 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 -0
- package/dist/src/platform/posix/runtime-paths.mjs +25 -25
- 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/activate-desktop.ps1 +48 -48
- package/dist/src/platform/windows/background-process.mjs +30 -30
- package/dist/src/platform/windows/codex-runtime.mjs +215 -215
- package/dist/src/platform/windows/credential-store.mjs +27 -27
- package/dist/src/platform/windows/credential-store.ps1 +49 -49
- package/dist/src/platform/windows/desktop-helper.mjs +179 -179
- package/dist/src/platform/windows/desktop-host.mjs +58 -58
- package/dist/src/platform/windows/desktop-info.ps1 +14 -14
- package/dist/src/platform/windows/desktop-project-registration.mjs +4 -4
- package/dist/src/platform/windows/desktop-runtime.ps1 +10 -10
- package/dist/src/platform/windows/desktop-shortcuts.ps1 +21 -21
- package/dist/src/platform/windows/dots-profile.mjs +49 -49
- package/dist/src/platform/windows/launch-worker.mjs +41 -41
- package/dist/src/platform/windows/network-info.mjs +34 -34
- package/dist/src/platform/windows/project-locations.mjs +16 -16
- 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/service-host.mjs +31 -31
- package/dist/src/platform/windows/start-hidden-console.ps1 +49 -49
- package/dist/src/platform/windows/stop-desktop.ps1 +22 -22
- package/dist/src/platform/windows/user-environment.mjs +50 -50
- package/dist/src/platform/windows/user-environment.ps1 +41 -41
- package/dist/src/project-archive.mjs +30 -0
- package/dist/src/project-create.mjs +121 -119
- package/dist/src/project-management.mjs +59 -69
- package/dist/src/project-remove.mjs +55 -0
- package/dist/src/project-update.mjs +56 -0
- package/dist/src/runtime-discovery.mjs +47 -47
- package/dist/src/self-relay/account-cleanup.mjs +30 -30
- package/dist/src/self-relay/account-connections.mjs +52 -52
- package/dist/src/self-relay/admin-cloud.mjs +101 -101
- package/dist/src/self-relay/admin-page.mjs +80 -80
- package/dist/src/self-relay/admin-server.mjs +74 -74
- package/dist/src/self-relay/admin-state.mjs +127 -127
- package/dist/src/self-relay/admin-style.mjs +14 -14
- package/dist/src/self-relay/android-releases.mjs +61 -61
- package/dist/src/self-relay/client.mjs +61 -61
- package/dist/src/self-relay/connector.mjs +439 -438
- package/dist/src/self-relay/debug-allowlist.mjs +33 -33
- package/dist/src/self-relay/demo.mjs +37 -37
- package/dist/src/self-relay/device-info.mjs +27 -0
- package/dist/src/self-relay/device-store.mjs +92 -84
- package/dist/src/self-relay/diagnostics.mjs +54 -54
- package/dist/src/self-relay/email-accounts.mjs +183 -183
- package/dist/src/self-relay/file-wire.mjs +13 -13
- package/dist/src/self-relay/lan-authorizations.mjs +71 -42
- package/dist/src/self-relay/lifecycle.mjs +264 -250
- package/dist/src/self-relay/long-pairing.mjs +40 -40
- package/dist/src/self-relay/mysql-state.mjs +118 -105
- package/dist/src/self-relay/p2p-probe.mjs +159 -159
- package/dist/src/self-relay/privacy-page.mjs +26 -26
- package/dist/src/self-relay/public-releases.mjs +52 -0
- package/dist/src/self-relay/rtc-file-bridge.mjs +41 -41
- package/dist/src/self-relay/server.mjs +1006 -932
- package/dist/src/self-relay/usage-daily.mjs +55 -55
- package/dist/src/self-relay/usage-meter.mjs +141 -141
- 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 +1861 -1778
- package/dist/src/service-control.mjs +82 -82
- package/dist/src/service-doctor.mjs +110 -110
- package/dist/src/service-health.mjs +33 -33
- package/dist/src/service-install.mjs +91 -91
- package/dist/src/service-lifecycle.mjs +138 -138
- package/dist/src/service-restart.mjs +81 -81
- package/dist/src/shared-app-server.mjs +615 -611
- package/dist/src/shared-catalog.mjs +132 -132
- package/dist/src/shared-recovery.mjs +146 -146
- package/dist/src/shared-thread-stream.mjs +164 -164
- 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/src/usage-reset.mjs +17 -17
- package/dist/src/workstation-downloads.mjs +120 -120
- 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 +7 -6
- package/tools/postinstall.mjs +127 -127
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
|
-
|
|
5
|
-
// 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
|
|
6
|
-
export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
|
|
7
|
-
|
|
8
|
-
export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
|
|
9
|
-
let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
10
|
-
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
11
|
-
|
|
12
|
-
async function save(next) {
|
|
13
|
-
const validated = validate(next);
|
|
14
|
-
if (persist) await persist(validated);
|
|
15
|
-
if (!file) { value = validated; return value; }
|
|
16
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
17
|
-
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
18
|
-
let descriptor;
|
|
19
|
-
try {
|
|
20
|
-
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
21
|
-
fs.writeFileSync(descriptor, JSON.stringify(validated));
|
|
22
|
-
fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
|
|
23
|
-
fs.renameSync(temporary, file);
|
|
24
|
-
if (process.platform !== 'win32') {
|
|
25
|
-
const directory = fs.openSync(path.dirname(file), 'r');
|
|
26
|
-
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
27
|
-
}
|
|
28
|
-
} finally {
|
|
29
|
-
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
30
|
-
fs.rmSync(temporary, { force: true });
|
|
31
|
-
}
|
|
32
|
-
value = validated;
|
|
33
|
-
return value;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
return {
|
|
37
|
-
read: () => structuredClone(value),
|
|
38
|
-
setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
|
|
39
|
-
createAnnouncement(title, message, publish = false) {
|
|
40
|
-
const now = new Date().toISOString();
|
|
41
|
-
const record = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
|
|
42
|
-
createdAt: now, updatedAt: now, publishedAt: publish ? now : null, status: publish ? 'active' : 'draft' };
|
|
43
|
-
const announcements = value.announcements.map(item => publish && item.status === 'active'
|
|
44
|
-
? { ...item, status: 'withdrawn', updatedAt: now } : item);
|
|
45
|
-
return save({ ...value, announcement: publish ? publicAnnouncement(record) : value.announcement,
|
|
46
|
-
announcements: [record, ...announcements] });
|
|
47
|
-
},
|
|
48
|
-
updateDraft(id, title, message) {
|
|
49
|
-
const existing = value.announcements.find(item => item.id === id);
|
|
50
|
-
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be edited');
|
|
51
|
-
return save({ ...value, announcements: value.announcements.map(item => item.id === id
|
|
52
|
-
? { ...item, title: clean(title, 80), message: clean(message, 2000), updatedAt: new Date().toISOString() } : item) });
|
|
53
|
-
},
|
|
54
|
-
publishDraft(id, title, message) {
|
|
55
|
-
const existing = value.announcements.find(item => item.id === id);
|
|
56
|
-
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be published');
|
|
57
|
-
const now = new Date().toISOString();
|
|
58
|
-
const published = { ...existing, title: title === undefined ? existing.title : clean(title, 80),
|
|
59
|
-
message: message === undefined ? existing.message : clean(message, 2000),
|
|
60
|
-
publishedAt: now, updatedAt: now, status: 'active' };
|
|
61
|
-
return save({ ...value, announcement: publicAnnouncement(published),
|
|
62
|
-
announcements: value.announcements.map(item => item.id === id ? published : item.status === 'active'
|
|
63
|
-
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
64
|
-
},
|
|
65
|
-
withdraw(id) {
|
|
66
|
-
if (!value.announcement || value.announcement.id !== id) throw new Error('Announcement is no longer active');
|
|
67
|
-
const now = new Date().toISOString();
|
|
68
|
-
return save({ ...value, announcement: null, announcements: value.announcements.map(item => item.status === 'active'
|
|
69
|
-
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
70
|
-
},
|
|
71
|
-
deleteAnnouncement(id) {
|
|
72
|
-
if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
|
|
73
|
-
if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
|
|
74
|
-
return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function validate(source) {
|
|
80
|
-
if (!source || ![1, 2, 3].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
|
|
81
|
-
source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
|
|
82
|
-
const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
|
|
83
|
-
const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
|
|
84
|
-
if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
|
|
85
|
-
const validated = source.version === 3 ? announcements.map(validateRecord) : announcements.map(item => {
|
|
86
|
-
const old = validateAnnouncement(item);
|
|
87
|
-
return { ...old, createdAt: old.publishedAt, updatedAt: old.publishedAt,
|
|
88
|
-
status: old.id === announcement?.id ? 'active' : 'withdrawn' };
|
|
89
|
-
});
|
|
90
|
-
const active = validated.filter(item => item.status === 'active');
|
|
91
|
-
if (new Set(validated.map(item => item.id)).size !== validated.length || active.length > 1 ||
|
|
92
|
-
(active.length === 0) !== (announcement === null) ||
|
|
93
|
-
(announcement && !active.some(item => item.id === announcement.id &&
|
|
94
|
-
item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
|
|
95
|
-
throw new Error('Invalid relay announcement history');
|
|
96
|
-
}
|
|
97
|
-
return { version: 3, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function validateAnnouncement(item) {
|
|
101
|
-
if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
|
|
102
|
-
!Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
|
|
103
|
-
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function validateRecord(item) {
|
|
107
|
-
if (!item || !/^[\w-]{16,128}$/.test(item.id) || !['draft', 'active', 'withdrawn'].includes(item.status) ||
|
|
108
|
-
typeof item.createdAt !== 'string' || !Number.isFinite(Date.parse(item.createdAt)) ||
|
|
109
|
-
typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
|
|
110
|
-
(item.status === 'draft' ? item.publishedAt !== null :
|
|
111
|
-
typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)))) {
|
|
112
|
-
throw new Error('Invalid relay announcement record');
|
|
113
|
-
}
|
|
114
|
-
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000),
|
|
115
|
-
createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, status: item.status };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function publicAnnouncement(record) {
|
|
119
|
-
return { id: record.id, title: record.title, message: record.message, publishedAt: record.publishedAt };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function clean(value, limit) {
|
|
123
|
-
if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
|
|
124
|
-
throw new Error('Invalid announcement text');
|
|
125
|
-
}
|
|
126
|
-
return value.trim();
|
|
127
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
// 80 title + 2000 body UTF-16 units fit even when every unit uses a six-byte JSON escape.
|
|
6
|
+
export const MAX_ADMIN_REQUEST_BYTES = 16 * 1024;
|
|
7
|
+
|
|
8
|
+
export function relayAdminState(file, initialMaxRelaySessions, { initial, persist } = {}) {
|
|
9
|
+
let value = initial ? validate(initial) : { version: 3, maxRelaySessions: initialMaxRelaySessions, announcement: null, announcements: [] };
|
|
10
|
+
if (file && fs.existsSync(file)) value = validate(JSON.parse(fs.readFileSync(file, 'utf8')));
|
|
11
|
+
|
|
12
|
+
async function save(next) {
|
|
13
|
+
const validated = validate(next);
|
|
14
|
+
if (persist) await persist(validated);
|
|
15
|
+
if (!file) { value = validated; return value; }
|
|
16
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
17
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
18
|
+
let descriptor;
|
|
19
|
+
try {
|
|
20
|
+
descriptor = fs.openSync(temporary, 'wx', 0o600);
|
|
21
|
+
fs.writeFileSync(descriptor, JSON.stringify(validated));
|
|
22
|
+
fs.fsyncSync(descriptor); fs.closeSync(descriptor); descriptor = undefined;
|
|
23
|
+
fs.renameSync(temporary, file);
|
|
24
|
+
if (process.platform !== 'win32') {
|
|
25
|
+
const directory = fs.openSync(path.dirname(file), 'r');
|
|
26
|
+
try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
|
|
27
|
+
}
|
|
28
|
+
} finally {
|
|
29
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
30
|
+
fs.rmSync(temporary, { force: true });
|
|
31
|
+
}
|
|
32
|
+
value = validated;
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
read: () => structuredClone(value),
|
|
38
|
+
setMaxRelaySessions(maxRelaySessions) { return save({ ...value, maxRelaySessions }); },
|
|
39
|
+
createAnnouncement(title, message, publish = false) {
|
|
40
|
+
const now = new Date().toISOString();
|
|
41
|
+
const record = { id: randomUUID(), title: clean(title, 80), message: clean(message, 2000),
|
|
42
|
+
createdAt: now, updatedAt: now, publishedAt: publish ? now : null, status: publish ? 'active' : 'draft' };
|
|
43
|
+
const announcements = value.announcements.map(item => publish && item.status === 'active'
|
|
44
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item);
|
|
45
|
+
return save({ ...value, announcement: publish ? publicAnnouncement(record) : value.announcement,
|
|
46
|
+
announcements: [record, ...announcements] });
|
|
47
|
+
},
|
|
48
|
+
updateDraft(id, title, message) {
|
|
49
|
+
const existing = value.announcements.find(item => item.id === id);
|
|
50
|
+
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be edited');
|
|
51
|
+
return save({ ...value, announcements: value.announcements.map(item => item.id === id
|
|
52
|
+
? { ...item, title: clean(title, 80), message: clean(message, 2000), updatedAt: new Date().toISOString() } : item) });
|
|
53
|
+
},
|
|
54
|
+
publishDraft(id, title, message) {
|
|
55
|
+
const existing = value.announcements.find(item => item.id === id);
|
|
56
|
+
if (!existing || existing.status !== 'draft') throw new Error('Only drafts can be published');
|
|
57
|
+
const now = new Date().toISOString();
|
|
58
|
+
const published = { ...existing, title: title === undefined ? existing.title : clean(title, 80),
|
|
59
|
+
message: message === undefined ? existing.message : clean(message, 2000),
|
|
60
|
+
publishedAt: now, updatedAt: now, status: 'active' };
|
|
61
|
+
return save({ ...value, announcement: publicAnnouncement(published),
|
|
62
|
+
announcements: value.announcements.map(item => item.id === id ? published : item.status === 'active'
|
|
63
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
64
|
+
},
|
|
65
|
+
withdraw(id) {
|
|
66
|
+
if (!value.announcement || value.announcement.id !== id) throw new Error('Announcement is no longer active');
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
return save({ ...value, announcement: null, announcements: value.announcements.map(item => item.status === 'active'
|
|
69
|
+
? { ...item, status: 'withdrawn', updatedAt: now } : item) });
|
|
70
|
+
},
|
|
71
|
+
deleteAnnouncement(id) {
|
|
72
|
+
if (typeof id !== 'string' || !value.announcements.some(item => item.id === id)) throw new Error('Announcement not found');
|
|
73
|
+
if (value.announcement?.id === id) throw new Error('Withdraw the current announcement before deleting it');
|
|
74
|
+
return save({ ...value, announcements: value.announcements.filter(item => item.id !== id) });
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function validate(source) {
|
|
80
|
+
if (!source || ![1, 2, 3].includes(source.version) || !Number.isSafeInteger(source.maxRelaySessions) ||
|
|
81
|
+
source.maxRelaySessions < 1 || source.maxRelaySessions > 256) throw new Error('Invalid relay admin state');
|
|
82
|
+
const announcement = source.announcement == null ? null : validateAnnouncement(source.announcement);
|
|
83
|
+
const announcements = source.version === 1 ? (announcement ? [announcement] : []) : source.announcements;
|
|
84
|
+
if (!Array.isArray(announcements)) throw new Error('Invalid relay announcement history');
|
|
85
|
+
const validated = source.version === 3 ? announcements.map(validateRecord) : announcements.map(item => {
|
|
86
|
+
const old = validateAnnouncement(item);
|
|
87
|
+
return { ...old, createdAt: old.publishedAt, updatedAt: old.publishedAt,
|
|
88
|
+
status: old.id === announcement?.id ? 'active' : 'withdrawn' };
|
|
89
|
+
});
|
|
90
|
+
const active = validated.filter(item => item.status === 'active');
|
|
91
|
+
if (new Set(validated.map(item => item.id)).size !== validated.length || active.length > 1 ||
|
|
92
|
+
(active.length === 0) !== (announcement === null) ||
|
|
93
|
+
(announcement && !active.some(item => item.id === announcement.id &&
|
|
94
|
+
item.title === announcement.title && item.message === announcement.message && item.publishedAt === announcement.publishedAt))) {
|
|
95
|
+
throw new Error('Invalid relay announcement history');
|
|
96
|
+
}
|
|
97
|
+
return { version: 3, maxRelaySessions: source.maxRelaySessions, announcement, announcements: validated };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function validateAnnouncement(item) {
|
|
101
|
+
if (!item || !/^[\w-]{16,128}$/.test(item.id) || typeof item.publishedAt !== 'string' ||
|
|
102
|
+
!Number.isFinite(Date.parse(item.publishedAt))) throw new Error('Invalid relay announcement');
|
|
103
|
+
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000), publishedAt: item.publishedAt };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function validateRecord(item) {
|
|
107
|
+
if (!item || !/^[\w-]{16,128}$/.test(item.id) || !['draft', 'active', 'withdrawn'].includes(item.status) ||
|
|
108
|
+
typeof item.createdAt !== 'string' || !Number.isFinite(Date.parse(item.createdAt)) ||
|
|
109
|
+
typeof item.updatedAt !== 'string' || !Number.isFinite(Date.parse(item.updatedAt)) ||
|
|
110
|
+
(item.status === 'draft' ? item.publishedAt !== null :
|
|
111
|
+
typeof item.publishedAt !== 'string' || !Number.isFinite(Date.parse(item.publishedAt)))) {
|
|
112
|
+
throw new Error('Invalid relay announcement record');
|
|
113
|
+
}
|
|
114
|
+
return { id: item.id, title: clean(item.title, 80), message: clean(item.message, 2000),
|
|
115
|
+
createdAt: item.createdAt, updatedAt: item.updatedAt, publishedAt: item.publishedAt, status: item.status };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function publicAnnouncement(record) {
|
|
119
|
+
return { id: record.id, title: record.title, message: record.message, publishedAt: record.publishedAt };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clean(value, limit) {
|
|
123
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > limit || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) {
|
|
124
|
+
throw new Error('Invalid announcement text');
|
|
125
|
+
}
|
|
126
|
+
return value.trim();
|
|
127
|
+
}
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
// Shared semantic resources for the standalone relay management pages.
|
|
2
|
-
export const adminUiTokens = `:root{
|
|
3
|
-
--admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
|
|
4
|
-
--admin-surface:#ffffffee;--admin-row:#f8faf8;
|
|
5
|
-
--admin-border:#d5ddd8;--admin-row-border:#dce5df;
|
|
6
|
-
--admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
|
|
7
|
-
--admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
|
|
8
|
-
--admin-card-padding:22px;
|
|
9
|
-
--admin-footer-caption:12px;--admin-footer-opacity:0.75;--admin-footer-space:24px;
|
|
10
|
-
}`;
|
|
11
|
-
|
|
12
|
-
export const adminFooterStyles = `body{display:flex;flex-direction:column}
|
|
13
|
-
body>main{width:100%;flex:1;margin:0 auto}
|
|
14
|
-
.admin-footer{flex-shrink:0;padding:0 var(--admin-footer-space) var(--admin-footer-space);text-align:center;color:var(--admin-muted);font-size:var(--admin-footer-caption);opacity:var(--admin-footer-opacity)}`;
|
|
1
|
+
// Shared semantic resources for the standalone relay management pages.
|
|
2
|
+
export const adminUiTokens = `:root{
|
|
3
|
+
--admin-page:#f5f7fa;--admin-text:#26332b;--admin-muted:#607168;
|
|
4
|
+
--admin-surface:#ffffffee;--admin-row:#f8faf8;
|
|
5
|
+
--admin-border:#d5ddd8;--admin-row-border:#dce5df;
|
|
6
|
+
--admin-accent:#286e43;--admin-accent-surface:#e0f0e5;
|
|
7
|
+
--admin-error:#a5443b;--admin-card-radius:16px;--admin-control-radius:9px;
|
|
8
|
+
--admin-card-padding:22px;
|
|
9
|
+
--admin-footer-caption:12px;--admin-footer-opacity:0.75;--admin-footer-space:24px;
|
|
10
|
+
}`;
|
|
11
|
+
|
|
12
|
+
export const adminFooterStyles = `body{display:flex;flex-direction:column}
|
|
13
|
+
body>main{width:100%;flex:1;margin:0 auto}
|
|
14
|
+
.admin-footer{flex-shrink:0;padding:0 var(--admin-footer-space) var(--admin-footer-space);text-align:center;color:var(--admin-muted);font-size:var(--admin-footer-caption);opacity:var(--admin-footer-opacity)}`;
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
|
|
3
|
-
const prefix = '/code-relax/android/';
|
|
4
|
-
const webOrigin = 'https://www.coderelax.cn';
|
|
5
|
-
|
|
6
|
-
export function readAndroidCatalog(file) {
|
|
7
|
-
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
8
|
-
if (!Array.isArray(data.releases) || !data.releases.length) throw new Error('No Android releases');
|
|
9
|
-
const versions = new Set();
|
|
10
|
-
for (const item of data.releases) {
|
|
11
|
-
if (!Number.isSafeInteger(item.versionCode) || item.versionCode < 1 || item.versionCode > 2100000000
|
|
12
|
-
|| versions.has(item.versionCode) || typeof item.versionName !== 'string' || !/^\d+\.\d+\.\d+\.\d+$/.test(item.versionName)
|
|
13
|
-
|| !item.versionName.endsWith('.' + item.versionCode)
|
|
14
|
-
|| !Number.isSafeInteger(item.size) || item.size <= 0 || !/^[a-f0-9]{64}$/.test(item.sha256)
|
|
15
|
-
|| typeof item.objectKey !== 'string' || !/^code-relax\/android\/\d+\/[A-Za-z0-9._-]+\.apk$/.test(item.objectKey)
|
|
16
|
-
|| typeof item.notes !== 'string' || item.notes.length > 10000) throw new Error('Invalid Android release catalog');
|
|
17
|
-
versions.add(item.versionCode);
|
|
18
|
-
}
|
|
19
|
-
if (!versions.has(data.latestVersionCode)) throw new Error('Latest Android release is missing');
|
|
20
|
-
return data;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function createAndroidReleaseHandler({ catalogFile, signDownload, log = () => {} }) {
|
|
24
|
-
return async function handle(request, response, target) {
|
|
25
|
-
if (!target.pathname.startsWith(prefix)) return false;
|
|
26
|
-
const headers = { 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8',
|
|
27
|
-
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
28
|
-
'Access-Control-Allow-Origin': webOrigin, Vary: 'Origin' };
|
|
29
|
-
const reply = (code, data) => { response.writeHead(code, headers); response.end(JSON.stringify(data)); };
|
|
30
|
-
if (request.method !== 'GET') { reply(405, { error: 'Method not allowed' }); return true; }
|
|
31
|
-
if (![prefix + 'latest.json', prefix + 'download'].includes(target.pathname)) {
|
|
32
|
-
reply(404, { error: 'Not found' }); return true;
|
|
33
|
-
}
|
|
34
|
-
try {
|
|
35
|
-
if (!catalogFile || !signDownload) { reply(503, { error: 'Android release is not available' }); return true; }
|
|
36
|
-
const catalog = readAndroidCatalog(catalogFile);
|
|
37
|
-
const requested = target.searchParams.get('versionCode');
|
|
38
|
-
if (requested !== null && !/^[1-9]\d{0,9}$/.test(requested)) { reply(400, { error: 'Invalid version' }); return true; }
|
|
39
|
-
const code = target.pathname.endsWith('/latest.json') || requested === null ? catalog.latestVersionCode : Number(requested);
|
|
40
|
-
const release = catalog.releases.find(item => item.versionCode === code);
|
|
41
|
-
if (!release) { reply(404, { error: 'Release unavailable; check for updates again' }); return true; }
|
|
42
|
-
const { versionCode, versionName, size, sha256, notes } = release;
|
|
43
|
-
if (target.pathname.endsWith('/latest.json')) {
|
|
44
|
-
reply(200, { versionCode, versionName, size, sha256, notes }); return true;
|
|
45
|
-
}
|
|
46
|
-
// Sign only an explicitly published object, never a caller-supplied key or URL.
|
|
47
|
-
const ticket = await signDownload(release);
|
|
48
|
-
if (!ticket || new URL(ticket.url).protocol !== 'https:' || !Number.isSafeInteger(ticket.expiresAt) || ticket.expiresAt <= Date.now()) {
|
|
49
|
-
throw new Error('Invalid download ticket');
|
|
50
|
-
}
|
|
51
|
-
if (target.searchParams.get('redirect') === '1') {
|
|
52
|
-
response.writeHead(302, { ...headers, Location: ticket.url }); response.end();
|
|
53
|
-
} else reply(200, { versionCode, url: ticket.url, expiresAt: ticket.expiresAt });
|
|
54
|
-
} catch (error) {
|
|
55
|
-
// Provider errors may contain signed URLs or credentials. Never return or log them.
|
|
56
|
-
log({ event: 'android-release-error', type: error?.code === 'ENOENT' ? 'not-published' : 'configuration-or-storage' });
|
|
57
|
-
reply(503, { error: 'Android release is temporarily unavailable' });
|
|
58
|
-
}
|
|
59
|
-
return true;
|
|
60
|
-
};
|
|
61
|
-
}
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
const prefix = '/code-relax/android/';
|
|
4
|
+
const webOrigin = 'https://www.coderelax.cn';
|
|
5
|
+
|
|
6
|
+
export function readAndroidCatalog(file) {
|
|
7
|
+
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
8
|
+
if (!Array.isArray(data.releases) || !data.releases.length) throw new Error('No Android releases');
|
|
9
|
+
const versions = new Set();
|
|
10
|
+
for (const item of data.releases) {
|
|
11
|
+
if (!Number.isSafeInteger(item.versionCode) || item.versionCode < 1 || item.versionCode > 2100000000
|
|
12
|
+
|| versions.has(item.versionCode) || typeof item.versionName !== 'string' || !/^\d+\.\d+\.\d+\.\d+$/.test(item.versionName)
|
|
13
|
+
|| !item.versionName.endsWith('.' + item.versionCode)
|
|
14
|
+
|| !Number.isSafeInteger(item.size) || item.size <= 0 || !/^[a-f0-9]{64}$/.test(item.sha256)
|
|
15
|
+
|| typeof item.objectKey !== 'string' || !/^code-relax\/android\/\d+\/[A-Za-z0-9._-]+\.apk$/.test(item.objectKey)
|
|
16
|
+
|| typeof item.notes !== 'string' || item.notes.length > 10000) throw new Error('Invalid Android release catalog');
|
|
17
|
+
versions.add(item.versionCode);
|
|
18
|
+
}
|
|
19
|
+
if (!versions.has(data.latestVersionCode)) throw new Error('Latest Android release is missing');
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createAndroidReleaseHandler({ catalogFile, signDownload, log = () => {} }) {
|
|
24
|
+
return async function handle(request, response, target) {
|
|
25
|
+
if (!target.pathname.startsWith(prefix)) return false;
|
|
26
|
+
const headers = { 'Cache-Control': 'no-store', 'Content-Type': 'application/json; charset=utf-8',
|
|
27
|
+
'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer',
|
|
28
|
+
'Access-Control-Allow-Origin': webOrigin, Vary: 'Origin' };
|
|
29
|
+
const reply = (code, data) => { response.writeHead(code, headers); response.end(JSON.stringify(data)); };
|
|
30
|
+
if (request.method !== 'GET') { reply(405, { error: 'Method not allowed' }); return true; }
|
|
31
|
+
if (![prefix + 'latest.json', prefix + 'download'].includes(target.pathname)) {
|
|
32
|
+
reply(404, { error: 'Not found' }); return true;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
if (!catalogFile || !signDownload) { reply(503, { error: 'Android release is not available' }); return true; }
|
|
36
|
+
const catalog = readAndroidCatalog(catalogFile);
|
|
37
|
+
const requested = target.searchParams.get('versionCode');
|
|
38
|
+
if (requested !== null && !/^[1-9]\d{0,9}$/.test(requested)) { reply(400, { error: 'Invalid version' }); return true; }
|
|
39
|
+
const code = target.pathname.endsWith('/latest.json') || requested === null ? catalog.latestVersionCode : Number(requested);
|
|
40
|
+
const release = catalog.releases.find(item => item.versionCode === code);
|
|
41
|
+
if (!release) { reply(404, { error: 'Release unavailable; check for updates again' }); return true; }
|
|
42
|
+
const { versionCode, versionName, size, sha256, notes } = release;
|
|
43
|
+
if (target.pathname.endsWith('/latest.json')) {
|
|
44
|
+
reply(200, { versionCode, versionName, size, sha256, notes }); return true;
|
|
45
|
+
}
|
|
46
|
+
// Sign only an explicitly published object, never a caller-supplied key or URL.
|
|
47
|
+
const ticket = await signDownload(release);
|
|
48
|
+
if (!ticket || new URL(ticket.url).protocol !== 'https:' || !Number.isSafeInteger(ticket.expiresAt) || ticket.expiresAt <= Date.now()) {
|
|
49
|
+
throw new Error('Invalid download ticket');
|
|
50
|
+
}
|
|
51
|
+
if (target.searchParams.get('redirect') === '1') {
|
|
52
|
+
response.writeHead(302, { ...headers, Location: ticket.url }); response.end();
|
|
53
|
+
} else reply(200, { versionCode, url: ticket.url, expiresAt: ticket.expiresAt });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// Provider errors may contain signed URLs or credentials. Never return or log them.
|
|
56
|
+
log({ event: 'android-release-error', type: error?.code === 'ENOENT' ? 'not-published' : 'configuration-or-storage' });
|
|
57
|
+
reply(503, { error: 'Android release is temporarily unavailable' });
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -1,61 +1,61 @@
|
|
|
1
|
-
import { EventEmitter } from 'node:events';
|
|
2
|
-
import WebSocket from 'ws';
|
|
3
|
-
import { decode, relayAddress, send } from './wire.mjs';
|
|
4
|
-
|
|
5
|
-
// Headless mobile-side adapter for the local validation. No OpenAI credentials.
|
|
6
|
-
export function connectRelayClient({ relayUrl, room, code, credential, clientId, accountToken, observe = false }) {
|
|
7
|
-
relayAddress(relayUrl);
|
|
8
|
-
const events = new EventEmitter(), pending = new Map();
|
|
9
|
-
const socket = new WebSocket(relayUrl, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: true });
|
|
10
|
-
let serial = 0, connected = false, pairedCredential = credential, pairedClientId = clientId;
|
|
11
|
-
const ready = new Promise((resolve, reject) => {
|
|
12
|
-
const timer = setTimeout(() => { reject(new Error('Relay connection timeout')); socket.terminate(); }, 7000);
|
|
13
|
-
socket.on('open', () => send(socket, observe ? { type: 'observe', room, credential, clientId, accountToken }
|
|
14
|
-
: code ? { type: 'pair', room, code, credential, clientId, accountToken } : { type: 'resume', room, credential, clientId, accountToken }));
|
|
15
|
-
socket.on('error', error => { reject(error); events.emit('diagnostic', error.message); });
|
|
16
|
-
socket.on('close', () => {
|
|
17
|
-
clearTimeout(timer); connected = false; reject(new Error('Relay connection closed'));
|
|
18
|
-
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('连接断开,操作结果未确认,不自动重发')); }
|
|
19
|
-
pending.clear(); events.emit('disconnected');
|
|
20
|
-
});
|
|
21
|
-
socket.on('message', (bytes, binary) => {
|
|
22
|
-
try {
|
|
23
|
-
const message = decode(bytes, binary);
|
|
24
|
-
if (message.type === 'paired') { if (message.credential) pairedCredential = message.credential; if (message.clientId) pairedClientId = message.clientId; }
|
|
25
|
-
else if (message.type === 'observing') { clearTimeout(timer); connected = true; events.emit('observing', message); resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
26
|
-
else if (message.type === 'computer-info') events.emit('computer-info', { hostName: message.hostName });
|
|
27
|
-
else if (message.type === 'lan-candidates') events.emit('lan-candidates', message.candidates);
|
|
28
|
-
else if (message.type === 'official-computer') events.emit('official-computer', message.computer);
|
|
29
|
-
else if (message.type === 'official-computer-saved') events.emit('official-computer-saved', message.computer);
|
|
30
|
-
else if (message.type === 'announcement') events.emit('announcement', message.announcement);
|
|
31
|
-
else if (message.type === 'ready') { clearTimeout(timer); connected = true; resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
32
|
-
else if (message.type === 'data') {
|
|
33
|
-
const body = decode(message.payload, false), request = pending.get(body.id);
|
|
34
|
-
if (body.type === 'response' && request) { clearTimeout(request.timer); pending.delete(body.id); request.resolve(body); }
|
|
35
|
-
else events.emit('message', body);
|
|
36
|
-
} else throw new Error('Invalid relay message');
|
|
37
|
-
} catch (error) { reject(error); events.emit('diagnostic', error.message); socket.close(1008, 'Invalid response'); }
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
return {
|
|
41
|
-
ready, events,
|
|
42
|
-
request(path, { method = 'GET', body, timeoutMs = 5000 } = {}) {
|
|
43
|
-
if (!connected) return Promise.reject(new Error('Relay not ready'));
|
|
44
|
-
const id = ++serial;
|
|
45
|
-
return new Promise((resolve, reject) => {
|
|
46
|
-
const timer = setTimeout(() => { pending.delete(id); reject(new Error('请求超时,结果未确认,不自动重发')); }, timeoutMs);
|
|
47
|
-
pending.set(id, { resolve, reject, timer });
|
|
48
|
-
if (!send(socket, { type: 'data', payload: JSON.stringify({ type: 'request', id, path, method, ...(body ? { body } : {}) }) })) {
|
|
49
|
-
clearTimeout(timer); pending.delete(id); reject(new Error('Relay not connected'));
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
},
|
|
53
|
-
send(message) { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'data', payload: JSON.stringify(message) }); },
|
|
54
|
-
routeDirect() { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'p2p-route', direct: 1 }); },
|
|
55
|
-
recordOfficialComputer(computer) {
|
|
56
|
-
if (!connected || !observe) throw new Error('Metadata channel not ready');
|
|
57
|
-
return send(socket, { type: 'official-computer', ...computer });
|
|
58
|
-
},
|
|
59
|
-
close() { socket.terminate(); },
|
|
60
|
-
};
|
|
61
|
-
}
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import WebSocket from 'ws';
|
|
3
|
+
import { decode, relayAddress, send } from './wire.mjs';
|
|
4
|
+
|
|
5
|
+
// Headless mobile-side adapter for the local validation. No OpenAI credentials.
|
|
6
|
+
export function connectRelayClient({ relayUrl, room, code, credential, clientId, accountToken, observe = false }) {
|
|
7
|
+
relayAddress(relayUrl);
|
|
8
|
+
const events = new EventEmitter(), pending = new Map();
|
|
9
|
+
const socket = new WebSocket(relayUrl, { maxPayload: 64 * 1024 * 1024, perMessageDeflate: true });
|
|
10
|
+
let serial = 0, connected = false, pairedCredential = credential, pairedClientId = clientId;
|
|
11
|
+
const ready = new Promise((resolve, reject) => {
|
|
12
|
+
const timer = setTimeout(() => { reject(new Error('Relay connection timeout')); socket.terminate(); }, 7000);
|
|
13
|
+
socket.on('open', () => send(socket, observe ? { type: 'observe', room, credential, clientId, accountToken }
|
|
14
|
+
: code ? { type: 'pair', room, code, credential, clientId, accountToken } : { type: 'resume', room, credential, clientId, accountToken }));
|
|
15
|
+
socket.on('error', error => { reject(error); events.emit('diagnostic', error.message); });
|
|
16
|
+
socket.on('close', () => {
|
|
17
|
+
clearTimeout(timer); connected = false; reject(new Error('Relay connection closed'));
|
|
18
|
+
for (const request of pending.values()) { clearTimeout(request.timer); request.reject(new Error('连接断开,操作结果未确认,不自动重发')); }
|
|
19
|
+
pending.clear(); events.emit('disconnected');
|
|
20
|
+
});
|
|
21
|
+
socket.on('message', (bytes, binary) => {
|
|
22
|
+
try {
|
|
23
|
+
const message = decode(bytes, binary);
|
|
24
|
+
if (message.type === 'paired') { if (message.credential) pairedCredential = message.credential; if (message.clientId) pairedClientId = message.clientId; }
|
|
25
|
+
else if (message.type === 'observing') { clearTimeout(timer); connected = true; events.emit('observing', message); resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
26
|
+
else if (message.type === 'computer-info') events.emit('computer-info', { hostName: message.hostName });
|
|
27
|
+
else if (message.type === 'lan-candidates') events.emit('lan-candidates', message.candidates);
|
|
28
|
+
else if (message.type === 'official-computer') events.emit('official-computer', message.computer);
|
|
29
|
+
else if (message.type === 'official-computer-saved') events.emit('official-computer-saved', message.computer);
|
|
30
|
+
else if (message.type === 'announcement') events.emit('announcement', message.announcement);
|
|
31
|
+
else if (message.type === 'ready') { clearTimeout(timer); connected = true; resolve({ room, credential: pairedCredential, clientId: pairedClientId }); }
|
|
32
|
+
else if (message.type === 'data') {
|
|
33
|
+
const body = decode(message.payload, false), request = pending.get(body.id);
|
|
34
|
+
if (body.type === 'response' && request) { clearTimeout(request.timer); pending.delete(body.id); request.resolve(body); }
|
|
35
|
+
else events.emit('message', body);
|
|
36
|
+
} else throw new Error('Invalid relay message');
|
|
37
|
+
} catch (error) { reject(error); events.emit('diagnostic', error.message); socket.close(1008, 'Invalid response'); }
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
ready, events,
|
|
42
|
+
request(path, { method = 'GET', body, timeoutMs = 5000 } = {}) {
|
|
43
|
+
if (!connected) return Promise.reject(new Error('Relay not ready'));
|
|
44
|
+
const id = ++serial;
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
const timer = setTimeout(() => { pending.delete(id); reject(new Error('请求超时,结果未确认,不自动重发')); }, timeoutMs);
|
|
47
|
+
pending.set(id, { resolve, reject, timer });
|
|
48
|
+
if (!send(socket, { type: 'data', payload: JSON.stringify({ type: 'request', id, path, method, ...(body ? { body } : {}) }) })) {
|
|
49
|
+
clearTimeout(timer); pending.delete(id); reject(new Error('Relay not connected'));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
send(message) { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'data', payload: JSON.stringify(message) }); },
|
|
54
|
+
routeDirect() { if (!connected) throw new Error('Relay not ready'); return send(socket, { type: 'p2p-route', direct: 1 }); },
|
|
55
|
+
recordOfficialComputer(computer) {
|
|
56
|
+
if (!connected || !observe) throw new Error('Metadata channel not ready');
|
|
57
|
+
return send(socket, { type: 'official-computer', ...computer });
|
|
58
|
+
},
|
|
59
|
+
close() { socket.terminate(); },
|
|
60
|
+
};
|
|
61
|
+
}
|