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();