@walkhi/code-relax 0.1.0-beta.31 → 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/dist/bin/publish-android-release.mjs +4 -3
- package/dist/bin/publish-harmony-release.mjs +46 -0
- package/dist/bin/release-upload-server.mjs +174 -0
- package/dist/build-platform.json +1 -1
- package/dist/shared/p2p-file-channel.cjs +10 -6
- package/dist/shared/question-attention.cjs +30 -1
- package/dist/src/self-relay/public-releases.mjs +20 -2
- package/package.json +4 -4
|
@@ -7,11 +7,12 @@ import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
|
7
7
|
import { readPublicManifest, updatePublicAndroid } from '../src/self-relay/public-releases.mjs';
|
|
8
8
|
|
|
9
9
|
// This administrative command runs only on the deployment host; no keys cross SSH.
|
|
10
|
-
const [apkFile, metadataFile] = process.argv.slice(2);
|
|
10
|
+
const [apkFile, metadataFile, mode] = process.argv.slice(2);
|
|
11
|
+
if (mode && mode !== '--existing-object') throw new Error('Invalid publication mode');
|
|
11
12
|
if (!apkFile || !metadataFile) throw new Error('Usage: publish-android-release.mjs APK METADATA');
|
|
12
13
|
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
13
14
|
const metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf8'));
|
|
14
|
-
if (metadata.packageName !== '
|
|
15
|
+
if (metadata.packageName !== 'com.walkhi.codexremote' || !/^[a-f0-9]{64}$/.test(metadata.certificateSha256)) throw new Error('Invalid APK identity');
|
|
15
16
|
const { certificateSha256: expectedCertificate } = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
16
17
|
if (!/^[a-f0-9]{64}$/.test(expectedCertificate) || metadata.certificateSha256 !== expectedCertificate) throw new Error('APK signing certificate does not match release configuration');
|
|
17
18
|
const bytes = fs.readFileSync(apkFile), sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
@@ -30,7 +31,7 @@ try {
|
|
|
30
31
|
const next = { latestVersionCode: versionCode, releases: [...catalog.releases.filter(item => item.versionCode !== versionCode), release] };
|
|
31
32
|
fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + '\n', { mode: 0o640 });
|
|
32
33
|
readAndroidCatalog(temporary);
|
|
33
|
-
if (!previous) {
|
|
34
|
+
if (!previous && mode !== '--existing-object') {
|
|
34
35
|
await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object(release), Body: bytes,
|
|
35
36
|
ContentLength: bytes.length, ContentType: 'application/vnd.android.package-archive',
|
|
36
37
|
ContentDisposition: `attachment; filename="Code-Relax-${versionName}.apk"`
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
6
|
+
import { readHarmonyConfig, hapName, validateHarmonyMetadata, checkHarmonyVersion,
|
|
7
|
+
verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
|
|
8
|
+
|
|
9
|
+
// Local PowerShell releases use the same verifier, COS objects and activation as GitHub CD.
|
|
10
|
+
const directory = path.resolve(process.argv[2] || '');
|
|
11
|
+
if (!process.argv[2]) throw Error('Usage: publish-harmony-release.mjs DIRECTORY');
|
|
12
|
+
let stage = 'metadata';
|
|
13
|
+
try {
|
|
14
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(directory, 'manifest.json5'), 'utf8')).app;
|
|
15
|
+
const config = readHarmonyConfig();
|
|
16
|
+
const metadata = { packageName: manifest.bundleName, versionCode: manifest.versionCode,
|
|
17
|
+
versionName: manifest.versionName, certificateSha256: config.certificateSha256 };
|
|
18
|
+
if (!Number.isSafeInteger(metadata.versionCode) || metadata.versionCode <= 0
|
|
19
|
+
|| !/^\d+\.\d+\.\d+$/.test(metadata.versionName)) throw Error();
|
|
20
|
+
metadata.files = [hapName(metadata), 'manifest.json5', 'icon.png'].map(name => {
|
|
21
|
+
const bytes = fs.readFileSync(path.join(directory, name));
|
|
22
|
+
return { name, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
23
|
+
contentMd5: createHash('md5').update(bytes).digest('base64') };
|
|
24
|
+
});
|
|
25
|
+
Object.assign(metadata, { size: metadata.files[0].size, sha256: metadata.files[0].sha256,
|
|
26
|
+
contentMd5: metadata.files[0].contentMd5 });
|
|
27
|
+
validateHarmonyMetadata(metadata, config);
|
|
28
|
+
checkHarmonyVersion(metadata);
|
|
29
|
+
stage = 'signature';
|
|
30
|
+
await verifyHarmony(directory, metadata, config);
|
|
31
|
+
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
32
|
+
const keys = metadata.files.map(f => `code-relax/harmony/${metadata.versionCode}/${f.name}`);
|
|
33
|
+
stage = 'upload';
|
|
34
|
+
for (const [i, file] of metadata.files.entries()) {
|
|
35
|
+
await new Promise((resolve, reject) => storage.cos.putObject({ ...storage.object({ objectKey: keys[i] }),
|
|
36
|
+
Body: fs.createReadStream(path.join(directory, file.name)), ContentLength: file.size,
|
|
37
|
+
ContentMD5: file.contentMd5, ContentType: i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream',
|
|
38
|
+
Headers: { 'x-cos-forbid-overwrite': 'true' }
|
|
39
|
+
}, (error, result) => error ? reject(error) : resolve(result)));
|
|
40
|
+
}
|
|
41
|
+
stage = 'publication';
|
|
42
|
+
console.log(JSON.stringify(await activateHarmony(metadata, storage, keys)));
|
|
43
|
+
} catch {
|
|
44
|
+
console.error(`Harmony publication failed at ${stage}; inspect objects and current version before retrying.`);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import http from 'node:http';
|
|
6
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { Transform, Readable } from 'node:stream';
|
|
9
|
+
import { execFile } from 'node:child_process';
|
|
10
|
+
import { promisify } from 'node:util';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { verifyReleaseIdentity, createUploadSigner } from '../relay-admin/release-oidc.mjs';
|
|
13
|
+
import { androidReleaseStorage } from '../relay-admin/android-release-storage.mjs';
|
|
14
|
+
import { readAndroidCatalog } from '../src/self-relay/android-releases.mjs';
|
|
15
|
+
|
|
16
|
+
import { readHarmonyConfig, validateHarmonyMetadata, checkHarmonyVersion, verifyHarmony, activateHarmony } from '../relay-admin/harmony-release.mjs';
|
|
17
|
+
|
|
18
|
+
const execute = promisify(execFile);
|
|
19
|
+
const config = JSON.parse(fs.readFileSync('/etc/codex-relay/android-releases.json', 'utf8'));
|
|
20
|
+
const credential = JSON.parse(fs.readFileSync('/etc/codex-relay/tencent-monitor.json', 'utf8'));
|
|
21
|
+
const storage = androidReleaseStorage('/etc/codex-relay/android-releases.json', '/etc/codex-relay/tencent-monitor.json');
|
|
22
|
+
const signUpload = createUploadSigner(config, { secretId: credential.secretId, secretKey: credential.secretKey });
|
|
23
|
+
const stateDir = '/var/lib/codex-relay/release-publisher';
|
|
24
|
+
fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
|
|
25
|
+
const cosCall = (method, args) => new Promise((resolve, reject) => storage.cos[method](args, (e, r) => e ? reject(e) : resolve(r)));
|
|
26
|
+
const object = key => ({ Bucket: config.bucket, Region: config.region, Key: key });
|
|
27
|
+
const identityKey = p => `${p.repository_id}/${p.run_id}/${p.run_attempt}/${p.sha}`;
|
|
28
|
+
const filename = id => path.join(stateDir, id + '.json');
|
|
29
|
+
function save(row) {
|
|
30
|
+
const temp = filename(row.intentId) + '.' + randomUUID();
|
|
31
|
+
fs.writeFileSync(temp, JSON.stringify(row), { mode: 0o600 }); fs.renameSync(temp, filename(row.intentId));
|
|
32
|
+
}
|
|
33
|
+
const read = id => fs.existsSync(filename(id)) ? JSON.parse(fs.readFileSync(filename(id))) : null;
|
|
34
|
+
const publicState = row => ({ ok: true, intentId: row.intentId, status: row.status, result: row.result, error: row.error });
|
|
35
|
+
async function readJson(req) {
|
|
36
|
+
const chunks = []; let size = 0;
|
|
37
|
+
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error('Request too large'); chunks.push(chunk); }
|
|
38
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
39
|
+
}
|
|
40
|
+
function metadataFrom(data, claims, target) {
|
|
41
|
+
const { packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
42
|
+
buildCommit, artifactId, runId, sourceCommit, verifyOnly } = data;
|
|
43
|
+
if (!Number.isSafeInteger(versionCode) || versionCode < 1 || versionCode > 2100000000
|
|
44
|
+
|| !/^[a-f0-9]{64}$/.test(sha256)
|
|
45
|
+
|| !Number.isSafeInteger(size) || size < 1 || size > 200 * 1024 * 1024
|
|
46
|
+
|| typeof notes !== 'string' || notes.length > 2000 || !/^[A-Za-z0-9+/]{22}==$/.test(contentMd5)
|
|
47
|
+
|| !/^\d+$/.test(artifactId) || runId !== claims.run_id || sourceCommit !== claims.sha
|
|
48
|
+
|| typeof verifyOnly !== 'boolean' || !/^[a-f0-9]{40}$/.test(buildCommit)) throw Error('Invalid metadata');
|
|
49
|
+
if (target === 'hap') validateHarmonyMetadata(data, readHarmonyConfig());
|
|
50
|
+
else if (packageName !== 'com.walkhi.codexremote' || !/^\d+\.\d+\.\d+\.\d+$/.test(versionName)
|
|
51
|
+
|| !versionName.endsWith('.' + versionCode) || certificateSha256 !== config.certificateSha256) throw Error('Invalid Android identity');
|
|
52
|
+
return { target, ...(target === 'hap' ? { files: data.files } : {}), packageName, versionCode, versionName, size, sha256, contentMd5, certificateSha256, notes,
|
|
53
|
+
buildCommit, artifactId, runId, sourceCommit, verifyOnly };
|
|
54
|
+
}
|
|
55
|
+
let busy = false;
|
|
56
|
+
const server = http.createServer(async (req, res) => {
|
|
57
|
+
const reply = (code, data) => { if (!res.destroyed) { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(data)); } };
|
|
58
|
+
let claims;
|
|
59
|
+
try { claims = await verifyReleaseIdentity((req.headers.authorization || '').replace(/^Bearer /, '')); }
|
|
60
|
+
catch { reply(401, { ok: false, error: 'OIDC_REJECTED' }); return; }
|
|
61
|
+
const target = /^\/code-relax\/publish\/(android|hap)\/intents(?:\/|$)/.exec(req.url || '')?.[1];
|
|
62
|
+
if (!target) { reply(404, { ok: false, error: 'NOT_FOUND' }); return; }
|
|
63
|
+
const prefix = `/code-relax/publish/${target}/intents`;
|
|
64
|
+
try {
|
|
65
|
+
if (req.method === 'POST' && req.url === prefix) {
|
|
66
|
+
let metadata;
|
|
67
|
+
try { metadata = metadataFrom(await readJson(req), claims, target); } catch { reply(400, { ok: false, error: 'INVALID_RELEASE' }); return; }
|
|
68
|
+
if (!metadata.verifyOnly && target === 'hap') {
|
|
69
|
+
try { checkHarmonyVersion(metadata); } catch { reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return; }
|
|
70
|
+
}
|
|
71
|
+
if (!metadata.verifyOnly && target === 'android') {
|
|
72
|
+
const catalog = readAndroidCatalog(config.catalogFile);
|
|
73
|
+
const previous = catalog.releases.find(x => x.versionCode === metadata.versionCode);
|
|
74
|
+
if (catalog.latestVersionCode > metadata.versionCode || (previous && previous.sha256 !== metadata.sha256)) {
|
|
75
|
+
reply(409, { ok: false, error: 'VERSION_CONFLICT' }); return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const identity = identityKey(claims);
|
|
79
|
+
const intentId = createHash('sha256').update(JSON.stringify({ identity, metadata })).digest('hex');
|
|
80
|
+
let row = read(intentId);
|
|
81
|
+
if (row && row.status !== 'prepared') { reply(200, publicState(row)); return; }
|
|
82
|
+
const key = metadata.verifyOnly ? `code-relax/android/ci-verification/${claims.run_id}/${intentId}.apk`
|
|
83
|
+
: `code-relax/android/${metadata.versionCode}/Code-Relax-${metadata.sha256.slice(0, 16)}.apk`;
|
|
84
|
+
const keys = target === 'hap' ? metadata.files.map(f => metadata.verifyOnly
|
|
85
|
+
? `code-relax/harmony/ci-verification/${claims.run_id}/${intentId}/${f.name}`
|
|
86
|
+
: `code-relax/harmony/${metadata.versionCode}/${f.name}`) : [key];
|
|
87
|
+
row ??= { intentId, identity, metadata, key, keys, status: 'prepared' };
|
|
88
|
+
save(row);
|
|
89
|
+
const grant = target === 'hap' ? { uploads: await Promise.all(metadata.files.map(async (f, i) => ({ name: f.name,
|
|
90
|
+
...await signUpload(keys[i], f, i === 1 ? 'application/json' : i === 2 ? 'image/png' : 'application/octet-stream') }))) }
|
|
91
|
+
: await signUpload(key, metadata);
|
|
92
|
+
reply(200, { ...publicState(row), ...grant }); return;
|
|
93
|
+
}
|
|
94
|
+
const match = new RegExp(`^${prefix}/([a-f0-9]{64})(/complete)?$`).exec(req.url || '');
|
|
95
|
+
if (!match || !((!match[2] && req.method === 'GET') || (match[2] && req.method === 'POST'))) {
|
|
96
|
+
reply(404, { ok: false, error: 'NOT_FOUND' }); return;
|
|
97
|
+
}
|
|
98
|
+
const row = read(match[1]);
|
|
99
|
+
if (!row || row.identity !== identityKey(claims) || (row.metadata.target || 'android') !== target) { reply(404, { ok: false, error: 'INTENT_NOT_FOUND' }); return; }
|
|
100
|
+
if (match[2] && row.status === 'prepared') {
|
|
101
|
+
if (busy) { reply(409, { ok: false, error: 'PUBLICATION_BUSY' }); return; }
|
|
102
|
+
busy = true; row.status = 'validating'; save(row);
|
|
103
|
+
void complete(row).finally(() => { busy = false; });
|
|
104
|
+
}
|
|
105
|
+
reply(row.status === 'validating' ? 202 : 200, publicState(row));
|
|
106
|
+
} catch (error) {
|
|
107
|
+
console.error(JSON.stringify({ event: 'release-api-failed', code: /^[A-Za-z.]+$/.test(error.code || '') ? error.code : 'io-or-provider' }));
|
|
108
|
+
reply(503, { ok: false, error: 'RELEASE_UNAVAILABLE' });
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
async function complete(row) {
|
|
112
|
+
const { metadata, key } = row;
|
|
113
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'code-relax-release-'));
|
|
114
|
+
const apk = path.join(dir, 'release.apk'), json = path.join(dir, 'release.json');
|
|
115
|
+
const hap = metadata.target === 'hap';
|
|
116
|
+
const files = hap ? metadata.files : [{ ...metadata, name: 'release.apk' }];
|
|
117
|
+
let stage = 'download';
|
|
118
|
+
try {
|
|
119
|
+
for (const [i, file] of files.entries()) {
|
|
120
|
+
const ticket = await cosCall('getObjectUrl', { ...object(row.keys?.[i] || key), Sign: true, Method: 'GET', Protocol: 'https:', Expires: 300 });
|
|
121
|
+
const response = await fetch(ticket.Url, { signal: AbortSignal.timeout(120000) });
|
|
122
|
+
if (!response.ok || Number(response.headers.get('content-length')) !== file.size) throw Error();
|
|
123
|
+
let size = 0; const hash = createHash('sha256'), md5 = createHash('md5');
|
|
124
|
+
await pipeline(Readable.fromWeb(response.body), new Transform({ transform(chunk, encoding, next) {
|
|
125
|
+
size += chunk.length; if (size > file.size) return next(Error('Oversize'));
|
|
126
|
+
hash.update(chunk); md5.update(chunk); next(null, chunk);
|
|
127
|
+
} }), fs.createWriteStream(path.join(dir, file.name), { flags: 'wx', mode: 0o600 }));
|
|
128
|
+
if (size !== file.size || hash.digest('hex') !== file.sha256 || md5.digest('base64') !== file.contentMd5) throw Error();
|
|
129
|
+
const unsigned = new URL(ticket.Url); unsigned.search = '';
|
|
130
|
+
if (!hap && (await fetch(unsigned, { method: 'HEAD', signal: AbortSignal.timeout(15000) })).status !== 403) throw Error();
|
|
131
|
+
}
|
|
132
|
+
if (hap) {
|
|
133
|
+
stage = 'harmony-signature';
|
|
134
|
+
await verifyHarmony(dir, metadata, readHarmonyConfig());
|
|
135
|
+
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
136
|
+
if (metadata.verifyOnly) row.status = 'verified';
|
|
137
|
+
else { stage = 'harmony-publish'; row.result = await activateHarmony(metadata, storage, row.keys); row.status = 'published'; }
|
|
138
|
+
save(row); return;
|
|
139
|
+
}
|
|
140
|
+
stage = 'signature';
|
|
141
|
+
const { stdout: signature } = await execute('/usr/bin/apksigner', ['verify', '--print-certs', apk], {
|
|
142
|
+
timeout: 30000, env: { ...process.env, JAVA_TOOL_OPTIONS: '-Xmx256m' } });
|
|
143
|
+
const certificates = [...signature.matchAll(/^Signer #\d+ certificate SHA-256 digest: ([a-f0-9]{64})$/gm)];
|
|
144
|
+
if (certificates.length !== 1 || certificates[0][1] !== metadata.certificateSha256) throw Error();
|
|
145
|
+
stage = 'identity';
|
|
146
|
+
const { stdout: badging } = await execute('/usr/bin/aapt', ['dump', 'badging', apk], { timeout: 30000 });
|
|
147
|
+
const identity = /^package: name='([^']+)' versionCode='(\d+)' versionName='([^']+)'/m.exec(badging);
|
|
148
|
+
if (!identity || identity[1] !== metadata.packageName || Number(identity[2]) !== metadata.versionCode
|
|
149
|
+
|| identity[3] !== metadata.versionName || /^application-debuggable/m.test(badging)) throw Error();
|
|
150
|
+
row.result = { sha256: metadata.sha256, versionCode: metadata.versionCode, size: metadata.size };
|
|
151
|
+
if (metadata.verifyOnly) row.status = 'verified';
|
|
152
|
+
else {
|
|
153
|
+
stage = 'publish';
|
|
154
|
+
fs.writeFileSync(json, JSON.stringify(metadata), { mode: 0o600 });
|
|
155
|
+
const { stdout } = await execute(process.execPath, [fileURLToPath(new URL('./publish-android-release.mjs', import.meta.url)), apk, json, '--existing-object'], { timeout: 240000 });
|
|
156
|
+
row.result = JSON.parse(stdout.trim()); row.status = 'published';
|
|
157
|
+
}
|
|
158
|
+
save(row);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
row.status = 'failed'; row.error = `RELEASE_${stage.toUpperCase()}_FAILED`; save(row);
|
|
161
|
+
console.error(JSON.stringify({ event: 'release-validation-failed', intentId: row.intentId, stage, code: /^[A-Za-z0-9_.-]+$/.test(error.code || '') ? error.code : 'validation' }));
|
|
162
|
+
} finally {
|
|
163
|
+
for (const file of [...files.map(f => path.join(dir, f.name)), json]) fs.rmSync(file, { force: true }); fs.rmdirSync(dir);
|
|
164
|
+
// Verification objects remain private evidence; the deployment identity has no DeleteObject grant.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// A restart never blindly launches an interrupted publisher a second time.
|
|
168
|
+
for (const name of fs.readdirSync(stateDir).filter(x => /^[a-f0-9]{64}\.json$/.test(x))) {
|
|
169
|
+
const row = read(name.slice(0, -5));
|
|
170
|
+
if (row.status === 'validating') { row.status = 'failed'; row.error = 'INTERRUPTED_CHECK_LATEST'; save(row); }
|
|
171
|
+
}
|
|
172
|
+
server.requestTimeout = 15000;
|
|
173
|
+
server.listen(45852, '127.0.0.1');
|
|
174
|
+
process.on('SIGTERM', () => { server.close(); const timer = setInterval(() => { if (!busy) { clearInterval(timer); process.exit(0); } }, 250); });
|
package/dist/build-platform.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"layout":"npm-platform-packages","version":"0.1.0-beta.
|
|
1
|
+
{"layout":"npm-platform-packages","version":"0.1.0-beta.32"}
|
|
@@ -42,7 +42,11 @@
|
|
|
42
42
|
if (this.io.send(item.frames[item.index]) === false) throw new Error('文件直连发送失败');
|
|
43
43
|
if (++item.index === item.frames.length) { this.outbound.shift(); this.queued -= item.size; }
|
|
44
44
|
}
|
|
45
|
-
} catch { this.close(1011); }
|
|
45
|
+
} catch { this.close(1011, 'send-failed'); }
|
|
46
|
+
}
|
|
47
|
+
waitForPartial() {
|
|
48
|
+
clearTimeout(this.timer);
|
|
49
|
+
this.timer = setTimeout(() => this.close(1008, 'partial-idle-timeout'), 30000);
|
|
46
50
|
}
|
|
47
51
|
receive(value) {
|
|
48
52
|
if (this.readyState !== 1) return;
|
|
@@ -53,22 +57,22 @@
|
|
|
53
57
|
if (frame.type !== 'rtc-file-bytes') { this.io.received?.(); this.emit('message', { data: value }); return; }
|
|
54
58
|
if (!Number.isSafeInteger(frame.length) || frame.length < 1 || frame.length > MAX) throw new Error();
|
|
55
59
|
this.io.received?.(); this.partial = { data: new Uint8Array(frame.length), offset: 0 };
|
|
56
|
-
this.
|
|
60
|
+
this.waitForPartial(); return;
|
|
57
61
|
}
|
|
58
62
|
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value), part = this.partial;
|
|
59
63
|
if (!part || bytes.length !== Math.min(PART, part.data.length - part.offset)) throw new Error();
|
|
60
|
-
this.io.received?.(); part.data.set(bytes, part.offset); part.offset += bytes.length;
|
|
64
|
+
this.waitForPartial(); this.io.received?.(); part.data.set(bytes, part.offset); part.offset += bytes.length;
|
|
61
65
|
if (part.offset === part.data.length) {
|
|
62
66
|
this.partial = null; clearTimeout(this.timer); this.timer = null;
|
|
63
67
|
this.emit('message', { data: part.data.buffer });
|
|
64
68
|
}
|
|
65
|
-
} catch { this.close(1008); }
|
|
69
|
+
} catch { this.close(1008, 'invalid-frame'); }
|
|
66
70
|
}
|
|
67
|
-
close(code = 1000) {
|
|
71
|
+
close(code = 1000, reason = '') {
|
|
68
72
|
if (this.readyState === 3) return;
|
|
69
73
|
this.readyState = 3; clearTimeout(this.timer); this.timer = null;
|
|
70
74
|
this.partial = null; this.outbound = []; this.queued = 0;
|
|
71
|
-
this.io.close(); this.emit('close', { code });
|
|
75
|
+
this.io.close(); this.emit('close', { code, reason });
|
|
72
76
|
}
|
|
73
77
|
};
|
|
74
78
|
});
|
|
@@ -36,5 +36,34 @@
|
|
|
36
36
|
}
|
|
37
37
|
return null;
|
|
38
38
|
}
|
|
39
|
-
|
|
39
|
+
// A successful submission is enough to dismiss a question; model consumption
|
|
40
|
+
// can happen later. Store identities only, never answers or replayable writes.
|
|
41
|
+
function createAnswerStore({ storage, scope, onError }) {
|
|
42
|
+
const records = new Map();
|
|
43
|
+
const key = thread => 'codexRemoteAnsweredQuestions:' + JSON.stringify([
|
|
44
|
+
scope(), thread.hostId || 'local', thread.id,
|
|
45
|
+
]);
|
|
46
|
+
function read(owner) {
|
|
47
|
+
if (!records.has(owner)) {
|
|
48
|
+
try {
|
|
49
|
+
const ids = JSON.parse(storage.getItem(owner) || '[]');
|
|
50
|
+
records.set(owner, new Set(Array.isArray(ids) ? ids.filter(id => typeof id === 'string') : []));
|
|
51
|
+
} catch (error) { records.set(owner, new Set()); onError?.(error); }
|
|
52
|
+
}
|
|
53
|
+
return records.get(owner);
|
|
54
|
+
}
|
|
55
|
+
function remember(owner, ids) {
|
|
56
|
+
const answered = read(owner);
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const id of ids) if (typeof id === 'string' && !answered.has(id)) {
|
|
59
|
+
answered.add(id); changed = true;
|
|
60
|
+
}
|
|
61
|
+
if (changed) {
|
|
62
|
+
try { storage.setItem(owner, JSON.stringify([...answered])); }
|
|
63
|
+
catch (error) { onError?.(error); }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { key, has: (thread, id) => read(key(thread)).has(id), remember };
|
|
67
|
+
}
|
|
68
|
+
return { parseQuestionReply, questionIds, fromNotification, createAnswerStore };
|
|
40
69
|
});
|
|
@@ -14,16 +14,34 @@ export function updatePublicAndroid(release, file = publicManifestFile) {
|
|
|
14
14
|
|| !/^\d+\.\d+\.\d+\.\d+$/.test(release.versionName)
|
|
15
15
|
|| !Number.isSafeInteger(release.size) || release.size <= 0
|
|
16
16
|
|| !/^[a-f0-9]{64}$/.test(release.sha256)) throw new Error('Invalid public Android release');
|
|
17
|
+
return updateMobileManifest('android', release, file);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function updatePublicHarmony(release, file = publicManifestFile) {
|
|
21
|
+
if (!Number.isSafeInteger(release.versionCode) || !/^\d+\.\d+\.\d+$/.test(release.versionName)
|
|
22
|
+
|| !/^[a-f0-9]{64}$/.test(release.sha256) || !Number.isSafeInteger(release.size) || release.size <= 0
|
|
23
|
+
|| release.manifestUrl !== `https://download.coderelax.cn/code-relax/harmony/${release.versionCode}/manifest.json5`)
|
|
24
|
+
throw Error('Invalid public Harmony release');
|
|
25
|
+
return updateMobileManifest('harmonyos', release, file);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function updateMobileManifest(platform, release, file) {
|
|
17
29
|
const lock = file + '.lock', temporary = file + '.new';
|
|
18
30
|
const handle = fs.openSync(lock, 'wx', 0o600);
|
|
19
31
|
try {
|
|
20
32
|
const manifest = readPublicManifest(file);
|
|
21
|
-
if (manifest.
|
|
22
|
-
manifest
|
|
33
|
+
if (manifest[platform].versionCode > release.versionCode) throw new Error('Public Android version would go backwards');
|
|
34
|
+
manifest[platform] = { status: 'published', statusText: { zh: '', en: '' }, version: release.versionName,
|
|
23
35
|
versionCode: release.versionCode, size: release.size, sha256: release.sha256,
|
|
24
36
|
url: `https://api.coderelax.cn/code-relax/android/download?versionCode=${release.versionCode}&redirect=1`, action: 'download' };
|
|
37
|
+
if (platform === 'harmonyos') {
|
|
38
|
+
manifest.harmonyos.statusText = { zh: '指定设备测试版', en: 'Registered-device test' };
|
|
39
|
+
manifest.harmonyos.url = 'https://www.coderelax.cn/code-relax/install';
|
|
40
|
+
manifest.harmonyos.manifestUrl = release.manifestUrl;
|
|
41
|
+
}
|
|
25
42
|
const owner = fs.statSync(file);
|
|
26
43
|
fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n', { mode: owner.mode & 0o777 });
|
|
44
|
+
fs.chmodSync(temporary, owner.mode & 0o777);
|
|
27
45
|
if (process.platform !== 'win32') fs.chownSync(temporary, owner.uid, owner.gid);
|
|
28
46
|
fs.renameSync(temporary, file);
|
|
29
47
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@walkhi/code-relax",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.32",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
"tag": "beta"
|
|
42
42
|
},
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.
|
|
45
|
-
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.
|
|
46
|
-
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.
|
|
44
|
+
"@walkhi/code-relax-native-win32-x64": "0.1.0-beta.32",
|
|
45
|
+
"@walkhi/code-relax-native-darwin-arm64": "0.1.0-beta.32",
|
|
46
|
+
"@walkhi/code-relax-native-darwin-x64": "0.1.0-beta.32"
|
|
47
47
|
}
|
|
48
48
|
}
|