| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238 |
- #!/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(/<!-- DNS Prefetch & Preconnect CDN Origins to Warm Up Latency -->[\s\S]*?<!-- PERF-015:/i, '<!-- PERF-015:');
- html = html.replace(/<!-- Canonical Link -->[\s\S]*?<!-- PWA Web Manifest -->/i, '<!-- PWA Web Manifest -->');
- html = html.replace(/<link rel="manifest" href="manifest\.json">/i, '');
- html = html.replace(/<!-- Primary Meta Tags -->[\s\S]*?<!-- JSON-LD Structured Data Schema/i, '<!-- JSON-LD Structured Data Schema');
- html = html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/i, '');
- // Inject Neutralino script tags
- html = html.replace(
- /<script\s+src="script\.js"\s*><\/script>/i,
- '<script src="/js/neutralino.js"></script>\n <script src="/js/main.js"></script>\n <script src="/js/script.js"></script>',
- );
- // Inject app-info element
- html = html.replace(
- '<div class="app-container">',
- `<div class="app-container">
- <div id="neutralino-app">
- <div id="neutralino-info"></div>
- </div>`,
- );
- fs.writeFileSync(path.join(RESOURCES_DIR, "index.html"), html, "utf-8");
- console.log("✓ Generated resources/index.html (Offline replacements & injections applied)");
- console.log("\nDone! Run `npm run dev` to start the desktop app.");
- }
- prepareOfflineDependencies().catch(err => {
- console.error("✗ Fatal Prepare Error:", err);
- process.exit(1);
- });
|