253 lines
8.3 KiB
TypeScript
253 lines
8.3 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import { createHash } from "crypto";
|
|
import { baseDir, getCacheDir, resourceBaseDir } from "./constants";
|
|
import { safeLog } from "./safe-console";
|
|
|
|
const CACHE_LAYOUT_VERSION = "2";
|
|
|
|
type ExportSpawnTarget = {
|
|
scriptPath: string;
|
|
converterPath: string;
|
|
};
|
|
|
|
/** MSIX installs live under Program Files\\WindowsApps and block dlopen on packaged .node files. */
|
|
export function isWindowsStoreInstall(): boolean {
|
|
if (process.platform !== "win32") {
|
|
return false;
|
|
}
|
|
const markers = [baseDir, process.execPath, path.dirname(process.execPath)];
|
|
return markers.some((candidate) => /\\windowsapps\\/i.test(candidate));
|
|
}
|
|
|
|
function getExportRuntimeVersion(): string {
|
|
try {
|
|
const packageJsonPath = path.join(baseDir, "package.json");
|
|
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
|
exportVersion?: string;
|
|
version?: string;
|
|
};
|
|
return pkg.exportVersion?.trim() || pkg.version?.trim() || "unknown";
|
|
} catch {
|
|
return "unknown";
|
|
}
|
|
}
|
|
|
|
function getMsixExportCacheRoot(exportRuntimeVersion: string): string {
|
|
return path.join(
|
|
getCacheDir(),
|
|
"msix-export-runtime",
|
|
CACHE_LAYOUT_VERSION,
|
|
exportRuntimeVersion
|
|
);
|
|
}
|
|
|
|
function sharpPackagesForPlatform(): string[] {
|
|
const arch = process.arch;
|
|
if (process.platform === "win32") {
|
|
if (arch === "arm64") {
|
|
return [
|
|
"sharp",
|
|
"@img/sharp-win32-arm64",
|
|
"@img/colour",
|
|
"detect-libc",
|
|
"semver",
|
|
];
|
|
}
|
|
return [
|
|
"sharp",
|
|
"@img/sharp-win32-x64",
|
|
"@img/colour",
|
|
"detect-libc",
|
|
"semver",
|
|
];
|
|
}
|
|
if (process.platform === "darwin") {
|
|
return arch === "arm64"
|
|
? ["sharp", "@img/sharp-darwin-arm64", "@img/colour", "detect-libc", "semver"]
|
|
: ["sharp", "@img/sharp-darwin-x64", "@img/colour", "detect-libc", "semver"];
|
|
}
|
|
return ["sharp", "@img/sharp-linux-x64", "@img/colour", "detect-libc", "semver"];
|
|
}
|
|
|
|
function resolvePackageSourceDir(sourceModulesRoot: string, packageName: string): string {
|
|
if (packageName.startsWith("@")) {
|
|
const [scope, name] = packageName.split("/");
|
|
return path.join(sourceModulesRoot, scope, name);
|
|
}
|
|
return path.join(sourceModulesRoot, packageName);
|
|
}
|
|
|
|
function resolvePackageDestDir(destModulesRoot: string, packageName: string): string {
|
|
return resolvePackageSourceDir(destModulesRoot, packageName);
|
|
}
|
|
|
|
function assertSourcePackagesAvailable(sourceModulesRoot: string): void {
|
|
const missingPackages = sharpPackagesForPlatform().filter((packageName) => {
|
|
const sourceDir = resolvePackageSourceDir(sourceModulesRoot, packageName);
|
|
return !fs.existsSync(path.join(sourceDir, "package.json"));
|
|
});
|
|
|
|
if (missingPackages.length > 0) {
|
|
throw new Error(
|
|
[
|
|
"Export dependencies are missing from the unpacked app bundle.",
|
|
`Missing: ${missingPackages.join(", ")}`,
|
|
`Expected under: ${sourceModulesRoot}`,
|
|
"Rebuild the APPX after unpacking sharp dependencies from app.asar.",
|
|
].join(" ")
|
|
);
|
|
}
|
|
}
|
|
|
|
async function fileFingerprint(filePath: string): Promise<string> {
|
|
const stat = await fs.promises.stat(filePath);
|
|
return `${stat.size}:${stat.mtimeMs}`;
|
|
}
|
|
|
|
async function buildSourceFingerprint(exportRoot: string, sourceModulesRoot: string): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
const indexPath = path.join(exportRoot, "index.js");
|
|
hash.update(await fileFingerprint(indexPath));
|
|
|
|
for (const packageName of sharpPackagesForPlatform()) {
|
|
const packagePath = resolvePackageSourceDir(sourceModulesRoot, packageName);
|
|
hash.update(packageName);
|
|
hash.update(await fileFingerprint(path.join(packagePath, "package.json")));
|
|
if (packageName.startsWith("@img/sharp-")) {
|
|
const libDir = path.join(packagePath, "lib");
|
|
const entries = await fs.promises.readdir(libDir);
|
|
const nodeBinary = entries.find((entry) => entry.endsWith(".node"));
|
|
if (nodeBinary) {
|
|
hash.update(await fileFingerprint(path.join(libDir, nodeBinary)));
|
|
}
|
|
}
|
|
}
|
|
|
|
const pyDir = path.join(exportRoot, "py");
|
|
try {
|
|
const pyEntries = await fs.promises.readdir(pyDir);
|
|
for (const entry of pyEntries) {
|
|
if (!/^convert/i.test(entry)) {
|
|
continue;
|
|
}
|
|
hash.update(await fileFingerprint(path.join(pyDir, entry)));
|
|
}
|
|
} catch {
|
|
// Converter binaries are optional for fingerprinting.
|
|
}
|
|
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async function readCacheFingerprint(cacheRoot: string): Promise<string | null> {
|
|
const stampPath = path.join(cacheRoot, ".source-fingerprint");
|
|
try {
|
|
return (await fs.promises.readFile(stampPath, "utf8")).trim() || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function copyPath(source: string, destination: string): Promise<void> {
|
|
await fs.promises.mkdir(path.dirname(destination), { recursive: true });
|
|
await fs.promises.cp(source, destination, { recursive: true, force: true });
|
|
}
|
|
|
|
async function materializeMsixExportRuntime(
|
|
exportRoot: string,
|
|
sourceModulesRoot: string,
|
|
cacheRoot: string
|
|
): Promise<void> {
|
|
safeLog("[Export] Preparing MSIX export runtime in user cache:", cacheRoot);
|
|
await fs.promises.rm(cacheRoot, { recursive: true, force: true });
|
|
await fs.promises.mkdir(cacheRoot, { recursive: true });
|
|
|
|
await copyPath(path.join(exportRoot, "index.js"), path.join(cacheRoot, "index.js"));
|
|
|
|
const pyDir = path.join(exportRoot, "py");
|
|
if (fs.existsSync(pyDir)) {
|
|
await copyPath(pyDir, path.join(cacheRoot, "py"));
|
|
}
|
|
|
|
const destModulesRoot = path.join(cacheRoot, "node_modules");
|
|
for (const packageName of sharpPackagesForPlatform()) {
|
|
const sourceDir = resolvePackageSourceDir(sourceModulesRoot, packageName);
|
|
const destDir = resolvePackageDestDir(destModulesRoot, packageName);
|
|
if (!fs.existsSync(sourceDir)) {
|
|
throw new Error(`Export dependency missing from app bundle: ${packageName} (${sourceDir})`);
|
|
}
|
|
await copyPath(sourceDir, destDir);
|
|
}
|
|
|
|
const fingerprint = await buildSourceFingerprint(exportRoot, sourceModulesRoot);
|
|
await fs.promises.writeFile(path.join(cacheRoot, ".source-fingerprint"), fingerprint, "utf8");
|
|
safeLog("[Export] MSIX export runtime ready.");
|
|
}
|
|
|
|
async function resolveConverterPathFromExportRoot(exportRoot: string): Promise<string> {
|
|
const pyDir = path.join(exportRoot, "py");
|
|
const extension = process.platform === "win32" ? ".exe" : "";
|
|
const converterCandidates = [
|
|
path.join(pyDir, `convert-${process.platform}-${process.arch}${extension}`),
|
|
path.join(pyDir, `convert-${process.platform}${extension}`),
|
|
...(process.platform === "win32"
|
|
? [path.join(pyDir, "convert.exe"), path.join(pyDir, "convert")]
|
|
: [path.join(pyDir, "convert")]),
|
|
];
|
|
|
|
for (const candidate of converterCandidates) {
|
|
try {
|
|
await fs.promises.access(candidate, fs.constants.F_OK);
|
|
return candidate;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
throw new Error(
|
|
[
|
|
"No converter binary found for export.",
|
|
"Expected one of:",
|
|
...converterCandidates.map((candidate) => ` - ${candidate}`),
|
|
].join("\n")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* For Microsoft Store (MSIX) installs, copy export JS + sharp native addons to a writable
|
|
* directory so Node can dlopen them. Returns the original paths elsewhere.
|
|
*/
|
|
export async function resolveExportSpawnTarget(
|
|
packagedExportRoot: string,
|
|
packagedScriptPath: string,
|
|
resolvePackagedConverterPath: (exportRoot: string) => Promise<string>
|
|
): Promise<ExportSpawnTarget> {
|
|
if (!isWindowsStoreInstall()) {
|
|
return {
|
|
scriptPath: packagedScriptPath,
|
|
converterPath: await resolvePackagedConverterPath(packagedExportRoot),
|
|
};
|
|
}
|
|
|
|
const exportRuntimeVersion = getExportRuntimeVersion();
|
|
const cacheRoot = getMsixExportCacheRoot(exportRuntimeVersion);
|
|
const cachedScriptPath = path.join(cacheRoot, "index.js");
|
|
const sourceModulesRoot = path.join(resourceBaseDir, "node_modules");
|
|
assertSourcePackagesAvailable(sourceModulesRoot);
|
|
|
|
const expectedFingerprint = await buildSourceFingerprint(packagedExportRoot, sourceModulesRoot);
|
|
const cachedFingerprint = await readCacheFingerprint(cacheRoot);
|
|
const cacheIsCurrent =
|
|
cachedFingerprint === expectedFingerprint && fs.existsSync(cachedScriptPath);
|
|
|
|
if (!cacheIsCurrent) {
|
|
await materializeMsixExportRuntime(packagedExportRoot, sourceModulesRoot, cacheRoot);
|
|
}
|
|
|
|
return {
|
|
scriptPath: cachedScriptPath,
|
|
converterPath: await resolveConverterPathFromExportRoot(cacheRoot),
|
|
};
|
|
}
|