feat: integrate learning module
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-16 20:52:16 +08:00
parent 26b52d76e3
commit 01bee3188b
107 changed files with 6318 additions and 12063 deletions

View File

@@ -2,6 +2,13 @@ const { execFileSync } = require('node:child_process');
const { join } = require('node:path');
exports.default = async function beforePack() {
execFileSync(process.execPath, [
join(__dirname, 'verify-learning-player-artifact.mjs'),
join(__dirname, '..', 'build', 'learning-player'),
], {
cwd: join(__dirname, '..'),
stdio: 'inherit',
});
execFileSync(process.execPath, [join(__dirname, 'prepare-publish-runtime.mjs')], {
cwd: join(__dirname, '..'),
stdio: 'inherit',

View File

@@ -0,0 +1,62 @@
import { createHash } from 'node:crypto';
import { appendFile, cp, mkdir, readdir, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import AdmZip from 'adm-zip';
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
const artifactUrl = process.env.LEARNING_PLAYER_ARTIFACT_URL?.trim();
const expectedSha256 = process.env.LEARNING_PLAYER_ARTIFACT_SHA256?.trim().toLowerCase();
if (!artifactUrl || !/^https:\/\//i.test(artifactUrl)) {
throw new Error('LEARNING_PLAYER_ARTIFACT_URL must be a versioned HTTPS artifact URL');
}
if (!expectedSha256 || !/^[0-9a-f]{64}$/.test(expectedSha256)) {
throw new Error('LEARNING_PLAYER_ARTIFACT_SHA256 must be the pinned artifact digest');
}
const response = await fetch(artifactUrl, { redirect: 'follow' });
if (!response.ok) throw new Error(`Learning player download failed (HTTP ${response.status})`);
const archive = Buffer.from(await response.arrayBuffer());
const actualSha256 = createHash('sha256').update(archive).digest('hex');
if (actualSha256 !== expectedSha256) {
throw new Error(`Learning player archive digest mismatch: expected ${expectedSha256}, got ${actualSha256}`);
}
const root = resolve(import.meta.dirname, '..');
const extractionRoot = resolve(root, 'build', '.learning-player-download');
const outputRoot = resolve(root, 'build', 'learning-player-source');
await Promise.all([
rm(extractionRoot, { recursive: true, force: true }),
rm(outputRoot, { recursive: true, force: true }),
]);
await mkdir(extractionRoot, { recursive: true });
const zip = new AdmZip(archive);
for (const entry of zip.getEntries()) {
const normalized = entry.entryName.replace(/\\/g, '/');
const parts = normalized.split('/');
if (normalized.startsWith('/') || /^[A-Za-z]:\//.test(normalized) || parts.includes('..')) {
throw new Error('Learning player archive contains an unsafe path');
}
}
zip.extractAllTo(extractionRoot, true);
async function findArtifactRoots(directory, depth = 0) {
const entries = await readdir(directory, { withFileTypes: true });
if (entries.some((entry) => entry.isFile() && entry.name === 'artifact.json')) return [directory];
if (depth >= 2) return [];
const nested = await Promise.all(entries
.filter((entry) => entry.isDirectory())
.map((entry) => findArtifactRoots(resolve(directory, entry.name), depth + 1)));
return nested.flat();
}
const candidates = await findArtifactRoots(extractionRoot);
if (candidates.length !== 1) throw new Error('Learning player archive must contain exactly one artifact.json root');
await validateLearningPlayerArtifact(candidates[0]);
await cp(candidates[0], outputRoot, { recursive: true });
await validateLearningPlayerArtifact(outputRoot);
await rm(extractionRoot, { recursive: true, force: true });
if (process.env.GITHUB_ENV) {
await appendFile(process.env.GITHUB_ENV, `MAKELORE_LEARNING_PLAYER_ARTIFACT=${outputRoot}\n`);
}
process.stdout.write(`Downloaded verified learning player ${actualSha256} to ${outputRoot}\n`);

View File

@@ -0,0 +1,46 @@
import { createHash } from 'node:crypto';
import { readdir, readFile, stat } from 'node:fs/promises';
import { resolve } from 'node:path';
async function hasFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.isFile()) return true;
if (entry.isDirectory() && await hasFiles(resolve(directory, entry.name))) return true;
}
return false;
}
export async function validateLearningPlayerArtifact(directory) {
const root = resolve(directory);
const [artifactText, html] = await Promise.all([
readFile(resolve(root, 'artifact.json'), 'utf8'),
readFile(resolve(root, 'index.html')),
]).catch((error) => {
throw new Error(`Invalid learning player artifact at ${root}: ${error.message}`);
});
const artifact = JSON.parse(artifactText);
if (artifact.schemaVersion !== 1
|| artifact.entrypoint !== 'index.html'
|| typeof artifact.htmlSha256 !== 'string'
|| !/^[0-9a-f]{64}$/.test(artifact.htmlSha256)) {
throw new Error(`Invalid learning player artifact manifest at ${root}`);
}
const htmlSha256 = createHash('sha256').update(html).digest('hex');
if (htmlSha256 !== artifact.htmlSha256) {
throw new Error(`Learning player index hash mismatch at ${root}`);
}
if (!(await stat(resolve(root, '_next', 'static')).then((entry) => entry.isDirectory()).catch(() => false))
|| !await hasFiles(resolve(root, '_next', 'static'))) {
throw new Error(`Learning player static assets are missing at ${root}`);
}
if (!(await stat(resolve(root, 'avatars')).then((entry) => entry.isDirectory()).catch(() => false))
|| !await hasFiles(resolve(root, 'avatars'))) {
throw new Error(`Learning player avatars are missing at ${root}`);
}
if (!(await stat(resolve(root, 'public')).then((entry) => entry.isDirectory()).catch(() => false))
|| !await hasFiles(resolve(root, 'public'))) {
throw new Error(`Learning player public assets are missing at ${root}`);
}
return { root, artifact, htmlSha256 };
}

View File

@@ -0,0 +1,18 @@
{
"schemaVersion": 1,
"artifact": {
"manifest": "artifact.json",
"entrypoint": "index.html",
"requiredDirectories": ["_next/static", "avatars", "public"]
},
"localSource": {
"environmentVariable": "MAKELORE_LEARNING_SOURCE",
"defaultRelativePath": "../麦洛学习/OpenMAIC",
"requiredScripts": ["build", "build:makelore-player"]
},
"release": {
"artifactEnvironmentVariable": "MAKELORE_LEARNING_PLAYER_ARTIFACT",
"downloadUrlSecret": "LEARNING_PLAYER_ARTIFACT_URL",
"sha256Secret": "LEARNING_PLAYER_ARTIFACT_SHA256"
}
}

View File

@@ -0,0 +1,66 @@
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { resolve } from 'node:path';
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
const makeloreRoot = resolve(import.meta.dirname, '..');
const outputRoot = resolve(makeloreRoot, 'build', 'learning-player');
const buildManifest = JSON.parse(await readFile(
resolve(import.meta.dirname, 'learning-player-build-manifest.json'),
'utf8',
));
function run(command, args, cwd) {
return new Promise((resolveRun, rejectRun) => {
const child = spawn(command, args, { cwd, stdio: 'inherit', shell: process.platform === 'win32' });
child.once('error', rejectRun);
child.once('exit', (code, signal) => {
if (code === 0) resolveRun();
else rejectRun(new Error(`${command} ${args.join(' ')} failed (${signal || code})`));
});
});
}
async function buildLocalFallback() {
if (process.env.CI || process.env.MAKELORE_RELEASE_BUILD === '1') {
throw new Error(
'Release builds require MAKELORE_LEARNING_PLAYER_ARTIFACT. Download and verify the versioned OpenMAIC player before packaging.',
);
}
const learningRoot = resolve(
process.env.MAKELORE_LEARNING_SOURCE
|| resolve(makeloreRoot, buildManifest.localSource.defaultRelativePath),
);
const packageJson = JSON.parse(await readFile(resolve(learningRoot, 'package.json'), 'utf8').catch(() => {
throw new Error(`OpenMAIC source checkout is unavailable at ${learningRoot}`);
}));
for (const script of buildManifest.localSource.requiredScripts) {
if (typeof packageJson.scripts?.[script] !== 'string') {
throw new Error(`OpenMAIC source is missing required script: ${script}`);
}
}
const temporaryOutput = resolve(makeloreRoot, 'build', '.learning-player-local');
await rm(temporaryOutput, { recursive: true, force: true });
await run('pnpm', ['run', 'build'], learningRoot);
await run('pnpm', ['run', 'build:makelore-player', '--', temporaryOutput], learningRoot);
return temporaryOutput;
}
const explicitArtifact = process.env.MAKELORE_LEARNING_PLAYER_ARTIFACT?.trim();
const sourceRoot = explicitArtifact ? resolve(explicitArtifact) : await buildLocalFallback();
if (sourceRoot === outputRoot) {
throw new Error('MAKELORE_LEARNING_PLAYER_ARTIFACT must not point at build/learning-player');
}
const verified = await validateLearningPlayerArtifact(sourceRoot);
await rm(outputRoot, { recursive: true, force: true });
await mkdir(resolve(outputRoot, '..'), { recursive: true });
await cp(verified.root, outputRoot, { recursive: true });
await writeFile(resolve(outputRoot, 'makelore-build.json'), JSON.stringify({
schemaVersion: 1,
sourceKind: explicitArtifact ? 'versioned-artifact' : 'local-source-build',
htmlSha256: verified.htmlSha256,
}, null, 2));
await validateLearningPlayerArtifact(outputRoot);
process.stdout.write(`Synced verified OpenMAIC player to ${outputRoot}\n`);

View File

@@ -0,0 +1,6 @@
import { resolve } from 'node:path';
import { validateLearningPlayerArtifact } from './learning-player-artifact.mjs';
const root = resolve(process.argv[2] || 'build/learning-player');
const result = await validateLearningPlayerArtifact(root);
process.stdout.write(`Verified Makelore learning player ${result.htmlSha256} at ${result.root}\n`);