#!/usr/bin/env node /** * prepare.js — Build script for the Neutralinojs desktop app. * * Copies shared browser-version files (script.js, styles.css, assets/) * from the repo root into desktop-app/resources/, downloads all remote CDN * libraries locally for 100% offline capabilities, validates their cryptographic * integrity using SRI hashes (SHA-384), and generates a Neutralinojs-compatible index.html. */ const fs = require("fs"); const path = require("path"); const https = require("https"); const crypto = require("crypto"); const ROOT_DIR = path.resolve(__dirname, ".."); const RESOURCES_DIR = path.resolve(__dirname, "resources"); const jsDest = path.join(RESOURCES_DIR, "js"); const LIBS_DIR = path.join(RESOURCES_DIR, "libs"); // Create directories fs.mkdirSync(jsDest, { recursive: true }); fs.mkdirSync(LIBS_DIR, { recursive: true }); function copyDirSync(src, dest, excludePatterns) { if (!excludePatterns) excludePatterns = []; fs.mkdirSync(dest, { recursive: true }); for (const entry of fs.readdirSync(src, { withFileTypes: true })) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); // PERF-027: Skip files matching exclusion patterns (e.g., large demo GIFs) if (excludePatterns.some(p => entry.name.match(p))) { console.log(` ⊘ Skipped ${entry.name} (excluded from desktop build)`); continue; } if (entry.isDirectory()) { copyDirSync(srcPath, destPath, excludePatterns); } else { fs.copyFileSync(srcPath, destPath); } } } // Copy shared assets fs.copyFileSync(path.join(ROOT_DIR, "script.js"), path.join(jsDest, "script.js")); console.log("✓ Copied script.js → resources/js/script.js"); fs.copyFileSync(path.join(ROOT_DIR, "preview-worker.js"), path.join(jsDest, "preview-worker.js")); console.log("Copied preview-worker.js to resources/js/preview-worker.js"); fs.copyFileSync(path.join(ROOT_DIR, "styles.css"), path.join(RESOURCES_DIR, "styles.css")); console.log("✓ Copied styles.css → resources/styles.css"); // PERF-027: Exclude large demo assets (GIFs) from desktop build to reduce binary size copyDirSync(path.join(ROOT_DIR, "assets"), path.join(RESOURCES_DIR, "assets"), [/\.gif$/i]); console.log("✓ Copied assets/ → resources/assets/ (excluding GIF demos)"); /** * Validates the cryptographic integrity of a file against an expected SHA-384 hash. */ function verifyIntegrity(filePath, expectedSha384) { return new Promise((resolve, reject) => { if (!expectedSha384) { resolve(true); // Skip validation if no hash is provided (e.g., relative fonts) return; } const hash = crypto.createHash("sha384"); const stream = fs.createReadStream(filePath); stream.on("data", data => hash.update(data)); stream.on("end", () => { const calculated = "sha384-" + hash.digest("base64"); if (calculated === expectedSha384) { resolve(true); } else { reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedSha384}\nCalculated: ${calculated}`)); } }); stream.on("error", reject); }); } /** * Downloads a file from a URL and verifies its integrity. */ function downloadFile(url, destPath, expectedSha384) { return new Promise((resolve, reject) => { // If file already exists, verify its integrity before skipping if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) { verifyIntegrity(destPath, expectedSha384) .then(() => resolve()) .catch(() => { console.log(`↻ Cached file ${path.basename(destPath)} failed integrity check. Re-downloading...`); fs.unlinkSync(destPath); downloadAndVerify(); }); return; } downloadAndVerify(); function downloadAndVerify() { console.log(`Downloading offline dependency: ${path.basename(destPath)}...`); const req = https.get(url, (res) => { if (res.statusCode !== 200) { res.resume(); // Drain response to free up the socket reject(new Error(`Failed to load ${url} (${res.statusCode})`)); return; } const stream = fs.createWriteStream(destPath); // Handle stream and response errors stream.on("error", reject); res.on("error", reject); res.pipe(stream); stream.on("finish", () => { stream.close(); // Verify integrity of downloaded file verifyIntegrity(destPath, expectedSha384) .then(() => resolve()) .catch(err => { // Delete corrupted file if (fs.existsSync(destPath)) { fs.unlinkSync(destPath); } reject(err); }); }); }); req.on("error", reject); } }); } async function prepareOfflineDependencies() { console.log("\nStarting Secure Offline Assets Preparation..."); let html = fs.readFileSync(path.join(ROOT_DIR, "index.html"), "utf-8"); // Find all CDN script and link tags that match standard script/stylesheet declarations const tagRegex = /<(link|script)[^>]+(?:href|src)="https:\/\/(?:cdnjs\.cloudflare\.com|cdn\.jsdelivr\.net)\/[^"]+"[^>]*>/g; let match; const downloads = []; const replacements = []; while ((match = tagRegex.exec(html)) !== null) { const fullTag = match[0]; // Extract url const urlMatch = /(?:href|src)="([^"]+)"/.exec(fullTag); if (!urlMatch) continue; const url = urlMatch[1]; // Extract integrity hash const integrityMatch = /integrity="([^"]+)"/.exec(fullTag); const expectedSha384 = integrityMatch ? integrityMatch[1] : null; if (!expectedSha384) { console.warn(`⚠ Warning: CDN dependency is missing an integrity hash: ${url}`); throw new Error(`CDN dependency is missing an integrity hash: ${url}`); } // Determine local filename - sanitize package version tags or query strings const urlPath = new URL(url).pathname; let filename = path.basename(urlPath); if (url.includes("bootstrap-icons")) { filename = "bootstrap-icons.min.css"; } const localDest = path.join(LIBS_DIR, filename); downloads.push(downloadFile(url, localDest, expectedSha384)); // Queue replacement in HTML to point to local libs folder const attr = fullTag.includes("href=") ? "href" : "src"; replacements.push({ original: `${attr}="${url}"`, replaced: `${attr}="/libs/${filename}"` }); } // Also download the relative fonts loaded by bootstrap-icons (these are loaded by the stylesheet and do not have SRI tags) const fontDir = path.join(LIBS_DIR, "fonts"); fs.mkdirSync(fontDir, { recursive: true }); downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff2", path.join(fontDir, "bootstrap-icons.woff2"), null)); downloads.push(downloadFile("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/fonts/bootstrap-icons.woff", path.join(fontDir, "bootstrap-icons.woff"), null)); // Wait for all downloads and cryptographic validations to finish try { await Promise.all(downloads); console.log("✓ All offline libraries successfully downloaded and cryptographically validated."); } catch (err) { console.error("✗ Critical Security Error: Dependency integrity check failed!", err.message); process.exit(1); // Abort execution if a download fails validation } // Apply replacements in HTML replacements.forEach(rep => { html = html.replace(rep.original, rep.replaced); }); // Fix relative assets html = html.replace(/href="assets\//g, 'href="/assets/'); html = html.replace(/href="styles\.css"/g, 'href="/styles.css"'); // PERF-034: Strip web-specific SEO tags, canonical, hreflang, preconnect, manifest and JSON-LD structured data for desktop build html = html.replace(/[\s\S]*?[\s\S]*?/i, ''); html = html.replace(//i, ''); html = html.replace(/[\s\S]*?