-
Notifications
You must be signed in to change notification settings - Fork 85
Expand file tree
/
Copy pathprepare.js
More file actions
252 lines (214 loc) · 9.61 KB
/
Copy pathprepare.js
File metadata and controls
252 lines (214 loc) · 9.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/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, expectedHash) {
return new Promise((resolve, reject) => {
if (!expectedHash) {
resolve(true); // Skip validation if no hash is provided (e.g., relative fonts)
return;
}
const algo = expectedHash.startsWith("sha512-") ? "sha512" : "sha384";
const hash = crypto.createHash(algo);
const stream = fs.createReadStream(filePath);
stream.on("data", data => hash.update(data));
stream.on("end", () => {
const calculated = algo + "-" + hash.digest("base64");
if (calculated === expectedHash) {
resolve(true);
} else {
reject(new Error(`Integrity mismatch for ${path.basename(filePath)}:\nExpected: ${expectedHash}\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));
// Dynamic / Lazy-loaded dependencies to download manually for offline capability
const dynamicLibs = [
{
url: "https://cdnjs.cloudflare.com/ajax/libs/abcjs/6.5.2/abcjs-basic-min.js",
dest: path.join(LIBS_DIR, "abcjs-basic-min.js"),
hash: "sha512-QJ21PAOSw5KSiQ12gnP74qwLRAEn9GZtrFI0yY1akCLLpcEaC7xwZ7BiONZ/7pyrfUADyh7sHnI3SYHifO+tmg=="
}
];
for (const lib of dynamicLibs) {
downloads.push(downloadFile(lib.url, lib.dest, lib.hash));
}
// 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);
});