prepare.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. #!/usr/bin/env node
  2. /**
  3. * prepare.js — Build script for the Neutralinojs desktop app.
  4. *
  5. * Copies shared browser-version files (script.js, styles.css, assets/)
  6. * from the repo root into desktop-app/resources/, downloads all remote CDN
  7. * libraries locally for 100% offline capabilities, validates their cryptographic
  8. * integrity using SRI hashes (SHA-384), and generates a Neutralinojs-compatible index.html.
  9. */
  10. const fs = require("fs");
  11. const path = require("path");
  12. const https = require("https");
  13. const crypto = require("crypto");
  14. const ROOT_DIR = path.resolve(__dirname, "..");
  15. const RESOURCES_DIR = path.resolve(__dirname, "resources");
  16. const jsDest = path.join(RESOURCES_DIR, "js");
  17. const LIBS_DIR = path.join(RESOURCES_DIR, "libs");
  18. // Create directories
  19. fs.mkdirSync(jsDest, { recursive: true });
  20. fs.mkdirSync(LIBS_DIR, { recursive: true });
  21. function copyDirSync(src, dest, excludePatterns) {
  22. if (!excludePatterns) excludePatterns = [];
  23. fs.mkdirSync(dest, { recursive: true });
  24. for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
  25. const srcPath = path.join(src, entry.name);
  26. const destPath = path.join(dest, entry.name);
  27. // PERF-027: Skip files matching exclusion patterns (e.g., large demo GIFs)
  28. if (excludePatterns.some(p => entry.name.match(p))) {
  29. console.log(` ⊘ Skipped ${entry.name} (excluded from desktop build)`);
  30. continue;
  31. }
  32. if (entry.isDirectory()) {
  33. copyDirSync(srcPath, destPath, excludePatterns);
  34. } else {
  35. fs.copyFileSync(srcPath, destPath);
  36. }
  37. }
  38. }
  39. // Copy shared assets
  40. fs.copyFileSync(path.join(ROOT_DIR, "script.js"), path.join(jsDest, "script.js"));
  41. console.log("✓ Copied script.js → resources/js/script.js");
  42. fs.copyFileSync(path.join(ROOT_DIR, "preview-worker.js"), path.join(jsDest, "preview-worker.js"));
  43. console.log("Copied preview-worker.js to resources/js/preview-worker.js");
  44. fs.copyFileSync(path.join(ROOT_DIR, "styles.css"), path.join(RESOURCES_DIR, "styles.css"));
  45. console.log("✓ Copied styles.css → resources/styles.css");
  46. // PERF-027: Exclude large demo assets (GIFs) from desktop build to reduce binary size
  47. copyDirSync(path.join(ROOT_DIR, "assets"), path.join(RESOURCES_DIR, "assets"), [/\.gif$/i]);
  48. console.log("✓ Copied assets/ → resources/assets/ (excluding GIF demos)");
  49. /**
  50. * Validates the cryptographic integrity of a file against an expected SHA-384 hash.
  51. */
  52. function verifyIntegrity(filePath, expectedSha384) {
  53. return new Promise((resolve, reject) => {
  54. if (!expectedSha384) {
  55. resolve(true); // Skip validation if no hash is provided (e.g., relative fonts)
  56. return;
  57. }
  58. const hash = crypto.createHash("sha384");
  59. const stream = fs.createReadStream(filePath);
  60. stream.on("data", data => hash.update(data));
  61. stream.on("end", () => {
  62. const calculated = "sha384-" + hash.digest("base64");
  63. if (calculated === expectedSha384) {
  64. resolve(true);
  65. } else {
  66. reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedSha384}\nCalculated: ${calculated}`));
  67. }
  68. });
  69. stream.on("error", reject);
  70. });
  71. }
  72. /**
  73. * Downloads a file from a URL and verifies its integrity.
  74. */
  75. function downloadFile(url, destPath, expectedSha384) {
  76. return new Promise((resolve, reject) => {
  77. // If file already exists, verify its integrity before skipping
  78. if (fs.existsSync(destPath) && fs.statSync(destPath).size > 0) {
  79. verifyIntegrity(destPath, expectedSha384)
  80. .then(() => resolve())
  81. .catch(() => {
  82. console.log(`↻ Cached file ${path.basename(destPath)} failed integrity check. Re-downloading...`);
  83. fs.unlinkSync(destPath);
  84. downloadAndVerify();
  85. });
  86. return;
  87. }
  88. downloadAndVerify();
  89. function downloadAndVerify() {
  90. console.log(`Downloading offline dependency: ${path.basename(destPath)}...`);
  91. const req = https.get(url, (res) => {
  92. if (res.statusCode !== 200) {
  93. res.resume(); // Drain response to free up the socket
  94. reject(new Error(`Failed to load ${url} (${res.statusCode})`));
  95. return;
  96. }
  97. const stream = fs.createWriteStream(destPath);
  98. // Handle stream and response errors
  99. stream.on("error", reject);
  100. res.on("error", reject);
  101. res.pipe(stream);
  102. stream.on("finish", () => {
  103. stream.close();
  104. // Verify integrity of downloaded file
  105. verifyIntegrity(destPath, expectedSha384)
  106. .then(() => resolve())
  107. .catch(err => {
  108. // Delete corrupted file
  109. if (fs.existsSync(destPath)) {
  110. fs.unlinkSync(destPath);
  111. }
  112. reject(err);
  113. });
  114. });
  115. });
  116. req.on("error", reject);
  117. }
  118. });
  119. }
  120. async function prepareOfflineDependencies() {
  121. console.log("\nStarting Secure Offline Assets Preparation...");
  122. let html = fs.readFileSync(path.join(ROOT_DIR, "index.html"), "utf-8");
  123. // Find all CDN script and link tags that match standard script/stylesheet declarations
  124. const tagRegex = /<(link|script)[^>]+(?:href|src)="https:\/\/(?:cdnjs\.cloudflare\.com|cdn\.jsdelivr\.net)\/[^"]+"[^>]*>/g;
  125. let match;
  126. const downloads = [];
  127. const replacements = [];
  128. while ((match = tagRegex.exec(html)) !== null) {
  129. const fullTag = match[0];
  130. // Extract url
  131. const urlMatch = /(?:href|src)="([^"]+)"/.exec(fullTag);
  132. if (!urlMatch) continue;
  133. const url = urlMatch[1];
  134. // Extract integrity hash
  135. const integrityMatch = /integrity="([^"]+)"/.exec(fullTag);
  136. const expectedSha384 = integrityMatch ? integrityMatch[1] : null;
  137. if (!expectedSha384) {
  138. console.warn(`⚠ Warning: CDN dependency is missing an integrity hash: ${url}`);
  139. throw new Error(`CDN dependency is missing an integrity hash: ${url}`);
  140. }
  141. // Determine local filename - sanitize package version tags or query strings
  142. const urlPath = new URL(url).pathname;
  143. let filename = path.basename(urlPath);
  144. if (url.includes("bootstrap-icons")) {
  145. filename = "bootstrap-icons.min.css";
  146. }
  147. const localDest = path.join(LIBS_DIR, filename);
  148. downloads.push(downloadFile(url, localDest, expectedSha384));
  149. // Queue replacement in HTML to point to local libs folder
  150. const attr = fullTag.includes("href=") ? "href" : "src";
  151. replacements.push({
  152. original: `${attr}="${url}"`,
  153. replaced: `${attr}="/libs/${filename}"`
  154. });
  155. }
  156. // Also download the relative fonts loaded by bootstrap-icons (these are loaded by the stylesheet and do not have SRI tags)
  157. const fontDir = path.join(LIBS_DIR, "fonts");
  158. fs.mkdirSync(fontDir, { recursive: true });
  159. 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));
  160. 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));
  161. // Wait for all downloads and cryptographic validations to finish
  162. try {
  163. await Promise.all(downloads);
  164. console.log("✓ All offline libraries successfully downloaded and cryptographically validated.");
  165. } catch (err) {
  166. console.error("✗ Critical Security Error: Dependency integrity check failed!", err.message);
  167. process.exit(1); // Abort execution if a download fails validation
  168. }
  169. // Apply replacements in HTML
  170. replacements.forEach(rep => {
  171. html = html.replace(rep.original, rep.replaced);
  172. });
  173. // Fix relative assets
  174. html = html.replace(/href="assets\//g, 'href="/assets/');
  175. html = html.replace(/href="styles\.css"/g, 'href="/styles.css"');
  176. // PERF-034: Strip web-specific SEO tags, canonical, hreflang, preconnect, manifest and JSON-LD structured data for desktop build
  177. html = html.replace(/<!-- DNS Prefetch & Preconnect CDN Origins to Warm Up Latency -->[\s\S]*?<!-- PERF-015:/i, '<!-- PERF-015:');
  178. html = html.replace(/<!-- Canonical Link -->[\s\S]*?<!-- PWA Web Manifest -->/i, '<!-- PWA Web Manifest -->');
  179. html = html.replace(/<link rel="manifest" href="manifest\.json">/i, '');
  180. html = html.replace(/<!-- Primary Meta Tags -->[\s\S]*?<!-- JSON-LD Structured Data Schema/i, '<!-- JSON-LD Structured Data Schema');
  181. html = html.replace(/<script type="application\/ld\+json">[\s\S]*?<\/script>/i, '');
  182. // Inject Neutralino script tags
  183. html = html.replace(
  184. /<script\s+src="script\.js"\s*><\/script>/i,
  185. '<script src="/js/neutralino.js"></script>\n <script src="/js/main.js"></script>\n <script src="/js/script.js"></script>',
  186. );
  187. // Inject app-info element
  188. html = html.replace(
  189. '<div class="app-container">',
  190. `<div class="app-container">
  191. <div id="neutralino-app">
  192. <div id="neutralino-info"></div>
  193. </div>`,
  194. );
  195. fs.writeFileSync(path.join(RESOURCES_DIR, "index.html"), html, "utf-8");
  196. console.log("✓ Generated resources/index.html (Offline replacements & injections applied)");
  197. console.log("\nDone! Run `npm run dev` to start the desktop app.");
  198. }
  199. prepareOfflineDependencies().catch(err => {
  200. console.error("✗ Fatal Prepare Error:", err);
  201. process.exit(1);
  202. });