feat(scanner): complete localized artifact quality checkpoint
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { extractFile, listPackage } from "@electron/asar";
|
||||
|
||||
export function rendererAssetReferences(html) {
|
||||
return [...String(html).matchAll(/\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)]
|
||||
.map((match) => match[1].trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function absoluteRendererAssetReferences(html) {
|
||||
return rendererAssetReferences(html).filter((reference) => reference.startsWith("/"));
|
||||
}
|
||||
|
||||
export function runtimeSignature(source) {
|
||||
return String(source).match(/APP_RUNTIME_SIGNATURE\s*=\s*["']([^"']+)["']/)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export function verifyProjectPackaging(projectRoot = process.cwd()) {
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
|
||||
const checks = [];
|
||||
const viteConfigPath = path.join(projectRoot, "vite.config.ts");
|
||||
const viteConfig = readText(viteConfigPath);
|
||||
const rendererIndexPath = path.join(projectRoot, "dist", "index.html");
|
||||
const rendererIndex = readText(rendererIndexPath);
|
||||
const mainSource = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceRuntimeSignature = runtimeSignature(mainSource);
|
||||
check(checks, packageJson.main === "dist-electron/electron/main.js", "main-entry", packageJson.main);
|
||||
check(checks, packageJson.build?.win?.requestedExecutionLevel === "requireAdministrator", "windows-elevation", packageJson.build?.win?.requestedExecutionLevel);
|
||||
check(checks, /\bbase\s*:\s*["']\.\/["']/.test(viteConfig), "vite-relative-base", "Vite base must be ./ for Electron loadFile");
|
||||
check(checks, resourceMapping(packageJson, "native/input-helper/bin/publish", "input-helper"), "helper-resource-mapping", "native helper -> resources/input-helper");
|
||||
check(checks, resourceMapping(packageJson, "data/ik-inventorylists", "ik-inventorylists"), "ik-resource-mapping", "IK lists -> resources/ik-inventorylists");
|
||||
checkFile(checks, path.join(projectRoot, "native", "input-helper", "bin", "publish", "InputHelper.exe"), "compiled-helper", 1_000_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "artifacts.json"), "ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "ik-version", 1);
|
||||
checkFile(checks, rendererIndexPath, "compiled-renderer-index", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "preload.cjs"), "compiled-preload", 100);
|
||||
checkFile(checks, path.join(projectRoot, "dist-electron", "electron", "runtimePaths.js"), "compiled-runtime-path-policy", 100);
|
||||
if (rendererIndex) checkRendererAssetPolicy(checks, rendererIndex, "project-renderer");
|
||||
check(checks, sourceRuntimeSignature.startsWith("2026-07-10-"), "current-runtime-signature", sourceRuntimeSignature || "missing");
|
||||
return packagingReport("project", projectRoot, checks);
|
||||
}
|
||||
|
||||
export function verifyPackagedDirectory(packageDir, projectRoot = process.cwd()) {
|
||||
const resolved = path.resolve(packageDir);
|
||||
const resources = path.join(resolved, "resources");
|
||||
const asarPath = path.join(resources, "app.asar");
|
||||
const checks = [];
|
||||
checkFile(checks, path.join(resolved, "Genshin Artifact Assistant.exe"), "packaged-executable", 1_000_000);
|
||||
checkFile(checks, asarPath, "app-asar", 1_000);
|
||||
checkFile(checks, path.join(resources, "input-helper", "InputHelper.exe"), "packaged-helper", 1_000_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "artifacts.json"), "packaged-ik-artifacts", 1_000);
|
||||
checkFile(checks, path.join(resources, "ik-inventorylists", "version.txt"), "packaged-ik-version", 1);
|
||||
if (fs.existsSync(asarPath)) {
|
||||
const files = new Set(listPackage(asarPath).map((entry) => entry.replaceAll("\\", "/").replace(/^\//, "")));
|
||||
for (const expected of ["dist/index.html", "dist-electron/electron/main.js", "dist-electron/electron/preload.cjs", "dist-electron/electron/runtimePaths.js", "package.json"]) {
|
||||
check(checks, files.has(expected), `asar:${expected}`, expected);
|
||||
}
|
||||
if (files.has("dist/index.html")) {
|
||||
const rendererIndex = extractFile(asarPath, asarExtractPath("dist/index.html")).toString("utf8");
|
||||
checkRendererAssetPolicy(checks, rendererIndex, "packaged-renderer");
|
||||
const missingAssets = rendererAssetReferences(rendererIndex)
|
||||
.filter(isLocalRendererReference)
|
||||
.map((reference) => rendererReferenceAsarPath(reference))
|
||||
.filter((reference) => !files.has(reference));
|
||||
check(checks, missingAssets.length === 0, "packaged-renderer-assets-present", missingAssets.join(", ") || "all referenced assets present");
|
||||
}
|
||||
if (files.has("dist-electron/electron/main.js")) {
|
||||
const packagedMain = extractFile(asarPath, asarExtractPath("dist-electron/electron/main.js")).toString("utf8");
|
||||
const sourceMain = readText(path.join(projectRoot, "electron", "main.ts"));
|
||||
const sourceSignature = runtimeSignature(sourceMain);
|
||||
const packagedSignature = runtimeSignature(packagedMain);
|
||||
check(
|
||||
checks,
|
||||
Boolean(sourceSignature) && packagedSignature === sourceSignature,
|
||||
"runtime-signature-match",
|
||||
`${sourceSignature || "missing source"} == ${packagedSignature || "missing package"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const sourceVersion = fs.readFileSync(path.join(projectRoot, "data", "ik-inventorylists", "version.txt"), "utf8").trim();
|
||||
const packagedVersionPath = path.join(resources, "ik-inventorylists", "version.txt");
|
||||
const packagedVersion = fs.existsSync(packagedVersionPath) ? fs.readFileSync(packagedVersionPath, "utf8").trim() : "";
|
||||
check(checks, sourceVersion === packagedVersion, "ik-version-match", `${sourceVersion} == ${packagedVersion || "missing"}`);
|
||||
return packagingReport("packaged", resolved, checks);
|
||||
}
|
||||
|
||||
function resourceMapping(packageJson, from, to) {
|
||||
return (packageJson.build?.extraResources ?? []).some((entry) => entry.from === from && entry.to === to);
|
||||
}
|
||||
|
||||
function readText(filePath) {
|
||||
return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
|
||||
}
|
||||
|
||||
function checkRendererAssetPolicy(checks, html, idPrefix) {
|
||||
const absoluteReferences = absoluteRendererAssetReferences(html);
|
||||
check(
|
||||
checks,
|
||||
absoluteReferences.length === 0,
|
||||
`${idPrefix}-assets-relative`,
|
||||
absoluteReferences.join(", ") || "all src/href references are relative",
|
||||
);
|
||||
}
|
||||
|
||||
function isLocalRendererReference(reference) {
|
||||
return !reference.startsWith("#") && !/^[a-z][a-z\d+.-]*:/i.test(reference);
|
||||
}
|
||||
|
||||
function rendererReferenceAsarPath(reference) {
|
||||
const withoutQuery = reference.split(/[?#]/, 1)[0].replace(/^\.\//, "");
|
||||
return path.posix.normalize(path.posix.join("dist", withoutQuery));
|
||||
}
|
||||
|
||||
function asarExtractPath(reference) {
|
||||
return reference.replaceAll("/", path.sep);
|
||||
}
|
||||
|
||||
function checkFile(checks, filePath, id, minimumBytes) {
|
||||
const exists = fs.existsSync(filePath);
|
||||
const size = exists ? fs.statSync(filePath).size : 0;
|
||||
check(checks, exists && size >= minimumBytes, id, `${filePath} (${size} bytes)`);
|
||||
}
|
||||
|
||||
function check(checks, ok, id, detail) {
|
||||
checks.push({ id, ok: Boolean(ok), detail: String(detail ?? "") });
|
||||
}
|
||||
|
||||
function packagingReport(mode, input, checks) {
|
||||
return {
|
||||
version: "packaging-offline-v2",
|
||||
mode,
|
||||
input,
|
||||
ok: checks.every((entry) => entry.ok),
|
||||
passed: checks.filter((entry) => entry.ok).length,
|
||||
failed: checks.filter((entry) => !entry.ok).length,
|
||||
checks,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const staticOnly = process.argv.includes("--static");
|
||||
const inputArg = process.argv.find((argument) => argument.startsWith("--input="));
|
||||
const reports = [verifyProjectPackaging()];
|
||||
if (!staticOnly) {
|
||||
const input = inputArg?.slice("--input=".length) || path.resolve("outputs", "dist", "win-unpacked");
|
||||
reports.push(verifyPackagedDirectory(input));
|
||||
}
|
||||
const result = { version: "packaging-offline-suite-v2", ok: reports.every((report) => report.ok), reports };
|
||||
const outputDir = path.resolve("outputs", "packaging");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const reportPath = path.join(outputDir, "offline-package-report.json");
|
||||
fs.writeFileSync(reportPath, JSON.stringify(result, null, 2), "utf8");
|
||||
for (const report of reports) {
|
||||
console.log(`${report.mode}: ${report.passed}/${report.checks.length} checks passed`);
|
||||
for (const failed of report.checks.filter((entry) => !entry.ok)) console.error(`FAIL ${failed.id}: ${failed.detail}`);
|
||||
}
|
||||
console.log(`Report: ${reportPath}`);
|
||||
if (!result.ok) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
|
||||
Reference in New Issue
Block a user