@bghitcode/bghitapp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/dist/cli.js +2995 -0
  4. package/package.json +104 -0
  5. package/src-tauri/Cargo.lock +5966 -0
  6. package/src-tauri/Cargo.toml +59 -0
  7. package/src-tauri/Info.plist +14 -0
  8. package/src-tauri/assets/macos/dmg/background.png +0 -0
  9. package/src-tauri/assets/main.wxs +350 -0
  10. package/src-tauri/bghitapp.json +42 -0
  11. package/src-tauri/build.rs +5 -0
  12. package/src-tauri/capabilities/default.json +29 -0
  13. package/src-tauri/entitlements.plist +7 -0
  14. package/src-tauri/icons/chatgpt.icns +0 -0
  15. package/src-tauri/icons/deepseek.icns +0 -0
  16. package/src-tauri/icons/excalidraw.icns +0 -0
  17. package/src-tauri/icons/flomo.icns +0 -0
  18. package/src-tauri/icons/gemini.icns +0 -0
  19. package/src-tauri/icons/grok.icns +0 -0
  20. package/src-tauri/icons/icon.icns +0 -0
  21. package/src-tauri/icons/icon.png +0 -0
  22. package/src-tauri/icons/lizhi.icns +0 -0
  23. package/src-tauri/icons/programmusic.icns +0 -0
  24. package/src-tauri/icons/qwerty.icns +0 -0
  25. package/src-tauri/icons/twitter.icns +0 -0
  26. package/src-tauri/icons/wechat.icns +0 -0
  27. package/src-tauri/icons/weekly.icns +0 -0
  28. package/src-tauri/icons/weread.icns +0 -0
  29. package/src-tauri/icons/xiaohongshu.icns +0 -0
  30. package/src-tauri/icons/youtube.icns +0 -0
  31. package/src-tauri/icons/youtubemusic.icns +0 -0
  32. package/src-tauri/rust_proxy.toml +10 -0
  33. package/src-tauri/src/app/config.rs +100 -0
  34. package/src-tauri/src/app/invoke.rs +242 -0
  35. package/src-tauri/src/app/menu.rs +324 -0
  36. package/src-tauri/src/app/mod.rs +6 -0
  37. package/src-tauri/src/app/setup.rs +172 -0
  38. package/src-tauri/src/app/window.rs +577 -0
  39. package/src-tauri/src/inject/auth.js +75 -0
  40. package/src-tauri/src/inject/custom.js +0 -0
  41. package/src-tauri/src/inject/event.js +1111 -0
  42. package/src-tauri/src/inject/find.js +708 -0
  43. package/src-tauri/src/inject/fullscreen.js +253 -0
  44. package/src-tauri/src/inject/offline.js +68 -0
  45. package/src-tauri/src/inject/splash-transition.js +13 -0
  46. package/src-tauri/src/inject/style.js +505 -0
  47. package/src-tauri/src/inject/theme_refresh.js +59 -0
  48. package/src-tauri/src/inject/toast.js +22 -0
  49. package/src-tauri/src/lib.rs +227 -0
  50. package/src-tauri/src/main.rs +8 -0
  51. package/src-tauri/src/util.rs +245 -0
  52. package/src-tauri/tauri.conf.json +20 -0
  53. package/src-tauri/tauri.linux.conf.json +12 -0
  54. package/src-tauri/tauri.macos.conf.json +28 -0
  55. package/src-tauri/tauri.windows.conf.json +15 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2995 @@
1
+ #!/usr/bin/env node
2
+ import log from 'loglevel';
3
+ import chalk from 'chalk';
4
+ import updateNotifier from 'update-notifier';
5
+ import path from 'path';
6
+ import fsExtra from 'fs-extra';
7
+ import { fileURLToPath } from 'url';
8
+ import prompts from 'prompts';
9
+ import os from 'os';
10
+ import { execa, execaSync } from 'execa';
11
+ import crypto from 'crypto';
12
+ import ora from 'ora';
13
+ import fs from 'fs/promises';
14
+ import { dir } from 'tmp-promise';
15
+ import { fileTypeFromBuffer } from 'file-type';
16
+ import icongen from 'icon-gen';
17
+ import sharp from 'sharp';
18
+ import * as psl from 'psl';
19
+ import { InvalidArgumentError, program as program$1, Option } from 'commander';
20
+ import fs$1 from 'fs';
21
+
22
+ var name = "@bghitcode/bghitapp";
23
+ var version = "1.0.0";
24
+ var description = "🤱🏻 Turn any webpage into a desktop app with one command — by BghitCode.";
25
+ var engines = {
26
+ node: ">=18.0.0"
27
+ };
28
+ var packageManager = "pnpm@10.26.2";
29
+ var bin = {
30
+ bghitapp: "dist/cli.js"
31
+ };
32
+ var repository = {
33
+ type: "git",
34
+ url: "git+https://github.com/BghitCode/bghitapp.git"
35
+ };
36
+ var author = {
37
+ name: "BghitCode",
38
+ email: "contact@bghitcode.com"
39
+ };
40
+ var keywords = [
41
+ "bghitapp",
42
+ "bghitcode",
43
+ "rust",
44
+ "tauri",
45
+ "no-electron",
46
+ "productivity"
47
+ ];
48
+ var files = [
49
+ "dist/cli.js",
50
+ "src-tauri",
51
+ "!src-tauri/.pake",
52
+ "!src-tauri/.bghitapp",
53
+ "!src-tauri/png",
54
+ "!src-tauri/target",
55
+ "!src-tauri/gen"
56
+ ];
57
+ var scripts = {
58
+ start: "pnpm run dev",
59
+ dev: "pnpm run tauri dev",
60
+ build: "tauri build",
61
+ "build:debug": "tauri build --debug",
62
+ "build:mac": "tauri build --target universal-apple-darwin",
63
+ analyze: "cd src-tauri && cargo bloat --release --crates",
64
+ tauri: "tauri",
65
+ cli: "cross-env NODE_ENV=development rollup -c -w",
66
+ "cli:build": "cross-env NODE_ENV=production rollup -c",
67
+ test: "pnpm run cli:build && cross-env BGHITAPP_CREATE_APP=1 node tests/index.js",
68
+ format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose",
69
+ "format:check": "prettier --check . --ignore-unknown",
70
+ "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts",
71
+ update: "pnpm update --verbose && cd src-tauri && cargo update",
72
+ prepublishOnly: "pnpm run cli:build"
73
+ };
74
+ var type = "module";
75
+ var exports$1 = "./dist/cli.js";
76
+ var license = "MIT";
77
+ var dependencies = {
78
+ "@tauri-apps/api": "^2.11.0",
79
+ "@tauri-apps/cli": "^2.10.0",
80
+ chalk: "^5.6.2",
81
+ commander: "^14.0.3",
82
+ execa: "^9.6.1",
83
+ "file-type": "^21.3.0",
84
+ "fs-extra": "^11.3.3",
85
+ "icon-gen": "^5.0.0",
86
+ loglevel: "^1.9.2",
87
+ ora: "^9.3.0",
88
+ prompts: "^2.4.2",
89
+ psl: "^1.15.0",
90
+ sharp: "^0.34.5",
91
+ "tmp-promise": "^3.0.3",
92
+ "update-notifier": "^7.3.1"
93
+ };
94
+ var devDependencies = {
95
+ "@rollup/plugin-alias": "^6.0.0",
96
+ "@rollup/plugin-commonjs": "^29.0.0",
97
+ "@rollup/plugin-json": "^6.1.0",
98
+ "@rollup/plugin-replace": "^6.0.3",
99
+ "@rollup/plugin-terser": "^0.4.4",
100
+ "@types/fs-extra": "^11.0.4",
101
+ "@types/node": "^25.3.2",
102
+ "@types/prompts": "^2.4.9",
103
+ "@types/tmp": "^0.2.6",
104
+ "@types/update-notifier": "^6.0.8",
105
+ "app-root-path": "^3.1.0",
106
+ "cross-env": "^10.1.0",
107
+ prettier: "^3.8.1",
108
+ rollup: "^4.59.0",
109
+ "rollup-plugin-typescript2": "^0.36.0",
110
+ tslib: "^2.8.1",
111
+ typescript: "^5.9.3",
112
+ vitest: "^4.0.18"
113
+ };
114
+ var pnpm = {
115
+ overrides: {
116
+ sharp: "^0.34.5",
117
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
118
+ },
119
+ onlyBuiltDependencies: [
120
+ "esbuild",
121
+ "sharp"
122
+ ]
123
+ };
124
+ var packageJson = {
125
+ name: name,
126
+ version: version,
127
+ description: description,
128
+ engines: engines,
129
+ packageManager: packageManager,
130
+ bin: bin,
131
+ repository: repository,
132
+ author: author,
133
+ keywords: keywords,
134
+ files: files,
135
+ scripts: scripts,
136
+ type: type,
137
+ exports: exports$1,
138
+ license: license,
139
+ dependencies: dependencies,
140
+ devDependencies: devDependencies,
141
+ pnpm: pnpm
142
+ };
143
+
144
+ // Convert the current module URL to a file path
145
+ const currentModulePath = fileURLToPath(import.meta.url);
146
+ // Resolve the parent directory of the current module
147
+ const npmDirectory = path.join(path.dirname(currentModulePath), '..');
148
+ const tauriConfigDirectory = path.join(npmDirectory, 'src-tauri', '.bghitapp');
149
+
150
+ // Load configs from npm package directory, not from project source
151
+ const tauriSrcDir = path.join(npmDirectory, 'src-tauri');
152
+ const bghitappConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'bghitapp.json'));
153
+ const CommonConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.conf.json'));
154
+ const WinConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.windows.conf.json'));
155
+ const MacConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.macos.conf.json'));
156
+ const LinuxConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'tauri.linux.conf.json'));
157
+ const platformConfigs = {
158
+ win32: WinConf,
159
+ darwin: MacConf,
160
+ linux: LinuxConf,
161
+ };
162
+ const { platform: platform$2 } = process;
163
+ // @ts-ignore
164
+ const platformConfig = platformConfigs[platform$2];
165
+ let tauriConfig = {
166
+ ...CommonConf,
167
+ bundle: platformConfig.bundle,
168
+ app: {
169
+ ...CommonConf.app,
170
+ trayIcon: {
171
+ ...(platformConfig?.app?.trayIcon ?? {}),
172
+ },
173
+ },
174
+ build: CommonConf.build,
175
+ bghitapp: bghitappConf,
176
+ };
177
+
178
+ // Generates a stable identifier based on the app URL (and optionally name).
179
+ // When name is provided it is included in the hash so two apps wrapping
180
+ // the same URL can coexist. Omitting name preserves backward compatibility
181
+ // with identifiers generated before V3.10.1.
182
+ function getIdentifier(url, name) {
183
+ const hashInput = name ? `${url}::${name}` : url;
184
+ const postFixHash = crypto
185
+ .createHash('md5')
186
+ .update(hashInput)
187
+ .digest('hex')
188
+ .substring(0, 6);
189
+ return `com.bghitapp.a${postFixHash}`;
190
+ }
191
+ function resolveIdentifier(url, explicitName, customIdentifier) {
192
+ const trimmedIdentifier = customIdentifier?.trim();
193
+ if (trimmedIdentifier) {
194
+ if (!/^[a-zA-Z][a-zA-Z0-9.-]*[a-zA-Z0-9]$/.test(trimmedIdentifier)) {
195
+ throw new Error(`Invalid identifier "${trimmedIdentifier}". Must start with a letter, ` +
196
+ `contain only letters, digits, hyphens, and dots, and end with a letter or digit.`);
197
+ }
198
+ return trimmedIdentifier;
199
+ }
200
+ return getIdentifier(url, explicitName);
201
+ }
202
+ async function promptText(message, initial) {
203
+ const response = await prompts({
204
+ type: 'text',
205
+ name: 'content',
206
+ message,
207
+ initial,
208
+ });
209
+ return response.content;
210
+ }
211
+ function capitalizeFirstLetter(string) {
212
+ return string.charAt(0).toUpperCase() + string.slice(1);
213
+ }
214
+ function getSpinner(text) {
215
+ const loadingType = {
216
+ interval: 80,
217
+ frames: ['✦', '✶', '✺', '✵', '✸', '✹', '✺'],
218
+ };
219
+ return ora({
220
+ text: `${chalk.cyan(text)}\n`,
221
+ spinner: loadingType,
222
+ color: 'cyan',
223
+ }).start();
224
+ }
225
+
226
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
227
+ const CN_MIRROR_ENV = 'BGHITAPP_USE_CN_MIRROR';
228
+ function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]) {
229
+ return TRUE_VALUES.has((value ?? '').trim().toLowerCase());
230
+ }
231
+
232
+ const { platform: platform$1 } = process;
233
+ const IS_MAC = platform$1 === 'darwin';
234
+ const IS_WIN = platform$1 === 'win32';
235
+ const IS_LINUX = platform$1 === 'linux';
236
+
237
+ async function shellExec(command, timeout = 300000, env) {
238
+ try {
239
+ const { exitCode } = await execa(command, {
240
+ cwd: npmDirectory,
241
+ // Use 'inherit' to show all output directly to user in real-time.
242
+ // This ensures linuxdeploy and other tool outputs are visible during builds.
243
+ stdio: 'inherit',
244
+ shell: true,
245
+ timeout,
246
+ env: env ? { ...process.env, ...env } : process.env,
247
+ });
248
+ return exitCode;
249
+ }
250
+ catch (error) {
251
+ const exitCode = error.exitCode ?? 'unknown';
252
+ const errorMessage = error.message || 'Unknown error occurred';
253
+ if (error.timedOut) {
254
+ throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`);
255
+ }
256
+ let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`;
257
+ // Provide helpful guidance for common Linux AppImage build failures
258
+ // caused by strip tool incompatibility with modern glibc (2.38+)
259
+ const lowerError = errorMessage.toLowerCase();
260
+ if (process.platform === 'linux' &&
261
+ (lowerError.includes('linuxdeploy') ||
262
+ lowerError.includes('appimage') ||
263
+ lowerError.includes('strip'))) {
264
+ errorMsg +=
265
+ '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' +
266
+ 'Linux AppImage Build Failed\n' +
267
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n' +
268
+ 'Cause: Strip tool incompatibility with glibc 2.38+\n' +
269
+ ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n' +
270
+ 'Quick fix:\n' +
271
+ ' NO_STRIP=1 bghitapp <url> --targets appimage --debug\n\n' +
272
+ 'Alternatives:\n' +
273
+ ' • Use DEB format: bghitapp <url> --targets deb\n' +
274
+ ' • Update binutils: sudo apt install binutils (or pacman -S binutils)\n' +
275
+ ' • Detailed guide: https://github.com/BghitCode/bghitapp/blob/main/docs/faq.md\n' +
276
+ '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
277
+ if (lowerError.includes('fuse') ||
278
+ lowerError.includes('operation not permitted') ||
279
+ lowerError.includes('/dev/fuse')) {
280
+ errorMsg +=
281
+ '\n\nDocker / Container hint:\n' +
282
+ ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' +
283
+ ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' +
284
+ ' or run on the host directly.';
285
+ }
286
+ }
287
+ throw new Error(errorMsg);
288
+ }
289
+ }
290
+
291
+ function normalizePathForComparison(targetPath) {
292
+ const normalized = path.normalize(targetPath);
293
+ return IS_WIN ? normalized.toLowerCase() : normalized;
294
+ }
295
+ function getCargoHomeCandidates() {
296
+ const candidates = new Set();
297
+ if (process.env.CARGO_HOME) {
298
+ candidates.add(process.env.CARGO_HOME);
299
+ }
300
+ const homeDir = os.homedir();
301
+ if (homeDir) {
302
+ candidates.add(path.join(homeDir, '.cargo'));
303
+ }
304
+ if (IS_WIN && process.env.USERPROFILE) {
305
+ candidates.add(path.join(process.env.USERPROFILE, '.cargo'));
306
+ }
307
+ return Array.from(candidates).filter(Boolean);
308
+ }
309
+ function ensureCargoBinOnPath() {
310
+ const currentPath = process.env.PATH || '';
311
+ const segments = currentPath.split(path.delimiter).filter(Boolean);
312
+ const normalizedSegments = new Set(segments.map((segment) => normalizePathForComparison(segment)));
313
+ const additions = [];
314
+ let cargoHomeSet = Boolean(process.env.CARGO_HOME);
315
+ for (const cargoHome of getCargoHomeCandidates()) {
316
+ const binDir = path.join(cargoHome, 'bin');
317
+ if (fsExtra.pathExistsSync(binDir) &&
318
+ !normalizedSegments.has(normalizePathForComparison(binDir))) {
319
+ additions.push(binDir);
320
+ normalizedSegments.add(normalizePathForComparison(binDir));
321
+ }
322
+ if (!cargoHomeSet && fsExtra.pathExistsSync(cargoHome)) {
323
+ process.env.CARGO_HOME = cargoHome;
324
+ cargoHomeSet = true;
325
+ }
326
+ }
327
+ if (additions.length) {
328
+ const prefix = additions.join(path.delimiter);
329
+ process.env.PATH = segments.length
330
+ ? `${prefix}${path.delimiter}${segments.join(path.delimiter)}`
331
+ : prefix;
332
+ }
333
+ }
334
+ function ensureRustEnv() {
335
+ ensureCargoBinOnPath();
336
+ }
337
+ async function installRust() {
338
+ const rustInstallScriptForUnix = isCnMirrorEnabled()
339
+ ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh'
340
+ : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y";
341
+ const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup';
342
+ const spinner = getSpinner('Downloading Rust...');
343
+ try {
344
+ await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined);
345
+ spinner.succeed(chalk.green('✔ Rust installed successfully!'));
346
+ ensureRustEnv();
347
+ }
348
+ catch (error) {
349
+ spinner.fail(chalk.red('✕ Rust installation failed!'));
350
+ if (error instanceof Error) {
351
+ console.error(error.message);
352
+ }
353
+ else {
354
+ console.error(error);
355
+ }
356
+ process.exit(1);
357
+ }
358
+ }
359
+ function checkRustInstalled() {
360
+ ensureCargoBinOnPath();
361
+ try {
362
+ execaSync('rustc', ['--version']);
363
+ return true;
364
+ }
365
+ catch {
366
+ return false;
367
+ }
368
+ }
369
+
370
+ async function combineFiles(files, output) {
371
+ const contents = await Promise.all(files.map(async (file) => {
372
+ if (file.endsWith('.css')) {
373
+ const fileContent = await fs.readFile(file, 'utf-8');
374
+ return `window.addEventListener('DOMContentLoaded', (_event) => {
375
+ const css = ${JSON.stringify(fileContent)};
376
+ const style = document.createElement('style');
377
+ style.textContent = css;
378
+ document.head.appendChild(style);
379
+ });`;
380
+ }
381
+ const fileContent = await fs.readFile(file);
382
+ return ("window.addEventListener('DOMContentLoaded', (_event) => { " +
383
+ fileContent +
384
+ ' });');
385
+ }));
386
+ await fs.writeFile(output, contents.join('\n'));
387
+ return files;
388
+ }
389
+
390
+ const logger = {
391
+ info(...msg) {
392
+ log.info(...msg.map((m) => chalk.white(m)));
393
+ },
394
+ debug(...msg) {
395
+ log.debug(...msg);
396
+ },
397
+ error(...msg) {
398
+ log.error(...msg.map((m) => chalk.red(m)));
399
+ },
400
+ warn(...msg) {
401
+ log.warn(...msg.map((m) => chalk.yellow(m)));
402
+ },
403
+ success(...msg) {
404
+ log.info(...msg.map((m) => chalk.green(m)));
405
+ },
406
+ };
407
+
408
+ const OG_IMAGE_MAX_SIZE = 500 * 1024; // 500KB
409
+ function generateSplashHtml(assetPath, iconPath, isIconFallback = false) {
410
+ if (isIconFallback) {
411
+ return `<!DOCTYPE html>
412
+ <html>
413
+ <head>
414
+ <meta charset="utf-8">
415
+ <style>
416
+ * { margin: 0; padding: 0; box-sizing: border-box; }
417
+ body {
418
+ display: flex; justify-content: center; align-items: center;
419
+ height: 100vh; background: #1a1a1a;
420
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
421
+ animation: fadeIn 0.3s ease-in;
422
+ overflow: hidden;
423
+ }
424
+ .icon-box {
425
+ display: flex; justify-content: center; align-items: center;
426
+ width: 200px; height: 200px; border-radius: 16px;
427
+ background: #2C2C2E;
428
+ box-shadow: 0 2px 12px rgba(0,0,0,0.3);
429
+ user-select: none; -webkit-user-select: none;
430
+ -webkit-user-drag: none;
431
+ }
432
+ .icon-box img {
433
+ max-width: 120px; max-height: 120px; object-fit: contain;
434
+ border-radius: 12px;
435
+ user-select: none; -webkit-user-select: none;
436
+ -webkit-user-drag: none;
437
+ pointer-events: none;
438
+ }
439
+ .loading-text {
440
+ position: fixed; bottom: 24px; left: 24px;
441
+ font-size: 13px; color: #636366; letter-spacing: 0.5px;
442
+ }
443
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
444
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
445
+ @keyframes pulse { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; } }
446
+ </style>
447
+ </head>
448
+ <body oncontextmenu="return false">
449
+ <div class="icon-box">
450
+ <img src="${assetPath}" alt="" draggable="false">
451
+ </div>
452
+ <div class="loading-text">Chargement\u2026</div>
453
+ </body>
454
+ </html>`;
455
+ }
456
+ return `<!DOCTYPE html>
457
+ <html>
458
+ <head>
459
+ <meta charset="utf-8">
460
+ <style>
461
+ * { margin: 0; padding: 0; box-sizing: border-box; }
462
+ body {
463
+ display: flex; justify-content: center; align-items: center;
464
+ height: 100vh; background: #1a1a1a;
465
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
466
+ animation: fadeIn 0.3s ease-in;
467
+ overflow: hidden;
468
+ }
469
+ img {
470
+ width: 100%; height: 100%; object-fit: cover;
471
+ border-radius: 16px;
472
+ user-select: none; -webkit-user-select: none;
473
+ -webkit-user-drag: none;
474
+ pointer-events: none;
475
+ }
476
+ .loading-text {
477
+ position: fixed; bottom: 24px; left: 24px;
478
+ font-size: 13px; color: #636366; letter-spacing: 0.5px;
479
+ }
480
+ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
481
+ @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } }
482
+ </style>
483
+ </head>
484
+ <body oncontextmenu="return false">
485
+ <img src="${assetPath}" alt="" draggable="false">
486
+ <div class="loading-text">Chargement\u2026</div>
487
+ </body>
488
+ </html>`;
489
+ }
490
+ function generateOfflineHtml() {
491
+ return `<!DOCTYPE html>
492
+ <html>
493
+ <head>
494
+ <meta charset="utf-8">
495
+ <style>
496
+ * { margin: 0; padding: 0; box-sizing: border-box; }
497
+ body {
498
+ display: flex; justify-content: center; align-items: center;
499
+ height: 100vh; background: #FFFFFF;
500
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
501
+ }
502
+ @media (prefers-color-scheme: dark) {
503
+ body { background: #1a1a1a; }
504
+ .heading { color: #FFFFFF; }
505
+ .subtext { color: #AEAEB2; }
506
+ .icon { stroke: #636366; }
507
+ .retry-btn { background: #0A84FF; }
508
+ .retry-btn:hover { background: #409CFF; }
509
+ }
510
+ .card { text-align: center; max-width: 400px; padding: 40px; }
511
+ .icon { width: 64px; height: 64px; stroke: #8E8E93; margin-bottom: 24px; }
512
+ .heading { font-size: 24px; font-weight: 700; color: #000000; margin-bottom: 8px; }
513
+ .subtext { font-size: 16px; color: #8E8E93; margin-bottom: 32px; }
514
+ .retry-btn {
515
+ display: inline-flex; align-items: center; justify-content: center;
516
+ min-width: 120px; height: 44px; padding: 0 16px;
517
+ background: #007AFF; color: #FFFFFF; border: none; border-radius: 8px;
518
+ font-size: 16px; font-weight: 600; cursor: pointer;
519
+ transition: background 0.15s;
520
+ }
521
+ .retry-btn:hover { background: #0056CC; }
522
+ .retry-btn:disabled { background: #C7C7CC; cursor: not-allowed; }
523
+ .retry-btn .spinner { display: none; width: 20px; height: 20px; border: 2px solid #FFF; border-top-color: transparent; border-radius: 50%; animation: spin 0.6s linear infinite; }
524
+ .retry-btn.loading .btn-text { display: none; }
525
+ .retry-btn.loading .spinner { display: inline-block; }
526
+ @keyframes spin { to { transform: rotate(360deg); } }
527
+ </style>
528
+ </head>
529
+ <body>
530
+ <div class="card">
531
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
532
+ <path d="M1 1l22 22"/>
533
+ <path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
534
+ <path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
535
+ <path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
536
+ <path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
537
+ <path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
538
+ <line x1="12" y1="20" x2="12.01" y2="20"/>
539
+ </svg>
540
+ <h1 class="heading">No Internet Connection</h1>
541
+ <p class="subtext">Check your network and try again</p>
542
+ <button class="retry-btn" onclick="retry()">
543
+ <span class="btn-text">Retry</span>
544
+ <span class="spinner"></span>
545
+ </button>
546
+ </div>
547
+ <script>
548
+ let cooldown = false;
549
+ function getLocalUrl(file) {
550
+ if (window.location.hostname === 'tauri.localhost' || window.location.protocol === 'tauri:') {
551
+ return file;
552
+ }
553
+ return 'https://tauri.localhost/' + file;
554
+ }
555
+ function retry() {
556
+ if (cooldown) return;
557
+ cooldown = true;
558
+ const btn = document.querySelector('.retry-btn');
559
+ btn.classList.add('loading');
560
+ btn.disabled = true;
561
+ setTimeout(() => {
562
+ const original = localStorage.getItem('bghitapp_original_url');
563
+ if (original) window.location.href = original;
564
+ else window.location.reload();
565
+ }, 3000);
566
+ }
567
+ </script>
568
+ </body>
569
+ </html>`;
570
+ }
571
+ async function fetchOgImage(url) {
572
+ try {
573
+ const response = await fetch(url, {
574
+ headers: { 'User-Agent': 'Mozilla/5.0 (compatible; BghitApp/1.0)' },
575
+ signal: AbortSignal.timeout(10000),
576
+ });
577
+ if (!response.ok)
578
+ return null;
579
+ const html = await response.text();
580
+ const ogMatch = html.match(/<meta\s+(?:[^>]*?)property=["']og:image["']\s+(?:[^>]*?)content=["']([^"']+)["']/i);
581
+ if (!ogMatch)
582
+ return null;
583
+ let imageUrl = ogMatch[1];
584
+ if (imageUrl.startsWith('/')) {
585
+ const base = new URL(url);
586
+ imageUrl = `${base.origin}${imageUrl}`;
587
+ }
588
+ else if (!imageUrl.startsWith('http')) {
589
+ const base = new URL(url);
590
+ imageUrl = new URL(imageUrl, base.origin).toString();
591
+ }
592
+ const imgResponse = await fetch(imageUrl, {
593
+ signal: AbortSignal.timeout(10000),
594
+ });
595
+ if (!imgResponse.ok)
596
+ return null;
597
+ const contentLength = imgResponse.headers.get('content-length');
598
+ if (contentLength && parseInt(contentLength) > OG_IMAGE_MAX_SIZE) {
599
+ logger.warn('✼ og:image too large, falling back to app icon.');
600
+ return null;
601
+ }
602
+ const contentType = imgResponse.headers.get('content-type');
603
+ if (contentType && !contentType.startsWith('image/')) {
604
+ return null;
605
+ }
606
+ return imageUrl;
607
+ }
608
+ catch {
609
+ return null;
610
+ }
611
+ }
612
+ async function processSplashAsset(splash, autoSplash, targetUrl, appIcon) {
613
+ const distDir = path.join(npmDirectory, 'dist');
614
+ if (splash) {
615
+ const resolved = path.resolve(splash);
616
+ if (resolved.startsWith('http://') || resolved.startsWith('https://')) {
617
+ const filename = `splash-asset${getExtension(resolved)}`;
618
+ const dest = path.join(distDir, filename);
619
+ try {
620
+ const response = await fetch(resolved, { signal: AbortSignal.timeout(15000) });
621
+ if (!response.ok)
622
+ throw new Error(`HTTP ${response.status}`);
623
+ const buffer = Buffer.from(await response.arrayBuffer());
624
+ await fsExtra.writeFile(dest, buffer);
625
+ return { assetFilename: filename, assetPath: filename };
626
+ }
627
+ catch (err) {
628
+ logger.warn(`✼ Failed to download splash image: ${err instanceof Error ? err.message : String(err)}`);
629
+ logger.warn('✼ Falling back to app icon.');
630
+ return { assetFilename: 'icon.png', assetPath: appIcon };
631
+ }
632
+ }
633
+ if (await fsExtra.pathExists(resolved)) {
634
+ const ext = path.extname(resolved);
635
+ const filename = `splash-asset${ext}`;
636
+ const dest = path.join(distDir, filename);
637
+ await fsExtra.copy(resolved, dest);
638
+ return { assetFilename: filename, assetPath: filename };
639
+ }
640
+ logger.warn('✼ Splash image not found, falling back to app icon.');
641
+ return { assetFilename: 'icon.png', assetPath: appIcon };
642
+ }
643
+ if (autoSplash) {
644
+ const ogUrl = await fetchOgImage(targetUrl);
645
+ if (ogUrl) {
646
+ const ext = getExtension(ogUrl);
647
+ const filename = `splash-asset${ext}`;
648
+ const dest = path.join(distDir, filename);
649
+ try {
650
+ const response = await fetch(ogUrl, { signal: AbortSignal.timeout(15000) });
651
+ if (!response.ok)
652
+ throw new Error(`HTTP ${response.status}`);
653
+ const buffer = Buffer.from(await response.arrayBuffer());
654
+ if (buffer.length > OG_IMAGE_MAX_SIZE) {
655
+ logger.warn('✼ Downloaded og:image too large, falling back to app icon.');
656
+ return { assetFilename: 'icon.png', assetPath: appIcon };
657
+ }
658
+ await fsExtra.writeFile(dest, buffer);
659
+ return { assetFilename: filename, assetPath: filename };
660
+ }
661
+ catch {
662
+ logger.warn('✼ Failed to download og:image, falling back to app icon.');
663
+ }
664
+ }
665
+ return { assetFilename: 'icon.png', assetPath: appIcon };
666
+ }
667
+ return { assetFilename: 'icon.png', assetPath: appIcon };
668
+ }
669
+ function getExtension(urlOrPath) {
670
+ try {
671
+ const url = new URL(urlOrPath);
672
+ const pathname = url.pathname;
673
+ const ext = path.extname(pathname).split('?')[0];
674
+ if (ext && ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext.toLowerCase())) {
675
+ return ext;
676
+ }
677
+ }
678
+ catch {
679
+ const ext = path.extname(urlOrPath);
680
+ if (ext)
681
+ return ext;
682
+ }
683
+ return '.png';
684
+ }
685
+
686
+ function generateSafeFilename(name) {
687
+ return name
688
+ .replace(/[<>:"/\\|?*]/g, '_')
689
+ .replace(/\s+/g, '_')
690
+ .replace(/\.+$/g, '')
691
+ .slice(0, 255);
692
+ }
693
+ function getSafeAppName(name) {
694
+ return generateSafeFilename(name).toLowerCase();
695
+ }
696
+ function generateLinuxPackageName(name) {
697
+ return name
698
+ .toLowerCase()
699
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-')
700
+ .replace(/^-+|-+$/g, '')
701
+ .replace(/-+/g, '-');
702
+ }
703
+ function generateIdentifierSafeName(name) {
704
+ const cleaned = name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').toLowerCase();
705
+ if (cleaned === '') {
706
+ const fallback = Array.from(name)
707
+ .map((char) => {
708
+ const code = char.charCodeAt(0);
709
+ if ((code >= 48 && code <= 57) ||
710
+ (code >= 65 && code <= 90) ||
711
+ (code >= 97 && code <= 122)) {
712
+ return char.toLowerCase();
713
+ }
714
+ return code.toString(16);
715
+ })
716
+ .join('')
717
+ .slice(0, 50);
718
+ return fallback || 'bghitapp-app';
719
+ }
720
+ return cleaned;
721
+ }
722
+
723
+ /**
724
+ * Pure transform from CLI options to the window-config slice that gets
725
+ * merged into bghitapp.json. Exposed for snapshot testing so option drift
726
+ * (e.g. a new flag added in cli-program.ts but forgotten here) is caught.
727
+ *
728
+ * Keep this function side-effect free.
729
+ */
730
+ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) {
731
+ const platformHideOnClose = options.hideOnClose ?? platform === 'darwin';
732
+ return {
733
+ width: options.width,
734
+ height: options.height,
735
+ fullscreen: options.fullscreen,
736
+ maximize: options.maximize,
737
+ resizable: options.resizable ?? true,
738
+ hide_title_bar: options.hideTitleBar,
739
+ activation_shortcut: options.activationShortcut,
740
+ always_on_top: options.alwaysOnTop,
741
+ dark_mode: options.darkMode,
742
+ disabled_web_shortcuts: options.disabledWebShortcuts,
743
+ hide_on_close: platformHideOnClose,
744
+ incognito: options.incognito,
745
+ title: options.title,
746
+ enable_wasm: options.wasm,
747
+ enable_drag_drop: options.enableDragDrop,
748
+ start_to_tray: options.startToTray && options.showSystemTray,
749
+ force_internal_navigation: options.forceInternalNavigation,
750
+ internal_url_regex: options.internalUrlRegex,
751
+ enable_find: options.enableFind,
752
+ zoom: options.zoom,
753
+ min_width: options.minWidth,
754
+ min_height: options.minHeight,
755
+ ignore_certificate_errors: options.ignoreCertificateErrors,
756
+ new_window: options.newWindow,
757
+ };
758
+ }
759
+ function asSupportedPlatform(platform) {
760
+ if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') {
761
+ throw new Error(`BghitApp only supports win32, darwin, and linux; detected '${platform}'.`);
762
+ }
763
+ return platform;
764
+ }
765
+ async function copyTemplateConfigs() {
766
+ const srcTauriDir = path.join(npmDirectory, 'src-tauri');
767
+ await fsExtra.ensureDir(tauriConfigDirectory);
768
+ const sourceFiles = [
769
+ 'tauri.conf.json',
770
+ 'tauri.macos.conf.json',
771
+ 'tauri.windows.conf.json',
772
+ 'tauri.linux.conf.json',
773
+ 'bghitapp.json',
774
+ ];
775
+ await Promise.all(sourceFiles.map(async (file) => {
776
+ const sourcePath = path.join(srcTauriDir, file);
777
+ const destPath = path.join(tauriConfigDirectory, file);
778
+ if ((await fsExtra.pathExists(sourcePath)) &&
779
+ !(await fsExtra.pathExists(destPath))) {
780
+ await fsExtra.copy(sourcePath, destPath);
781
+ }
782
+ }));
783
+ }
784
+ async function handleLocalFile(url, useLocalFile, tauriConf) {
785
+ const pathExists = await fsExtra.pathExists(url);
786
+ if (pathExists) {
787
+ logger.warn('✼ Your input might be a local file.');
788
+ const fileName = path.basename(url);
789
+ const dirName = path.dirname(url);
790
+ const distDir = path.join(npmDirectory, 'dist');
791
+ const distBakDir = path.join(npmDirectory, 'dist_bak');
792
+ if (!useLocalFile) {
793
+ const urlPath = path.join(distDir, fileName);
794
+ await fsExtra.copy(url, urlPath);
795
+ }
796
+ else {
797
+ fsExtra.moveSync(distDir, distBakDir, { overwrite: true });
798
+ fsExtra.copySync(dirName, distDir, { overwrite: true });
799
+ const filesToCopyBack = ['cli.js'];
800
+ await Promise.all(filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file))));
801
+ }
802
+ tauriConf.bghitapp.windows[0].url = fileName;
803
+ tauriConf.bghitapp.windows[0].url_type = 'local';
804
+ }
805
+ else {
806
+ tauriConf.bghitapp.windows[0].url_type = 'web';
807
+ }
808
+ }
809
+ async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) {
810
+ const linuxBundle = tauriConf.bundle.linux;
811
+ if (!linuxBundle) {
812
+ throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.');
813
+ }
814
+ delete linuxBundle.deb.files;
815
+ const linuxName = generateLinuxPackageName(name);
816
+ const desktopFileName = `com.bghitapp.${linuxName}.desktop`;
817
+ const iconName = `${linuxName}_512`;
818
+ const { title } = options;
819
+ const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
820
+ const desktopContent = `[Desktop Entry]
821
+ Version=1.0
822
+ Type=Application
823
+ Name=${name}
824
+ ${chineseName ? `Name[zh_CN]=${chineseName}` : ''}
825
+ Comment=${name}
826
+ Exec=${linuxBinaryName}
827
+ Icon=${iconName}
828
+ Categories=Network;WebBrowser;Utility;
829
+ MimeType=text/html;text/xml;application/xhtml_xml;
830
+ StartupNotify=true
831
+ Terminal=false
832
+ `;
833
+ const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets');
834
+ const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName);
835
+ await fsExtra.ensureDir(srcAssetsDir);
836
+ await fsExtra.writeFile(srcDesktopFilePath, desktopContent);
837
+ const desktopInstallPath = `/usr/share/applications/${desktopFileName}`;
838
+ linuxBundle.deb.files = {
839
+ [desktopInstallPath]: `assets/${desktopFileName}`,
840
+ };
841
+ if (!linuxBundle.rpm) {
842
+ linuxBundle.rpm = {};
843
+ }
844
+ linuxBundle.rpm.files = {
845
+ [desktopInstallPath]: `assets/${desktopFileName}`,
846
+ };
847
+ const validTargets = [
848
+ 'deb',
849
+ 'appimage',
850
+ 'rpm',
851
+ 'deb-arm64',
852
+ 'appimage-arm64',
853
+ 'rpm-arm64',
854
+ ];
855
+ const baseTarget = options.targets.includes('-arm64')
856
+ ? options.targets.replace('-arm64', '')
857
+ : options.targets;
858
+ if (validTargets.includes(options.targets)) {
859
+ tauriConf.bundle.targets = [baseTarget];
860
+ }
861
+ else {
862
+ logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`);
863
+ }
864
+ }
865
+ async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
866
+ const platformIconMap = {
867
+ win32: {
868
+ fileExt: '.ico',
869
+ path: `png/${safeAppName}_256.ico`,
870
+ defaultIcon: 'png/icon_256.ico',
871
+ message: 'Windows icon must be .ico and 256x256px.',
872
+ },
873
+ linux: {
874
+ fileExt: '.png',
875
+ path: `png/${generateLinuxPackageName(name)}_512.png`,
876
+ defaultIcon: 'png/icon_512.png',
877
+ message: 'Linux icon must be .png and 512x512px.',
878
+ },
879
+ darwin: {
880
+ fileExt: '.icns',
881
+ path: `icons/${safeAppName}.icns`,
882
+ defaultIcon: 'icons/icon.icns',
883
+ message: 'macOS icon must be .icns type.',
884
+ },
885
+ };
886
+ const iconInfo = platformIconMap[platform];
887
+ const resolvedIconPath = options.icon ? path.resolve(options.icon) : null;
888
+ const exists = resolvedIconPath && (await fsExtra.pathExists(resolvedIconPath));
889
+ if (exists) {
890
+ let updateIconPath = true;
891
+ const customIconExt = path.extname(resolvedIconPath).toLowerCase();
892
+ if (customIconExt !== iconInfo.fileExt) {
893
+ updateIconPath = false;
894
+ logger.warn(`✼ ${iconInfo.message}, but you give ${customIconExt}`);
895
+ tauriConf.bundle.icon = [iconInfo.defaultIcon];
896
+ }
897
+ else {
898
+ const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path);
899
+ tauriConf.bundle.resources = [iconInfo.path];
900
+ const absoluteDestPath = path.resolve(iconPath);
901
+ if (resolvedIconPath !== absoluteDestPath) {
902
+ try {
903
+ await fsExtra.copy(resolvedIconPath, iconPath);
904
+ }
905
+ catch (error) {
906
+ if (!(error instanceof Error &&
907
+ error.message.includes('Source and destination must not be the same'))) {
908
+ throw error;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ if (updateIconPath) {
914
+ tauriConf.bundle.icon = [iconInfo.path];
915
+ }
916
+ else {
917
+ logger.warn(`✼ Icon will remain as default.`);
918
+ }
919
+ }
920
+ else {
921
+ logger.warn('✼ Custom icon path may be invalid, default icon will be used instead.');
922
+ tauriConf.bundle.icon = [iconInfo.defaultIcon];
923
+ }
924
+ // Set tray icon path.
925
+ let trayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
926
+ if (options.systemTrayIcon.length > 0) {
927
+ try {
928
+ await fsExtra.pathExists(options.systemTrayIcon);
929
+ const iconExt = path.extname(options.systemTrayIcon).toLowerCase();
930
+ if (iconExt === '.png' || iconExt === '.ico') {
931
+ const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`);
932
+ trayIconPath = `png/${safeAppName}${iconExt}`;
933
+ await fsExtra.copy(options.systemTrayIcon, trayIcoPath);
934
+ }
935
+ else {
936
+ logger.warn(`✼ System tray icon must be .ico or .png, but you provided ${iconExt}.`);
937
+ logger.warn(`✼ Default system tray icon will be used.`);
938
+ }
939
+ }
940
+ catch (err) {
941
+ logger.warn(`✼ Failed to apply system tray icon "${options.systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`);
942
+ logger.warn(`✼ Default system tray icon will remain unchanged.`);
943
+ }
944
+ }
945
+ tauriConf.bghitapp.system_tray_path = trayIconPath;
946
+ delete tauriConf.app.trayIcon;
947
+ }
948
+ async function injectCustomCode(options, tauriConf) {
949
+ const { inject, proxyUrl, multiInstance, multiWindow, wasm } = options;
950
+ const injectFilePath = path.join(npmDirectory, 'src-tauri/src/inject/custom.js');
951
+ if (inject?.length > 0) {
952
+ const injectArray = Array.isArray(inject) ? inject : [inject];
953
+ if (!injectArray.every((item) => item.endsWith('.css') || item.endsWith('.js'))) {
954
+ logger.error('The injected file must be in either CSS or JS format.');
955
+ return;
956
+ }
957
+ const files = injectArray.map((filepath) => path.isAbsolute(filepath) ? filepath : path.join(process.cwd(), filepath));
958
+ tauriConf.bghitapp.inject = files;
959
+ await combineFiles(files, injectFilePath);
960
+ }
961
+ else {
962
+ tauriConf.bghitapp.inject = [];
963
+ await fsExtra.writeFile(injectFilePath, '');
964
+ }
965
+ tauriConf.bghitapp.proxy_url = proxyUrl || '';
966
+ tauriConf.bghitapp.multi_instance = multiInstance;
967
+ tauriConf.bghitapp.multi_window = multiWindow;
968
+ if (wasm) {
969
+ tauriConf.app.security = {
970
+ headers: {
971
+ 'Cross-Origin-Opener-Policy': 'same-origin',
972
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
973
+ },
974
+ };
975
+ }
976
+ }
977
+ async function generateMacEntitlements(camera, microphone) {
978
+ const entitlementEntries = [];
979
+ if (camera) {
980
+ entitlementEntries.push(' <key>com.apple.security.device.camera</key>\n <true/>');
981
+ }
982
+ if (microphone) {
983
+ entitlementEntries.push(' <key>com.apple.security.device.audio-input</key>\n <true/>');
984
+ }
985
+ const entitlementsContent = `<?xml version="1.0" encoding="UTF-8"?>
986
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
987
+ <plist version="1.0">
988
+ <dict>
989
+ ${entitlementEntries.join('\n')}
990
+ </dict>
991
+ </plist>
992
+ `;
993
+ const entitlementsPath = path.join(npmDirectory, 'src-tauri', 'entitlements.plist');
994
+ await fsExtra.writeFile(entitlementsPath, entitlementsContent);
995
+ }
996
+ async function writeAllConfigs(tauriConf, platform) {
997
+ const platformConfigPaths = {
998
+ win32: 'tauri.windows.conf.json',
999
+ darwin: 'tauri.macos.conf.json',
1000
+ linux: 'tauri.linux.conf.json',
1001
+ };
1002
+ const configPath = path.join(tauriConfigDirectory, platformConfigPaths[platform]);
1003
+ const bundleConf = { bundle: tauriConf.bundle };
1004
+ await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 });
1005
+ const pakeConfigPath = path.join(tauriConfigDirectory, 'bghitapp.json');
1006
+ await fsExtra.outputJSON(pakeConfigPath, tauriConf.bghitapp, { spaces: 4 });
1007
+ const tauriConf2 = JSON.parse(JSON.stringify(tauriConf));
1008
+ delete tauriConf2.bghitapp;
1009
+ const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json');
1010
+ await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 });
1011
+ }
1012
+ async function mergeConfig(url, options, tauriConf) {
1013
+ await copyTemplateConfigs();
1014
+ const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'bghitapp-app', installerLanguage, wasm, camera, microphone, splash, autoSplash, offline, } = options;
1015
+ const platform = asSupportedPlatform(process.platform);
1016
+ const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform);
1017
+ Object.assign(tauriConf.bghitapp.windows[0], { url, ...tauriConfWindowOptions });
1018
+ tauriConf.productName = name;
1019
+ tauriConf.identifier = identifier;
1020
+ tauriConf.version = appVersion;
1021
+ const linuxBinaryName = `bghitapp-${generateLinuxPackageName(name)}`;
1022
+ tauriConf.mainBinaryName =
1023
+ platform === 'linux'
1024
+ ? linuxBinaryName
1025
+ : `bghitapp-${generateIdentifierSafeName(name)}`;
1026
+ if (platform === 'win32') {
1027
+ const windowsBundle = tauriConf.bundle.windows;
1028
+ if (!windowsBundle) {
1029
+ throw new Error('Windows bundle configuration is missing from tauri.windows.conf.json; cannot build Windows target.');
1030
+ }
1031
+ windowsBundle.wix.language[0] = installerLanguage;
1032
+ }
1033
+ await handleLocalFile(url, useLocalFile, tauriConf);
1034
+ const platformMap = {
1035
+ win32: 'windows',
1036
+ linux: 'linux',
1037
+ darwin: 'macos',
1038
+ };
1039
+ const currentPlatform = platformMap[platform];
1040
+ if (userAgent.length > 0) {
1041
+ tauriConf.bghitapp.user_agent[currentPlatform] = userAgent;
1042
+ }
1043
+ tauriConf.bghitapp.system_tray[currentPlatform] = showSystemTray;
1044
+ if (platform === 'linux') {
1045
+ await mergeLinuxConfig(options, name, tauriConf, linuxBinaryName);
1046
+ }
1047
+ if (platform === 'darwin') {
1048
+ const validMacTargets = ['app', 'dmg'];
1049
+ if (validMacTargets.includes(options.targets)) {
1050
+ tauriConf.bundle.targets = [options.targets];
1051
+ }
1052
+ }
1053
+ const safeAppName = getSafeAppName(name);
1054
+ await mergeIcons(options, name, tauriConf, platform, safeAppName);
1055
+ await injectCustomCode(options, tauriConf);
1056
+ // Process splash screen
1057
+ if (splash || autoSplash) {
1058
+ const distDir = path.join(npmDirectory, 'dist');
1059
+ await fsExtra.ensureDir(distDir);
1060
+ const resolvedIcon = options.icon ? path.resolve(options.icon) : '';
1061
+ const { assetFilename, assetPath } = await processSplashAsset(splash, autoSplash, url, resolvedIcon);
1062
+ const isIconFallback = assetFilename === 'icon.png';
1063
+ const splashHtml = generateSplashHtml(assetPath, resolvedIcon, isIconFallback);
1064
+ await fsExtra.writeFile(path.join(distDir, 'splash.html'), splashHtml);
1065
+ tauriConf.bghitapp.windows[0].splash = assetFilename;
1066
+ logger.info('✼ Splash screen configured.');
1067
+ }
1068
+ // Process offline page
1069
+ if (offline) {
1070
+ const distDir = path.join(npmDirectory, 'dist');
1071
+ await fsExtra.ensureDir(distDir);
1072
+ const offlineHtml = generateOfflineHtml();
1073
+ await fsExtra.writeFile(path.join(distDir, 'offline.html'), offlineHtml);
1074
+ tauriConf.bghitapp.windows[0].offline = true;
1075
+ logger.info('✼ Offline page configured.');
1076
+ }
1077
+ if (platform === 'darwin') {
1078
+ await generateMacEntitlements(camera, microphone);
1079
+ }
1080
+ await writeAllConfigs(tauriConf, platform);
1081
+ }
1082
+
1083
+ /**
1084
+ * Returns build environment variables overrides for macOS, where Rust crates
1085
+ * sometimes need explicit C/C++ flags and a deterministic SDK target. Other
1086
+ * platforms inherit `process.env` unchanged.
1087
+ */
1088
+ function getBuildEnvironment() {
1089
+ if (!IS_MAC) {
1090
+ return undefined;
1091
+ }
1092
+ const currentPath = process.env.PATH || '';
1093
+ const systemToolsPath = '/usr/bin';
1094
+ const buildPath = currentPath.startsWith(`${systemToolsPath}:`)
1095
+ ? currentPath
1096
+ : `${systemToolsPath}:${currentPath}`;
1097
+ return {
1098
+ CFLAGS: '-fno-modules',
1099
+ CXXFLAGS: '-fno-modules',
1100
+ MACOSX_DEPLOYMENT_TARGET: '14.0',
1101
+ PATH: buildPath,
1102
+ };
1103
+ }
1104
+ /**
1105
+ * Windows needs more time due to native compilation and antivirus scanning.
1106
+ */
1107
+ function getInstallTimeout() {
1108
+ return process.platform === 'win32' ? 900000 : 600000;
1109
+ }
1110
+ function getBuildTimeout() {
1111
+ return 900000;
1112
+ }
1113
+ let packageManagerCache = null;
1114
+ function parseMajorVersion(version) {
1115
+ const match = version.match(/^(\d+)/);
1116
+ return match ? Number(match[1]) : null;
1117
+ }
1118
+ function getPinnedPnpmMajorVersion() {
1119
+ const packageManager = packageJson.packageManager;
1120
+ const match = packageManager?.match(/^pnpm@(\d+)/);
1121
+ return match ? Number(match[1]) : null;
1122
+ }
1123
+ async function detectNpm(execa) {
1124
+ try {
1125
+ await execa('npm', ['--version'], { stdio: 'ignore' });
1126
+ return true;
1127
+ }
1128
+ catch {
1129
+ return false;
1130
+ }
1131
+ }
1132
+ /**
1133
+ * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found.
1134
+ * Cached after the first successful detection so tests can call repeatedly.
1135
+ */
1136
+ async function detectPackageManager() {
1137
+ if (packageManagerCache) {
1138
+ return packageManagerCache;
1139
+ }
1140
+ const { execa } = await import('execa');
1141
+ try {
1142
+ const { stdout } = await execa('pnpm', ['--version']);
1143
+ const pnpmMajor = parseMajorVersion(stdout.trim());
1144
+ const pinnedPnpmMajor = getPinnedPnpmMajorVersion();
1145
+ if (pnpmMajor !== null &&
1146
+ pinnedPnpmMajor !== null &&
1147
+ pnpmMajor !== pinnedPnpmMajor &&
1148
+ (await detectNpm(execa))) {
1149
+ logger.warn(`✼ Detected pnpm v${stdout.trim()}, but BghitApp is pinned to ${packageJson.packageManager}; using npm for package installation instead.`);
1150
+ packageManagerCache = 'npm';
1151
+ return 'npm';
1152
+ }
1153
+ logger.info('✺ Using pnpm for package management.');
1154
+ packageManagerCache = 'pnpm';
1155
+ return 'pnpm';
1156
+ }
1157
+ catch {
1158
+ if (await detectNpm(execa)) {
1159
+ logger.info('✺ pnpm not available, using npm for package management.');
1160
+ packageManagerCache = 'npm';
1161
+ return 'npm';
1162
+ }
1163
+ throw new Error('Neither pnpm nor npm is available. Please install a package manager.');
1164
+ }
1165
+ }
1166
+ function getInstallCommand(packageManager, useCnMirror) {
1167
+ const registryOption = useCnMirror
1168
+ ? ' --registry=https://registry.npmmirror.com'
1169
+ : '';
1170
+ const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : '';
1171
+ return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`;
1172
+ }
1173
+ async function copyFileWithSamePathGuard(sourcePath, destinationPath) {
1174
+ if (path.resolve(sourcePath) === path.resolve(destinationPath)) {
1175
+ return;
1176
+ }
1177
+ try {
1178
+ await fsExtra.copy(sourcePath, destinationPath, { overwrite: true });
1179
+ }
1180
+ catch (error) {
1181
+ if (error instanceof Error &&
1182
+ error.message.includes('Source and destination must not be the same')) {
1183
+ return;
1184
+ }
1185
+ throw error;
1186
+ }
1187
+ }
1188
+ function isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) {
1189
+ return projectConfig.trim() === cnMirrorConfig.trim();
1190
+ }
1191
+ /**
1192
+ * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in
1193
+ * via `BGHITAPP_USE_CN_MIRROR=1`, and removes the auto-generated mirror config
1194
+ * (or warns about a manual one) when they opt out.
1195
+ */
1196
+ async function configureCargoRegistry(tauriSrcPath, useCnMirror) {
1197
+ const rustProjectDir = path.join(tauriSrcPath, '.cargo');
1198
+ const projectConf = path.join(rustProjectDir, 'config.toml');
1199
+ const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml');
1200
+ if (useCnMirror) {
1201
+ await fsExtra.ensureDir(rustProjectDir);
1202
+ await copyFileWithSamePathGuard(projectCnConf, projectConf);
1203
+ return;
1204
+ }
1205
+ if (!(await fsExtra.pathExists(projectConf))) {
1206
+ return;
1207
+ }
1208
+ const [projectConfig, cnMirrorConfig] = await Promise.all([
1209
+ fsExtra.readFile(projectConf, 'utf8'),
1210
+ fsExtra.readFile(projectCnConf, 'utf8'),
1211
+ ]);
1212
+ if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) {
1213
+ await fsExtra.remove(projectConf);
1214
+ return;
1215
+ }
1216
+ if (projectConfig.includes('rsproxy.cn')) {
1217
+ logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`);
1218
+ }
1219
+ }
1220
+ /**
1221
+ * Returns true when an error string looks like the well-known Tauri+linuxdeploy
1222
+ * strip failure that we automatically retry with NO_STRIP=1.
1223
+ */
1224
+ function isLinuxDeployStripError(error) {
1225
+ if (!(error instanceof Error) || !error.message) {
1226
+ return false;
1227
+ }
1228
+ const message = error.message.toLowerCase();
1229
+ return (message.includes('linuxdeploy') ||
1230
+ message.includes('failed to run linuxdeploy') ||
1231
+ message.includes('strip:') ||
1232
+ message.includes('unable to recognise the format of the input file') ||
1233
+ message.includes('appimage tool failed') ||
1234
+ message.includes('strip tool'));
1235
+ }
1236
+
1237
+ class BaseBuilder {
1238
+ constructor(options) {
1239
+ this.options = options;
1240
+ }
1241
+ async prepare() {
1242
+ const tauriSrcPath = path.join(npmDirectory, 'src-tauri');
1243
+ const tauriTargetPath = path.join(tauriSrcPath, 'target');
1244
+ const tauriTargetPathExists = await fsExtra.pathExists(tauriTargetPath);
1245
+ if (!IS_MAC && !tauriTargetPathExists) {
1246
+ logger.warn('✼ The first use requires installing system dependencies.');
1247
+ logger.warn('✼ See more in https://tauri.app/start/prerequisites/.');
1248
+ }
1249
+ ensureRustEnv();
1250
+ if (!checkRustInstalled()) {
1251
+ const res = await prompts({
1252
+ type: 'confirm',
1253
+ message: 'Rust not detected. Install now?',
1254
+ name: 'value',
1255
+ });
1256
+ if (res.value) {
1257
+ await installRust();
1258
+ }
1259
+ else {
1260
+ logger.error('✕ Rust required to package your webapp.');
1261
+ process.exit(1);
1262
+ }
1263
+ }
1264
+ const spinner = getSpinner('Installing package...');
1265
+ const useCnMirror = isCnMirrorEnabled();
1266
+ await configureCargoRegistry(tauriSrcPath, useCnMirror);
1267
+ const packageManager = await detectPackageManager();
1268
+ const timeout = getInstallTimeout();
1269
+ const buildEnv = getBuildEnvironment();
1270
+ // Show helpful message for first-time users
1271
+ if (!tauriTargetPathExists) {
1272
+ logger.info(process.platform === 'win32'
1273
+ ? '✺ First-time setup may take 10-15 minutes on Windows (compiling dependencies)...'
1274
+ : '✺ First-time setup may take 5-10 minutes (installing dependencies)...');
1275
+ }
1276
+ if (useCnMirror) {
1277
+ logger.info(`✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`);
1278
+ }
1279
+ try {
1280
+ await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, {
1281
+ ...buildEnv,
1282
+ CI: 'true',
1283
+ });
1284
+ spinner.succeed(chalk.green('Package installed!'));
1285
+ }
1286
+ catch (error) {
1287
+ spinner.fail(chalk.red('Installation failed'));
1288
+ if (!useCnMirror) {
1289
+ logger.info(`✺ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`);
1290
+ }
1291
+ throw error;
1292
+ }
1293
+ if (!tauriTargetPathExists) {
1294
+ logger.warn('✼ The first packaging may be slow, please be patient and wait, it will be faster afterwards.');
1295
+ }
1296
+ }
1297
+ async build(url) {
1298
+ await this.buildAndCopy(url, this.options.targets);
1299
+ }
1300
+ async start(url) {
1301
+ logger.info('BghitApp dev server starting...');
1302
+ await mergeConfig(url, this.options, tauriConfig);
1303
+ const packageManager = await detectPackageManager();
1304
+ const configPath = path.join(npmDirectory, 'src-tauri', '.bghitapp', 'tauri.conf.json');
1305
+ const features = this.getBuildFeatures();
1306
+ const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : '';
1307
+ const argSeparator = packageManager === 'npm' ? ' --' : '';
1308
+ const command = `cd "${npmDirectory}" && ${packageManager} run tauri${argSeparator} dev --config "${configPath}" ${featureArgs}`;
1309
+ await shellExec(command);
1310
+ }
1311
+ async buildAndCopy(url, target) {
1312
+ const { name = 'bghitapp-app' } = this.options;
1313
+ await mergeConfig(url, this.options, tauriConfig);
1314
+ const packageManager = await detectPackageManager();
1315
+ // Build app
1316
+ const buildSpinner = getSpinner('Building app...');
1317
+ // Let spinner run for a moment so user can see it, then stop before package manager command
1318
+ await new Promise((resolve) => setTimeout(resolve, 500));
1319
+ buildSpinner.stop();
1320
+ // Show static message to keep the status visible
1321
+ logger.warn('✸ Building app...');
1322
+ const baseEnv = getBuildEnvironment();
1323
+ let buildEnv = {
1324
+ ...(baseEnv ?? {}),
1325
+ ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}),
1326
+ };
1327
+ const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined;
1328
+ // Warn users about potential AppImage build failures on modern Linux systems.
1329
+ // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't
1330
+ // recognize the .relr.dyn section introduced in glibc 2.38+.
1331
+ if (process.platform === 'linux' && target === 'appimage') {
1332
+ if (!buildEnv.NO_STRIP) {
1333
+ logger.warn('⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+');
1334
+ logger.warn('⚠ If build fails, retry with: NO_STRIP=1 bghitapp <url> --targets appimage');
1335
+ }
1336
+ }
1337
+ const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`;
1338
+ const buildTimeout = getBuildTimeout();
1339
+ try {
1340
+ await shellExec(buildCommand, buildTimeout, resolveExecEnv());
1341
+ }
1342
+ catch (error) {
1343
+ const shouldRetryWithoutStrip = process.platform === 'linux' &&
1344
+ target === 'appimage' &&
1345
+ !buildEnv.NO_STRIP &&
1346
+ isLinuxDeployStripError(error);
1347
+ if (shouldRetryWithoutStrip) {
1348
+ logger.warn('⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.');
1349
+ buildEnv = {
1350
+ ...buildEnv,
1351
+ NO_STRIP: '1',
1352
+ };
1353
+ await shellExec(buildCommand, buildTimeout, resolveExecEnv());
1354
+ }
1355
+ else {
1356
+ throw error;
1357
+ }
1358
+ }
1359
+ // Copy app
1360
+ const fileName = this.getFileName();
1361
+ const fileType = this.getFileType(target);
1362
+ const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType);
1363
+ const distPath = path.resolve(`${name}.${fileType}`);
1364
+ await fsExtra.copy(appPath, distPath);
1365
+ // Copy raw binary if requested
1366
+ if (this.options.keepBinary) {
1367
+ await this.copyRawBinary(npmDirectory, name);
1368
+ }
1369
+ await fsExtra.remove(appPath);
1370
+ logger.success('✔ Build success!');
1371
+ logger.success('✔ App installer located in', distPath);
1372
+ // Log binary location if preserved
1373
+ if (this.options.keepBinary) {
1374
+ const binaryPath = this.getRawBinaryPath(name);
1375
+ logger.success('✔ Raw binary located in', path.resolve(binaryPath));
1376
+ }
1377
+ if (IS_MAC && fileType === 'app' && this.options.install) {
1378
+ await this.installAppToApplications(distPath, name);
1379
+ }
1380
+ }
1381
+ async installAppToApplications(appBundlePath, appName) {
1382
+ try {
1383
+ logger.info(`- Installing ${appName} to /Applications...`);
1384
+ const appBundleName = path.basename(appBundlePath);
1385
+ const appDest = path.join('/Applications', appBundleName);
1386
+ if (await fsExtra.pathExists(appDest)) {
1387
+ logger.warn(` Existing ${appBundleName} in /Applications will be replaced.`);
1388
+ }
1389
+ // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back
1390
+ // to copy+remove only when moving across volumes.
1391
+ await fsExtra.move(appBundlePath, appDest, { overwrite: true });
1392
+ logger.success(`✔ ${appBundleName.replace(/\.app$/, '')} installed to /Applications`);
1393
+ }
1394
+ catch (error) {
1395
+ logger.error(`✕ Failed to install ${appName}: ${error}`);
1396
+ logger.info(` App bundle still available at: ${appBundlePath}`);
1397
+ }
1398
+ }
1399
+ getFileType(target) {
1400
+ return target;
1401
+ }
1402
+ resolveTargetArch(requestedArch) {
1403
+ if (requestedArch === 'auto' || !requestedArch) {
1404
+ return process.arch;
1405
+ }
1406
+ return requestedArch;
1407
+ }
1408
+ getTauriTarget(arch, platform = process.platform) {
1409
+ const platformMappings = BaseBuilder.ARCH_MAPPINGS[platform];
1410
+ if (!platformMappings)
1411
+ return null;
1412
+ return platformMappings[arch] || null;
1413
+ }
1414
+ getArchDisplayName(arch) {
1415
+ return BaseBuilder.ARCH_DISPLAY_NAMES[arch] || arch;
1416
+ }
1417
+ buildBaseCommand(packageManager, configPath, target) {
1418
+ const baseCommand = this.options.debug
1419
+ ? `${packageManager} run build:debug`
1420
+ : `${packageManager} run build`;
1421
+ const argSeparator = packageManager === 'npm' ? ' --' : '';
1422
+ let fullCommand = `${baseCommand}${argSeparator} -c "${configPath}"`;
1423
+ if (target) {
1424
+ fullCommand += ` --target ${target}`;
1425
+ }
1426
+ // Enable verbose output in debug mode to help diagnose build issues.
1427
+ // This provides detailed logs from Tauri CLI and bundler tools.
1428
+ if (this.options.debug) {
1429
+ fullCommand += ' --verbose';
1430
+ }
1431
+ const features = this.getBuildFeatures();
1432
+ if (features.length > 0) {
1433
+ fullCommand += ` --features ${features.join(',')}`;
1434
+ }
1435
+ return fullCommand;
1436
+ }
1437
+ getBuildFeatures() {
1438
+ const features = ['cli-build'];
1439
+ // Add macos-proxy feature for modern macOS (Darwin 23+ = macOS 14+)
1440
+ if (IS_MAC) {
1441
+ const macOSVersion = this.getMacOSMajorVersion();
1442
+ if (macOSVersion >= 23) {
1443
+ features.push('macos-proxy');
1444
+ }
1445
+ }
1446
+ return features;
1447
+ }
1448
+ getBuildCommand(packageManager = 'pnpm') {
1449
+ // Use temporary config directory to avoid modifying source files
1450
+ const configPath = path.join(npmDirectory, 'src-tauri', '.bghitapp', 'tauri.conf.json');
1451
+ let fullCommand = this.buildBaseCommand(packageManager, configPath);
1452
+ // For macOS, use app bundles by default unless DMG is explicitly requested
1453
+ if (IS_MAC && this.options.targets === 'app') {
1454
+ fullCommand += ' --bundles app';
1455
+ }
1456
+ return fullCommand;
1457
+ }
1458
+ getMacOSMajorVersion() {
1459
+ try {
1460
+ const os = require('os');
1461
+ const release = os.release();
1462
+ const majorVersion = parseInt(release.split('.')[0], 10);
1463
+ return majorVersion;
1464
+ }
1465
+ catch (error) {
1466
+ return 0; // Disable proxy feature if version detection fails
1467
+ }
1468
+ }
1469
+ getBasePath() {
1470
+ const basePath = this.options.debug ? 'debug' : 'release';
1471
+ return `src-tauri/target/${basePath}/bundle/`;
1472
+ }
1473
+ getBuildAppPath(npmDirectory, fileName, fileType) {
1474
+ // For app bundles on macOS, the directory is 'macos', not 'app'
1475
+ const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase();
1476
+ return path.join(npmDirectory, this.getBasePath(), bundleDir, `${fileName}.${fileType}`);
1477
+ }
1478
+ /**
1479
+ * Copy raw binary file to output directory
1480
+ */
1481
+ async copyRawBinary(npmDirectory, appName) {
1482
+ const binaryPath = this.getRawBinarySourcePath(npmDirectory, appName);
1483
+ const outputPath = this.getRawBinaryPath(appName);
1484
+ if (await fsExtra.pathExists(binaryPath)) {
1485
+ await fsExtra.copy(binaryPath, outputPath);
1486
+ // Make binary executable on Unix-like systems
1487
+ if (process.platform !== 'win32') {
1488
+ await fsExtra.chmod(outputPath, 0o755);
1489
+ }
1490
+ }
1491
+ else {
1492
+ logger.warn(`✼ Raw binary not found at ${binaryPath}, skipping...`);
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Get the source path of the raw binary file in the build directory
1497
+ */
1498
+ getRawBinarySourcePath(npmDirectory, appName) {
1499
+ const basePath = this.options.debug ? 'debug' : 'release';
1500
+ const binaryName = this.getBinaryName(appName);
1501
+ // Handle cross-platform builds
1502
+ if (this.options.multiArch || this.hasArchSpecificTarget()) {
1503
+ return path.join(npmDirectory, this.getArchSpecificPath(), basePath, binaryName);
1504
+ }
1505
+ return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName);
1506
+ }
1507
+ /**
1508
+ * Get the output path for the raw binary file
1509
+ */
1510
+ getRawBinaryPath(appName) {
1511
+ const extension = process.platform === 'win32' ? '.exe' : '';
1512
+ const suffix = process.platform === 'win32' ? '' : '-binary';
1513
+ return `${appName}${suffix}${extension}`;
1514
+ }
1515
+ /**
1516
+ * Get the binary name based on app name and platform
1517
+ */
1518
+ getBinaryName(appName) {
1519
+ const extension = process.platform === 'win32' ? '.exe' : '';
1520
+ // Use unique binary name for all platforms to avoid conflicts
1521
+ const nameToUse = process.platform === 'linux'
1522
+ ? generateLinuxPackageName(appName)
1523
+ : generateIdentifierSafeName(appName);
1524
+ return `bghitapp-${nameToUse}${extension}`;
1525
+ }
1526
+ /**
1527
+ * Check if this build has architecture-specific target
1528
+ */
1529
+ hasArchSpecificTarget() {
1530
+ return false; // Override in subclasses if needed
1531
+ }
1532
+ /**
1533
+ * Get architecture-specific path for binary
1534
+ */
1535
+ getArchSpecificPath() {
1536
+ return 'src-tauri/target'; // Override in subclasses if needed
1537
+ }
1538
+ }
1539
+ BaseBuilder.ARCH_MAPPINGS = {
1540
+ darwin: {
1541
+ arm64: 'aarch64-apple-darwin',
1542
+ x64: 'x86_64-apple-darwin',
1543
+ universal: 'universal-apple-darwin',
1544
+ },
1545
+ win32: {
1546
+ arm64: 'aarch64-pc-windows-msvc',
1547
+ x64: 'x86_64-pc-windows-msvc',
1548
+ },
1549
+ linux: {
1550
+ arm64: 'aarch64-unknown-linux-gnu',
1551
+ x64: 'x86_64-unknown-linux-gnu',
1552
+ },
1553
+ };
1554
+ BaseBuilder.ARCH_DISPLAY_NAMES = {
1555
+ arm64: 'aarch64',
1556
+ x64: 'x64',
1557
+ universal: 'universal',
1558
+ };
1559
+
1560
+ class MacBuilder extends BaseBuilder {
1561
+ constructor(options) {
1562
+ super(options);
1563
+ const validArchs = ['intel', 'apple', 'universal', 'auto', 'x64', 'arm64'];
1564
+ this.buildArch = validArchs.includes(options.targets || '')
1565
+ ? options.targets
1566
+ : 'auto';
1567
+ if (options.iterativeBuild ||
1568
+ options.install ||
1569
+ process.env.BGHITAPP_CREATE_APP === '1') {
1570
+ this.buildFormat = 'app';
1571
+ }
1572
+ else {
1573
+ this.buildFormat = 'dmg';
1574
+ }
1575
+ this.options.targets = this.buildFormat;
1576
+ }
1577
+ getFileName() {
1578
+ const { name = 'bghitapp-app' } = this.options;
1579
+ if (this.buildFormat === 'app') {
1580
+ return name;
1581
+ }
1582
+ let arch;
1583
+ if (this.buildArch === 'universal' || this.options.multiArch) {
1584
+ arch = 'universal';
1585
+ }
1586
+ else if (this.buildArch === 'apple') {
1587
+ arch = 'aarch64';
1588
+ }
1589
+ else if (this.buildArch === 'intel') {
1590
+ arch = 'x64';
1591
+ }
1592
+ else {
1593
+ arch = this.getArchDisplayName(this.resolveTargetArch(this.buildArch));
1594
+ }
1595
+ return `${name}_${tauriConfig.version}_${arch}`;
1596
+ }
1597
+ getActualArch() {
1598
+ if (this.buildArch === 'universal' || this.options.multiArch) {
1599
+ return 'universal';
1600
+ }
1601
+ else if (this.buildArch === 'apple') {
1602
+ return 'arm64';
1603
+ }
1604
+ else if (this.buildArch === 'intel') {
1605
+ return 'x64';
1606
+ }
1607
+ return this.resolveTargetArch(this.buildArch);
1608
+ }
1609
+ getBuildCommand(packageManager = 'pnpm') {
1610
+ const configPath = path.join('src-tauri', '.bghitapp', 'tauri.conf.json');
1611
+ const actualArch = this.getActualArch();
1612
+ const buildTarget = this.getTauriTarget(actualArch, 'darwin');
1613
+ if (!buildTarget) {
1614
+ throw new Error(`Unsupported architecture: ${actualArch} for macOS`);
1615
+ }
1616
+ return this.buildBaseCommand(packageManager, configPath, buildTarget);
1617
+ }
1618
+ getBasePath() {
1619
+ const basePath = this.options.debug ? 'debug' : 'release';
1620
+ const actualArch = this.getActualArch();
1621
+ const target = this.getTauriTarget(actualArch, 'darwin');
1622
+ return `src-tauri/target/${target}/${basePath}/bundle`;
1623
+ }
1624
+ hasArchSpecificTarget() {
1625
+ return true;
1626
+ }
1627
+ getArchSpecificPath() {
1628
+ const actualArch = this.getActualArch();
1629
+ const target = this.getTauriTarget(actualArch, 'darwin');
1630
+ return `src-tauri/target/${target}`;
1631
+ }
1632
+ }
1633
+
1634
+ class WinBuilder extends BaseBuilder {
1635
+ constructor(options) {
1636
+ super(options);
1637
+ this.buildFormat = 'msi';
1638
+ const validArchs = ['x64', 'arm64', 'auto'];
1639
+ this.buildArch = validArchs.includes(options.targets || '')
1640
+ ? this.resolveTargetArch(options.targets)
1641
+ : this.resolveTargetArch('auto');
1642
+ this.options.targets = this.buildFormat;
1643
+ }
1644
+ getFileName() {
1645
+ const { name } = this.options;
1646
+ const language = tauriConfig.bundle.windows.wix.language[0];
1647
+ const targetArch = this.getArchDisplayName(this.buildArch);
1648
+ return `${name}_${tauriConfig.version}_${targetArch}_${language}`;
1649
+ }
1650
+ getBuildCommand(packageManager = 'pnpm') {
1651
+ const configPath = path.join('src-tauri', '.bghitapp', 'tauri.conf.json');
1652
+ const buildTarget = this.getTauriTarget(this.buildArch, 'win32');
1653
+ if (!buildTarget) {
1654
+ throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`);
1655
+ }
1656
+ return this.buildBaseCommand(packageManager, configPath, buildTarget);
1657
+ }
1658
+ getBasePath() {
1659
+ const basePath = this.options.debug ? 'debug' : 'release';
1660
+ const target = this.getTauriTarget(this.buildArch, 'win32');
1661
+ return `src-tauri/target/${target}/${basePath}/bundle/`;
1662
+ }
1663
+ hasArchSpecificTarget() {
1664
+ return true;
1665
+ }
1666
+ getArchSpecificPath() {
1667
+ const target = this.getTauriTarget(this.buildArch, 'win32');
1668
+ return `src-tauri/target/${target}`;
1669
+ }
1670
+ }
1671
+
1672
+ class LinuxBuilder extends BaseBuilder {
1673
+ constructor(options) {
1674
+ super(options);
1675
+ this.currentBuildType = '';
1676
+ const target = options.targets || 'deb';
1677
+ if (target.includes('-arm64')) {
1678
+ this.buildFormat = target.replace('-arm64', '');
1679
+ this.buildArch = 'arm64';
1680
+ }
1681
+ else {
1682
+ this.buildFormat = target;
1683
+ this.buildArch = this.resolveTargetArch('auto');
1684
+ }
1685
+ this.options.targets = this.buildFormat;
1686
+ }
1687
+ getFileName() {
1688
+ const { name = 'bghitapp-app', targets } = this.options;
1689
+ const version = tauriConfig.version;
1690
+ const buildType = this.currentBuildType || targets.split(',').map((t) => t.trim())[0];
1691
+ let arch;
1692
+ if (this.buildArch === 'arm64') {
1693
+ arch =
1694
+ buildType === 'rpm' || buildType === 'appimage' ? 'aarch64' : 'arm64';
1695
+ }
1696
+ else {
1697
+ if (this.buildArch === 'x64') {
1698
+ arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
1699
+ }
1700
+ else {
1701
+ arch = this.buildArch;
1702
+ if (this.buildArch === 'arm64' &&
1703
+ (buildType === 'rpm' || buildType === 'appimage')) {
1704
+ arch = 'aarch64';
1705
+ }
1706
+ }
1707
+ }
1708
+ if (this.currentBuildType === 'rpm') {
1709
+ return `${name}-${version}-1.${arch}`;
1710
+ }
1711
+ return `${name}_${version}_${arch}`;
1712
+ }
1713
+ async build(url) {
1714
+ const targetTypes = ['deb', 'appimage', 'rpm'];
1715
+ const requestedTargets = this.options.targets
1716
+ .split(',')
1717
+ .map((t) => t.trim());
1718
+ for (const target of targetTypes) {
1719
+ if (requestedTargets.includes(target)) {
1720
+ this.currentBuildType = target;
1721
+ await this.buildAndCopy(url, target);
1722
+ }
1723
+ }
1724
+ }
1725
+ // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time.
1726
+ async buildAndCopy(url, target) {
1727
+ this.currentBuildType = target;
1728
+ await super.buildAndCopy(url, target);
1729
+ }
1730
+ getBuildCommand(packageManager = 'pnpm') {
1731
+ const configPath = path.join('src-tauri', '.bghitapp', 'tauri.conf.json');
1732
+ const buildTarget = this.buildArch === 'arm64'
1733
+ ? (this.getTauriTarget(this.buildArch, 'linux') ?? undefined)
1734
+ : undefined;
1735
+ let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
1736
+ if (this.currentBuildType) {
1737
+ fullCommand += ` --bundles ${this.currentBuildType}`;
1738
+ }
1739
+ // Enable verbose output for AppImage builds when debugging or BGHITAPP_VERBOSE is set.
1740
+ // AppImage builds often fail with minimal error messages from linuxdeploy,
1741
+ // so verbose mode helps diagnose issues like strip failures and missing dependencies.
1742
+ if (this.currentBuildType === 'appimage' &&
1743
+ (this.options.targets.includes('appimage') ||
1744
+ this.options.debug ||
1745
+ process.env.BGHITAPP_VERBOSE)) {
1746
+ fullCommand += ' --verbose';
1747
+ }
1748
+ return fullCommand;
1749
+ }
1750
+ getBasePath() {
1751
+ const basePath = this.options.debug ? 'debug' : 'release';
1752
+ if (this.buildArch === 'arm64') {
1753
+ const target = this.getTauriTarget(this.buildArch, 'linux');
1754
+ return `src-tauri/target/${target}/${basePath}/bundle/`;
1755
+ }
1756
+ return super.getBasePath();
1757
+ }
1758
+ getFileType(target) {
1759
+ if (target === 'appimage') {
1760
+ return 'AppImage';
1761
+ }
1762
+ return super.getFileType(target);
1763
+ }
1764
+ hasArchSpecificTarget() {
1765
+ return this.buildArch === 'arm64';
1766
+ }
1767
+ getArchSpecificPath() {
1768
+ if (this.buildArch === 'arm64') {
1769
+ const target = this.getTauriTarget(this.buildArch, 'linux');
1770
+ return `src-tauri/target/${target}`;
1771
+ }
1772
+ return super.getArchSpecificPath();
1773
+ }
1774
+ }
1775
+
1776
+ const { platform } = process;
1777
+ const buildersMap = {
1778
+ darwin: MacBuilder,
1779
+ win32: WinBuilder,
1780
+ linux: LinuxBuilder,
1781
+ };
1782
+ class BuilderProvider {
1783
+ static create(options) {
1784
+ const Builder = buildersMap[platform];
1785
+ if (!Builder) {
1786
+ throw new Error('The current system is not supported!');
1787
+ }
1788
+ return new Builder(options);
1789
+ }
1790
+ }
1791
+
1792
+ const LOCAL_HOST_SUFFIXES = [
1793
+ '.local',
1794
+ '.lan',
1795
+ '.internal',
1796
+ '.home',
1797
+ '.localdomain',
1798
+ ];
1799
+ const IPV4_ADDRESS_PATTERN = /^(\d{1,3}\.){3}\d{1,3}$/;
1800
+ function normalize(value) {
1801
+ return value.trim().toLowerCase();
1802
+ }
1803
+ function simplify(value) {
1804
+ return normalize(value).replace(/[\s._-]+/g, '');
1805
+ }
1806
+ function generateDashboardIconSlugs(appName) {
1807
+ const normalizedName = normalize(appName);
1808
+ if (!normalizedName) {
1809
+ return [];
1810
+ }
1811
+ const slugs = new Set([
1812
+ normalizedName,
1813
+ normalizedName.replace(/\s+/g, '-'),
1814
+ ]);
1815
+ return [...slugs].filter(Boolean);
1816
+ }
1817
+ function isLikelyLocalHostname(hostname) {
1818
+ const normalizedHostname = normalize(hostname);
1819
+ if (!normalizedHostname) {
1820
+ return false;
1821
+ }
1822
+ return (normalizedHostname === 'localhost' ||
1823
+ IPV4_ADDRESS_PATTERN.test(normalizedHostname) ||
1824
+ normalizedHostname.includes(':') ||
1825
+ !normalizedHostname.includes('.') ||
1826
+ LOCAL_HOST_SUFFIXES.some((suffix) => normalizedHostname.endsWith(suffix)));
1827
+ }
1828
+ function shouldPreferDashboardIcons(url, appName) {
1829
+ if (!appName) {
1830
+ return false;
1831
+ }
1832
+ try {
1833
+ const hostname = new URL(url).hostname.toLowerCase();
1834
+ if (!hostname) {
1835
+ return false;
1836
+ }
1837
+ if (isLikelyLocalHostname(hostname)) {
1838
+ return true;
1839
+ }
1840
+ const parsed = psl.parse(hostname);
1841
+ if (!('domain' in parsed) || !parsed.domain) {
1842
+ return true;
1843
+ }
1844
+ const registrableDomain = parsed.domain.toLowerCase();
1845
+ if (hostname === registrableDomain) {
1846
+ return false;
1847
+ }
1848
+ const subdomain = 'subdomain' in parsed && typeof parsed.subdomain === 'string'
1849
+ ? parsed.subdomain
1850
+ : '';
1851
+ if (!subdomain) {
1852
+ return false;
1853
+ }
1854
+ const productLabel = subdomain.split('.').pop() || '';
1855
+ const rootLabel = registrableDomain.split('.')[0] || '';
1856
+ const normalizedAppName = simplify(appName);
1857
+ return (normalizedAppName.length > 0 &&
1858
+ simplify(productLabel) === normalizedAppName &&
1859
+ simplify(rootLabel) !== normalizedAppName);
1860
+ }
1861
+ catch {
1862
+ return false;
1863
+ }
1864
+ }
1865
+ function getIconSourcePriority(url, appName) {
1866
+ return shouldPreferDashboardIcons(url, appName)
1867
+ ? ['dashboard', 'domain']
1868
+ : ['domain', 'dashboard'];
1869
+ }
1870
+
1871
+ const ICO_HEADER_SIZE = 6;
1872
+ const ICO_DIR_ENTRY_SIZE = 16;
1873
+ const ICO_TYPE_ICON = 1;
1874
+ // Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48),
1875
+ // shell (48/256) and high-DPI (128/256). Issue #1190.
1876
+ const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256];
1877
+ function decodeDimension(value) {
1878
+ return value === 0 ? 256 : value;
1879
+ }
1880
+ function compareByPreferredSize(preferredSize) {
1881
+ return (a, b) => {
1882
+ const aSize = Math.max(a.width, a.height);
1883
+ const bSize = Math.max(b.width, b.height);
1884
+ const aExact = aSize === preferredSize ? 0 : 1;
1885
+ const bExact = bSize === preferredSize ? 0 : 1;
1886
+ if (aExact !== bExact)
1887
+ return aExact - bExact;
1888
+ const aDistance = Math.abs(aSize - preferredSize);
1889
+ const bDistance = Math.abs(bSize - preferredSize);
1890
+ if (aDistance !== bDistance)
1891
+ return aDistance - bDistance;
1892
+ const aSmaller = aSize < preferredSize ? 1 : 0;
1893
+ const bSmaller = bSize < preferredSize ? 1 : 0;
1894
+ if (aSmaller !== bSmaller)
1895
+ return aSmaller - bSmaller;
1896
+ if (a.bitCount !== b.bitCount)
1897
+ return b.bitCount - a.bitCount;
1898
+ if (aSize !== bSize)
1899
+ return bSize - aSize;
1900
+ return a.index - b.index;
1901
+ };
1902
+ }
1903
+ function parseIcoBuffer(buffer) {
1904
+ if (buffer.length < ICO_HEADER_SIZE) {
1905
+ throw new Error('Invalid ICO: header too short.');
1906
+ }
1907
+ const reserved = buffer.readUInt16LE(0);
1908
+ const type = buffer.readUInt16LE(2);
1909
+ const count = buffer.readUInt16LE(4);
1910
+ if (reserved !== 0 || type !== ICO_TYPE_ICON || count < 1) {
1911
+ throw new Error('Invalid ICO: invalid header.');
1912
+ }
1913
+ const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
1914
+ if (buffer.length < tableSize) {
1915
+ throw new Error('Invalid ICO: directory table too short.');
1916
+ }
1917
+ const entries = [];
1918
+ for (let i = 0; i < count; i++) {
1919
+ const offset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;
1920
+ const widthByte = buffer.readUInt8(offset);
1921
+ const heightByte = buffer.readUInt8(offset + 1);
1922
+ const bitCount = buffer.readUInt16LE(offset + 6);
1923
+ const bytesInRes = buffer.readUInt32LE(offset + 8);
1924
+ const imageOffset = buffer.readUInt32LE(offset + 12);
1925
+ if (bytesInRes < 1 || imageOffset + bytesInRes > buffer.length) {
1926
+ throw new Error('Invalid ICO: frame out of bounds.');
1927
+ }
1928
+ entries.push({
1929
+ index: i,
1930
+ width: decodeDimension(widthByte),
1931
+ height: decodeDimension(heightByte),
1932
+ bitCount,
1933
+ bytesInRes,
1934
+ imageOffset,
1935
+ directory: buffer.subarray(offset, offset + ICO_DIR_ENTRY_SIZE),
1936
+ data: buffer.subarray(imageOffset, imageOffset + bytesInRes),
1937
+ });
1938
+ }
1939
+ return entries;
1940
+ }
1941
+ function buildReorderedIcoBuffer(buffer, preferredSize) {
1942
+ const entries = parseIcoBuffer(buffer);
1943
+ const ordered = [...entries].sort(compareByPreferredSize(preferredSize));
1944
+ const count = ordered.length;
1945
+ const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
1946
+ const payloadSize = ordered.reduce((acc, entry) => acc + entry.data.length, 0);
1947
+ const output = Buffer.alloc(tableSize + payloadSize);
1948
+ output.writeUInt16LE(0, 0);
1949
+ output.writeUInt16LE(ICO_TYPE_ICON, 2);
1950
+ output.writeUInt16LE(count, 4);
1951
+ let currentOffset = tableSize;
1952
+ for (let i = 0; i < count; i++) {
1953
+ const entry = ordered[i];
1954
+ const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;
1955
+ entry.directory.copy(output, entryOffset, 0, 8);
1956
+ output.writeUInt32LE(entry.data.length, entryOffset + 8);
1957
+ output.writeUInt32LE(currentOffset, entryOffset + 12);
1958
+ entry.data.copy(output, currentOffset);
1959
+ currentOffset += entry.data.length;
1960
+ }
1961
+ return output;
1962
+ }
1963
+ async function writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize) {
1964
+ try {
1965
+ const sourceBuffer = await fsExtra.readFile(sourcePath);
1966
+ const reordered = buildReorderedIcoBuffer(sourceBuffer, preferredSize);
1967
+ await fsExtra.ensureDir(path.dirname(outputPath));
1968
+ await fsExtra.outputFile(outputPath, reordered);
1969
+ return true;
1970
+ }
1971
+ catch {
1972
+ return false;
1973
+ }
1974
+ }
1975
+ /**
1976
+ * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an
1977
+ * embedded PNG payload (PNG-in-ICO, supported since Windows Vista).
1978
+ */
1979
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
1980
+ function frameLooksLikePng(entry) {
1981
+ return (entry.data.length >= PNG_SIGNATURE.length &&
1982
+ entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE));
1983
+ }
1984
+ async function decodeFrameToPng(entry) {
1985
+ if (frameLooksLikePng(entry)) {
1986
+ return Buffer.from(entry.data);
1987
+ }
1988
+ // BMP DIB frames need to go through sharp's ico-to-PNG path, which only
1989
+ // works on the full ICO container. Fall back to letting the caller use a
1990
+ // sharp pipeline against the original ICO for the missing source.
1991
+ return null;
1992
+ }
1993
+ async function pickLargestFrameAsPng(buffer, entries) {
1994
+ const largest = [...entries].sort((a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height))[0];
1995
+ if (largest) {
1996
+ const decoded = await decodeFrameToPng(largest);
1997
+ if (decoded) {
1998
+ return decoded;
1999
+ }
2000
+ }
2001
+ // Fallback: let sharp render directly from the ICO buffer. sharp picks the
2002
+ // largest embedded frame on its own.
2003
+ try {
2004
+ return await sharp(buffer).png().toBuffer();
2005
+ }
2006
+ catch {
2007
+ return null;
2008
+ }
2009
+ }
2010
+ /**
2011
+ * Ensures the produced ICO carries every Windows standard size so the OS
2012
+ * never has to downsample a 256x256 frame to 16x16 for the tray.
2013
+ * Falls back to `writeIcoWithPreferredSize` if rendering fails.
2014
+ *
2015
+ * Issue #1190.
2016
+ */
2017
+ async function ensureMultiResolutionIco(sourcePath, outputPath, preferredSize = 256, desiredSizes = WIN_STANDARD_ICO_SIZES) {
2018
+ try {
2019
+ const sourceBuffer = await fsExtra.readFile(sourcePath);
2020
+ const entries = parseIcoBuffer(sourceBuffer);
2021
+ const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries);
2022
+ if (!sourcePng) {
2023
+ return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize);
2024
+ }
2025
+ const frames = await Promise.all(desiredSizes.map(async (size) => {
2026
+ // Reuse an existing exact-size PNG frame when possible to keep any
2027
+ // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting).
2028
+ const exact = entries.find((entry) => entry.width === size && entry.height === size);
2029
+ if (exact && frameLooksLikePng(exact)) {
2030
+ return { size, png: Buffer.from(exact.data) };
2031
+ }
2032
+ const png = await sharp(sourcePng)
2033
+ .resize(size, size, {
2034
+ fit: 'contain',
2035
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
2036
+ })
2037
+ .ensureAlpha()
2038
+ .png()
2039
+ .toBuffer();
2040
+ return { size, png };
2041
+ }));
2042
+ // Order frames so the preferred size lands first (Windows shell uses the
2043
+ // first-listed frame as a quality hint when choosing which to display).
2044
+ frames.sort((a, b) => {
2045
+ const aExact = a.size === preferredSize ? 0 : 1;
2046
+ const bExact = b.size === preferredSize ? 0 : 1;
2047
+ if (aExact !== bExact)
2048
+ return aExact - bExact;
2049
+ return b.size - a.size;
2050
+ });
2051
+ const icoBuffer = buildIcoFromPngBuffers(frames);
2052
+ await fsExtra.ensureDir(path.dirname(outputPath));
2053
+ await fsExtra.outputFile(outputPath, icoBuffer);
2054
+ return true;
2055
+ }
2056
+ catch {
2057
+ return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize);
2058
+ }
2059
+ }
2060
+ /**
2061
+ * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format
2062
+ * (supported since Windows Vista). This preserves alpha transparency.
2063
+ */
2064
+ function buildIcoFromPngBuffers(frames) {
2065
+ const count = frames.length;
2066
+ const headerSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE;
2067
+ const totalPayload = frames.reduce((acc, f) => acc + f.png.length, 0);
2068
+ const output = Buffer.alloc(headerSize + totalPayload);
2069
+ output.writeUInt16LE(0, 0);
2070
+ output.writeUInt16LE(ICO_TYPE_ICON, 2);
2071
+ output.writeUInt16LE(count, 4);
2072
+ let currentOffset = headerSize;
2073
+ for (let i = 0; i < count; i++) {
2074
+ const { size, png } = frames[i];
2075
+ const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE;
2076
+ const sizeByte = size >= 256 ? 0 : size;
2077
+ output.writeUInt8(sizeByte, entryOffset);
2078
+ output.writeUInt8(sizeByte, entryOffset + 1);
2079
+ output.writeUInt8(0, entryOffset + 2);
2080
+ output.writeUInt8(0, entryOffset + 3);
2081
+ output.writeUInt16LE(1, entryOffset + 4);
2082
+ output.writeUInt16LE(32, entryOffset + 6);
2083
+ output.writeUInt32LE(png.length, entryOffset + 8);
2084
+ output.writeUInt32LE(currentOffset, entryOffset + 12);
2085
+ png.copy(output, currentOffset);
2086
+ currentOffset += png.length;
2087
+ }
2088
+ return output;
2089
+ }
2090
+
2091
+ const ICON_CONFIG = {
2092
+ minFileSize: 100,
2093
+ supportedFormats: [
2094
+ 'png',
2095
+ 'ico',
2096
+ 'jpeg',
2097
+ 'jpg',
2098
+ 'webp',
2099
+ 'icns',
2100
+ 'svg',
2101
+ ],
2102
+ transparentBackground: { r: 255, g: 255, b: 255, alpha: 0 },
2103
+ downloadTimeout: {
2104
+ ci: 5000,
2105
+ default: 15000,
2106
+ },
2107
+ };
2108
+ const PLATFORM_CONFIG = {
2109
+ win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] },
2110
+ linux: { format: '.png', size: 512 },
2111
+ macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] },
2112
+ };
2113
+ const API_KEYS = {
2114
+ logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'],
2115
+ brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'],
2116
+ };
2117
+ /**
2118
+ * Generates platform-specific icon paths and handles copying for Windows
2119
+ */
2120
+ function generateIconPath(appName, isDefault = false) {
2121
+ const safeName = isDefault ? 'icon' : getIconBaseName(appName);
2122
+ const baseName = safeName;
2123
+ if (IS_WIN) {
2124
+ return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_256.ico`);
2125
+ }
2126
+ if (IS_LINUX) {
2127
+ return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_512.png`);
2128
+ }
2129
+ return path.join(npmDirectory, 'src-tauri', 'icons', `${baseName}.icns`);
2130
+ }
2131
+ function getIconBaseName(appName) {
2132
+ const baseName = IS_LINUX
2133
+ ? generateLinuxPackageName(appName)
2134
+ : getSafeAppName(appName);
2135
+ return baseName || 'bghitapp-app';
2136
+ }
2137
+ async function copyWindowsIconIfNeeded(convertedPath, appName) {
2138
+ if (!IS_WIN || !convertedPath.endsWith('.ico')) {
2139
+ return convertedPath;
2140
+ }
2141
+ try {
2142
+ const finalIconPath = generateIconPath(appName);
2143
+ await fsExtra.ensureDir(path.dirname(finalIconPath));
2144
+ // Re-render ICO so every Windows standard size is present and prefer the
2145
+ // 256px frame as the leading entry; falls back to plain reordering if the
2146
+ // ICO is non-decodable, then to a raw copy. (Issue #1190)
2147
+ const upgraded = await ensureMultiResolutionIco(convertedPath, finalIconPath, 256);
2148
+ if (!upgraded) {
2149
+ const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256);
2150
+ if (!reordered) {
2151
+ await fsExtra.copy(convertedPath, finalIconPath);
2152
+ }
2153
+ }
2154
+ return finalIconPath;
2155
+ }
2156
+ catch (error) {
2157
+ logger.warn(`Failed to copy Windows icon: ${error instanceof Error ? error.message : 'Unknown error'}`);
2158
+ return convertedPath;
2159
+ }
2160
+ }
2161
+ /**
2162
+ * Normalizes icon inputs to PNG while preserving alpha.
2163
+ */
2164
+ async function preprocessIcon(inputPath) {
2165
+ try {
2166
+ const extension = path.extname(inputPath).toLowerCase();
2167
+ const shouldNormalize = ['.png', '.jpeg', '.jpg', '.webp', '.svg'].includes(extension);
2168
+ if (!shouldNormalize) {
2169
+ return inputPath;
2170
+ }
2171
+ const { path: tempDir } = await dir();
2172
+ const outputPath = path.join(tempDir, 'icon-normalized.png');
2173
+ await sharp(inputPath).ensureAlpha().png().toFile(outputPath);
2174
+ return outputPath;
2175
+ }
2176
+ catch (error) {
2177
+ if (error instanceof Error) {
2178
+ logger.warn(`Failed to normalize icon: ${error.message}`);
2179
+ }
2180
+ return inputPath;
2181
+ }
2182
+ }
2183
+ /**
2184
+ * Applies macOS squircle mask to icon
2185
+ */
2186
+ async function applyMacOSMask(inputPath) {
2187
+ try {
2188
+ const { path: tempDir } = await dir();
2189
+ const outputPath = path.join(tempDir, 'icon-macos-rounded.png');
2190
+ // 1. Create a 1024x1024 rounded rect mask
2191
+ // rx="224" is closer to the smooth Apple squircle look for 1024px
2192
+ const mask = Buffer.from('<svg width="1024" height="1024"><rect x="0" y="0" width="1024" height="1024" rx="224" ry="224" fill="white"/></svg>');
2193
+ // 2. Load input, resize to 1024, apply mask
2194
+ const maskedBuffer = await sharp(inputPath)
2195
+ .resize(1024, 1024, {
2196
+ fit: 'contain',
2197
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
2198
+ })
2199
+ .composite([
2200
+ {
2201
+ input: mask,
2202
+ blend: 'dest-in',
2203
+ },
2204
+ ])
2205
+ .png()
2206
+ .toBuffer();
2207
+ // 3. Resize to 840x840 (~18% padding) to solve "too big" visual issue
2208
+ // Native MacOS icons often leave some breathing room
2209
+ await sharp(maskedBuffer)
2210
+ .resize(840, 840, {
2211
+ fit: 'contain',
2212
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
2213
+ })
2214
+ .extend({
2215
+ top: 92,
2216
+ bottom: 92,
2217
+ left: 92,
2218
+ right: 92,
2219
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
2220
+ })
2221
+ .toFile(outputPath);
2222
+ return outputPath;
2223
+ }
2224
+ catch (error) {
2225
+ if (error instanceof Error) {
2226
+ logger.warn(`Failed to apply macOS mask: ${error.message}`);
2227
+ }
2228
+ return inputPath;
2229
+ }
2230
+ }
2231
+ /**
2232
+ * Converts icon to platform-specific format
2233
+ */
2234
+ async function convertIconFormat(inputPath, appName) {
2235
+ try {
2236
+ if (!(await fsExtra.pathExists(inputPath)))
2237
+ return null;
2238
+ const { path: outputDir } = await dir();
2239
+ const platformOutputDir = path.join(outputDir, 'converted-icons');
2240
+ await fsExtra.ensureDir(platformOutputDir);
2241
+ const processedInputPath = await preprocessIcon(inputPath);
2242
+ const iconName = getIconBaseName(appName);
2243
+ // Generate platform-specific format
2244
+ if (IS_WIN) {
2245
+ const icoPath = path.join(platformOutputDir, `${iconName}_256${PLATFORM_CONFIG.win.format}`);
2246
+ const sourceBuffer = await fsExtra.readFile(processedInputPath);
2247
+ const frames = await Promise.all(PLATFORM_CONFIG.win.sizes.map(async (size) => {
2248
+ const png = await sharp(sourceBuffer)
2249
+ .resize(size, size, {
2250
+ fit: 'contain',
2251
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
2252
+ })
2253
+ .ensureAlpha()
2254
+ .png()
2255
+ .toBuffer();
2256
+ return { size, png };
2257
+ }));
2258
+ const icoBuffer = buildIcoFromPngBuffers(frames);
2259
+ await fsExtra.outputFile(icoPath, icoBuffer);
2260
+ return icoPath;
2261
+ }
2262
+ if (IS_LINUX) {
2263
+ const outputPath = path.join(platformOutputDir, `${iconName}_${PLATFORM_CONFIG.linux.size}${PLATFORM_CONFIG.linux.format}`);
2264
+ // Ensure we convert to proper PNG format with correct size
2265
+ await sharp(processedInputPath)
2266
+ .resize(PLATFORM_CONFIG.linux.size, PLATFORM_CONFIG.linux.size, {
2267
+ fit: 'contain',
2268
+ background: ICON_CONFIG.transparentBackground,
2269
+ })
2270
+ .ensureAlpha()
2271
+ .png()
2272
+ .toFile(outputPath);
2273
+ return outputPath;
2274
+ }
2275
+ // macOS
2276
+ const macIconPath = await applyMacOSMask(processedInputPath);
2277
+ await icongen(macIconPath, platformOutputDir, {
2278
+ report: false,
2279
+ icns: { name: iconName, sizes: PLATFORM_CONFIG.macos.sizes },
2280
+ });
2281
+ const outputPath = path.join(platformOutputDir, `${iconName}${PLATFORM_CONFIG.macos.format}`);
2282
+ return (await fsExtra.pathExists(outputPath)) ? outputPath : null;
2283
+ }
2284
+ catch (error) {
2285
+ if (error instanceof Error) {
2286
+ logger.warn(`Icon format conversion failed: ${error.message}`);
2287
+ }
2288
+ return null;
2289
+ }
2290
+ }
2291
+ /**
2292
+ * Processes downloaded or local icon for platform-specific format
2293
+ */
2294
+ async function processIcon(iconPath, appName) {
2295
+ if (!iconPath || !appName)
2296
+ return iconPath;
2297
+ // Check if already in correct platform format
2298
+ const ext = path.extname(iconPath).toLowerCase();
2299
+ const isCorrectFormat = (IS_WIN && ext === '.ico') ||
2300
+ (IS_LINUX && ext === '.png') ||
2301
+ (!IS_WIN && !IS_LINUX && ext === '.icns');
2302
+ if (isCorrectFormat) {
2303
+ return await copyWindowsIconIfNeeded(iconPath, appName);
2304
+ }
2305
+ // Convert to platform format
2306
+ const convertedPath = await convertIconFormat(iconPath, appName);
2307
+ if (convertedPath) {
2308
+ return await copyWindowsIconIfNeeded(convertedPath, appName);
2309
+ }
2310
+ return iconPath;
2311
+ }
2312
+ /**
2313
+ * Gets default icon with platform-specific fallback logic
2314
+ */
2315
+ async function getDefaultIcon() {
2316
+ logger.info('✼ No icon provided, using default icon.');
2317
+ if (IS_WIN) {
2318
+ const defaultIcoPath = generateIconPath('icon', true);
2319
+ const defaultPngPath = path.join(npmDirectory, 'src-tauri/png/icon_512.png');
2320
+ // Try default ico first
2321
+ if (await fsExtra.pathExists(defaultIcoPath)) {
2322
+ return defaultIcoPath;
2323
+ }
2324
+ // Convert from png if ico doesn't exist
2325
+ if (await fsExtra.pathExists(defaultPngPath)) {
2326
+ logger.info('✼ Default ico not found, converting from png...');
2327
+ try {
2328
+ const convertedPath = await convertIconFormat(defaultPngPath, 'icon');
2329
+ if (convertedPath && (await fsExtra.pathExists(convertedPath))) {
2330
+ return await copyWindowsIconIfNeeded(convertedPath, 'icon');
2331
+ }
2332
+ }
2333
+ catch (error) {
2334
+ logger.warn(`Failed to convert default png to ico: ${error instanceof Error ? error.message : 'Unknown error'}`);
2335
+ }
2336
+ }
2337
+ // Fallback to png or empty
2338
+ if (await fsExtra.pathExists(defaultPngPath)) {
2339
+ logger.warn('✼ Using png as fallback for Windows (may cause issues).');
2340
+ return defaultPngPath;
2341
+ }
2342
+ logger.warn('✼ No default icon found, will use bghitapp default.');
2343
+ return '';
2344
+ }
2345
+ // Linux and macOS defaults
2346
+ const iconPath = IS_LINUX
2347
+ ? 'src-tauri/png/icon_512.png'
2348
+ : 'src-tauri/icons/icon.icns';
2349
+ return path.join(npmDirectory, iconPath);
2350
+ }
2351
+ /**
2352
+ * Main icon handling function with simplified logic flow
2353
+ */
2354
+ async function handleIcon(options, url) {
2355
+ // Handle custom icon (local file or remote URL)
2356
+ if (options.icon) {
2357
+ if (options.icon.startsWith('http')) {
2358
+ const downloadedPath = await downloadIcon(options.icon);
2359
+ if (downloadedPath) {
2360
+ const result = await processIcon(downloadedPath, options.name || '');
2361
+ if (result)
2362
+ return result;
2363
+ }
2364
+ return '';
2365
+ }
2366
+ // Local file path
2367
+ const resolvedPath = path.resolve(options.icon);
2368
+ const result = await processIcon(resolvedPath, options.name || '');
2369
+ return result || resolvedPath;
2370
+ }
2371
+ // Check for existing local icon before downloading
2372
+ if (options.name) {
2373
+ const localIconPath = generateIconPath(options.name);
2374
+ if (await fsExtra.pathExists(localIconPath)) {
2375
+ logger.info(`✼ Using existing local icon: ${localIconPath}`);
2376
+ return localIconPath;
2377
+ }
2378
+ }
2379
+ // Try favicon from website
2380
+ if (url && options.name) {
2381
+ const faviconPath = await tryGetFavicon(url, options.name);
2382
+ if (faviconPath)
2383
+ return faviconPath;
2384
+ }
2385
+ // Use default icon
2386
+ return await getDefaultIcon();
2387
+ }
2388
+ /**
2389
+ * Generates icon service URLs for a domain
2390
+ */
2391
+ function generateIconServiceUrls(domain) {
2392
+ const logoDevUrls = API_KEYS.logoDev
2393
+ .sort(() => Math.random() - 0.5)
2394
+ .map((token) => `https://img.logo.dev/${domain}?token=${token}&format=png&size=256`);
2395
+ const brandfetchUrls = API_KEYS.brandfetch
2396
+ .sort(() => Math.random() - 0.5)
2397
+ .map((key) => `https://cdn.brandfetch.io/${domain}/w/400/h/400?c=${key}`);
2398
+ return [
2399
+ ...logoDevUrls,
2400
+ ...brandfetchUrls,
2401
+ `https://logo.clearbit.com/${domain}?size=256`,
2402
+ `https://www.google.com/s2/favicons?domain=${domain}&sz=256`,
2403
+ `https://favicon.is/${domain}`,
2404
+ `https://${domain}/favicon.ico`,
2405
+ `https://www.${domain}/favicon.ico`,
2406
+ ];
2407
+ }
2408
+ /**
2409
+ * Generates dashboard-icons URLs for an app name.
2410
+ * Uses walkxcode/dashboard-icons as a final fallback for selfhosted apps.
2411
+ * Keeps matching conservative to avoid overriding valid site-specific icons.
2412
+ */
2413
+ function generateDashboardIconUrls(appName) {
2414
+ const baseUrl = 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png';
2415
+ return generateDashboardIconSlugs(appName).map((slug) => `${baseUrl}/${slug}.png`);
2416
+ }
2417
+ function isSupportedIconFormat(extension) {
2418
+ return ICON_CONFIG.supportedFormats.includes(extension);
2419
+ }
2420
+ function looksLikeSvg(arrayBuffer) {
2421
+ const sample = Buffer.from(arrayBuffer)
2422
+ .toString('utf-8', 0, Math.min(arrayBuffer.byteLength, 512))
2423
+ .trimStart()
2424
+ .toLowerCase();
2425
+ return (sample.startsWith('<svg') ||
2426
+ (sample.startsWith('<?xml') && sample.includes('<svg')));
2427
+ }
2428
+ function getUrlExtension(iconUrl) {
2429
+ try {
2430
+ return path.extname(new URL(iconUrl).pathname).slice(1).toLowerCase();
2431
+ }
2432
+ catch {
2433
+ return path.extname(iconUrl).slice(1).toLowerCase();
2434
+ }
2435
+ }
2436
+ async function detectDownloadedIconExtension(response, arrayBuffer, iconUrl) {
2437
+ const fileDetails = await fileTypeFromBuffer(arrayBuffer);
2438
+ if (fileDetails && isSupportedIconFormat(fileDetails.ext)) {
2439
+ return fileDetails.ext;
2440
+ }
2441
+ const contentType = response.headers
2442
+ .get('content-type')
2443
+ ?.split(';')[0]
2444
+ .trim();
2445
+ if (contentType === 'image/svg+xml' && looksLikeSvg(arrayBuffer)) {
2446
+ return 'svg';
2447
+ }
2448
+ if (getUrlExtension(iconUrl) === 'svg' && looksLikeSvg(arrayBuffer)) {
2449
+ return 'svg';
2450
+ }
2451
+ return null;
2452
+ }
2453
+ async function resolveIconFromUrl(iconUrl, appName, downloadTimeout) {
2454
+ const iconPath = await downloadIcon(iconUrl, false, downloadTimeout);
2455
+ if (!iconPath) {
2456
+ return null;
2457
+ }
2458
+ const convertedPath = await convertIconFormat(iconPath, appName);
2459
+ if (!convertedPath) {
2460
+ return null;
2461
+ }
2462
+ return await copyWindowsIconIfNeeded(convertedPath, appName);
2463
+ }
2464
+ async function tryResolveIconSource(source, domain, appName, downloadTimeout) {
2465
+ const iconUrls = source === 'dashboard'
2466
+ ? generateDashboardIconUrls(appName)
2467
+ : generateIconServiceUrls(domain);
2468
+ for (const iconUrl of iconUrls) {
2469
+ try {
2470
+ const resolvedPath = await resolveIconFromUrl(iconUrl, appName, downloadTimeout);
2471
+ if (resolvedPath) {
2472
+ return resolvedPath;
2473
+ }
2474
+ }
2475
+ catch (error) {
2476
+ if (error instanceof Error) {
2477
+ const label = source === 'dashboard' ? 'Dashboard icon' : 'Icon service';
2478
+ logger.debug(`${label} ${iconUrl} failed: ${error.message}`);
2479
+ }
2480
+ }
2481
+ }
2482
+ return null;
2483
+ }
2484
+ /**
2485
+ * Attempts to fetch favicon from website
2486
+ */
2487
+ async function tryGetFavicon(url, appName) {
2488
+ try {
2489
+ const domain = new URL(url).hostname;
2490
+ const spinner = getSpinner(`Fetching icon from ${domain}...`);
2491
+ const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
2492
+ const downloadTimeout = isCI
2493
+ ? ICON_CONFIG.downloadTimeout.ci
2494
+ : ICON_CONFIG.downloadTimeout.default;
2495
+ const sourcePriority = getIconSourcePriority(url, appName);
2496
+ for (const source of sourcePriority) {
2497
+ const resolvedIconPath = await tryResolveIconSource(source, domain, appName, downloadTimeout);
2498
+ if (!resolvedIconPath) {
2499
+ continue;
2500
+ }
2501
+ spinner.succeed(chalk.green(source === 'dashboard'
2502
+ ? `Icon found via dashboard-icons for "${appName}"!`
2503
+ : 'Icon fetched and converted successfully!'));
2504
+ return resolvedIconPath;
2505
+ }
2506
+ spinner.warn(`No favicon found for ${domain}. Using default.`);
2507
+ return null;
2508
+ }
2509
+ catch (error) {
2510
+ if (error instanceof Error) {
2511
+ logger.warn(`Failed to fetch favicon: ${error.message}`);
2512
+ }
2513
+ return null;
2514
+ }
2515
+ }
2516
+ /**
2517
+ * Downloads icon from URL
2518
+ */
2519
+ async function downloadIcon(iconUrl, showSpinner = true, customTimeout) {
2520
+ const controller = new AbortController();
2521
+ const timeoutId = setTimeout(() => {
2522
+ controller.abort();
2523
+ }, customTimeout || 10000);
2524
+ try {
2525
+ const response = await fetch(iconUrl, {
2526
+ signal: controller.signal,
2527
+ });
2528
+ clearTimeout(timeoutId);
2529
+ if (!response.ok) {
2530
+ if (response.status === 404 && !showSpinner) {
2531
+ return null;
2532
+ }
2533
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
2534
+ }
2535
+ const arrayBuffer = await response.arrayBuffer();
2536
+ if (!arrayBuffer || arrayBuffer.byteLength < ICON_CONFIG.minFileSize)
2537
+ return null;
2538
+ const extension = await detectDownloadedIconExtension(response, arrayBuffer, iconUrl);
2539
+ if (!extension) {
2540
+ return null;
2541
+ }
2542
+ return await saveIconFile(arrayBuffer, extension);
2543
+ }
2544
+ catch (error) {
2545
+ clearTimeout(timeoutId);
2546
+ if (showSpinner) {
2547
+ if (error instanceof Error && error.name === 'AbortError') {
2548
+ logger.error('Icon download timed out!');
2549
+ }
2550
+ else {
2551
+ logger.error('Icon download failed!', error instanceof Error ? error.message : String(error));
2552
+ }
2553
+ }
2554
+ return null;
2555
+ }
2556
+ }
2557
+ /**
2558
+ * Saves icon file to temporary location
2559
+ */
2560
+ async function saveIconFile(iconData, extension) {
2561
+ const buffer = Buffer.from(iconData);
2562
+ const { path: tempPath } = await dir();
2563
+ // Always save with the original extension first
2564
+ const originalIconPath = path.join(tempPath, `icon.${extension}`);
2565
+ await fsExtra.outputFile(originalIconPath, buffer);
2566
+ return originalIconPath;
2567
+ }
2568
+
2569
+ // Extracts the domain from a given URL.
2570
+ function getDomain(inputUrl) {
2571
+ try {
2572
+ const url = new URL(inputUrl);
2573
+ // Use PSL to parse domain names.
2574
+ const parsed = psl.parse(url.hostname);
2575
+ // If domain is available, split it and return the SLD.
2576
+ if ('domain' in parsed && parsed.domain) {
2577
+ return parsed.domain.split('.')[0];
2578
+ }
2579
+ else {
2580
+ return null;
2581
+ }
2582
+ }
2583
+ catch (error) {
2584
+ return null;
2585
+ }
2586
+ }
2587
+ // Appends 'https://' protocol to the URL if not present.
2588
+ function appendProtocol(inputUrl) {
2589
+ try {
2590
+ new URL(inputUrl);
2591
+ return inputUrl;
2592
+ }
2593
+ catch {
2594
+ return `https://${inputUrl}`;
2595
+ }
2596
+ }
2597
+ // Normalizes the URL by ensuring it has a protocol and is valid.
2598
+ function normalizeUrl(urlToNormalize) {
2599
+ const urlWithProtocol = appendProtocol(urlToNormalize);
2600
+ try {
2601
+ new URL(urlWithProtocol);
2602
+ return urlWithProtocol;
2603
+ }
2604
+ catch (err) {
2605
+ throw new Error(`Your url "${urlWithProtocol}" is invalid: ${err.message}`);
2606
+ }
2607
+ }
2608
+
2609
+ /**
2610
+ * Error class used for user-facing CLI errors.
2611
+ *
2612
+ * The top-level catch in `bin/cli.ts` prints `message` directly without a
2613
+ * stack trace and exits with code 1. Use this for predictable failures
2614
+ * (invalid names, missing files, etc.) so users see a clean message instead
2615
+ * of a Node.js stack dump.
2616
+ */
2617
+ class BghitappError extends Error {
2618
+ constructor(message) {
2619
+ super(message);
2620
+ this.isUserError = true;
2621
+ this.name = 'BghitappError';
2622
+ }
2623
+ }
2624
+ function isBghitappError(error) {
2625
+ return (error instanceof BghitappError ||
2626
+ (typeof error === 'object' &&
2627
+ error !== null &&
2628
+ error.isUserError === true));
2629
+ }
2630
+
2631
+ function resolveAppName(name, platform) {
2632
+ const domain = getDomain(name) || 'bghitapp';
2633
+ return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain;
2634
+ }
2635
+ function resolveLocalAppName(filePath, platform) {
2636
+ const baseName = path.parse(filePath).name || 'bghitapp-app';
2637
+ if (platform === 'linux') {
2638
+ return generateLinuxPackageName(baseName) || 'bghitapp-app';
2639
+ }
2640
+ const normalized = baseName
2641
+ .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '')
2642
+ .replace(/^[ -]+/, '')
2643
+ .replace(/\s+/g, ' ')
2644
+ .trim();
2645
+ return normalized || 'bghitapp-app';
2646
+ }
2647
+ function isValidName(name, platform) {
2648
+ const reg = platform === 'linux'
2649
+ ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/
2650
+ : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/;
2651
+ return !!name && reg.test(name);
2652
+ }
2653
+ async function handleOptions(options, url) {
2654
+ const { platform } = process;
2655
+ const isActions = process.env.GITHUB_ACTIONS;
2656
+ let name = options.name;
2657
+ const pathExists = await fsExtra.pathExists(url);
2658
+ if (!options.name) {
2659
+ const defaultName = pathExists
2660
+ ? resolveLocalAppName(url, platform)
2661
+ : resolveAppName(url, platform);
2662
+ const promptMessage = 'Enter your application name';
2663
+ const namePrompt = await promptText(promptMessage, defaultName);
2664
+ name = namePrompt?.trim() || defaultName;
2665
+ }
2666
+ if (name && platform === 'linux') {
2667
+ name = generateLinuxPackageName(name);
2668
+ }
2669
+ if (name && !isValidName(name, platform)) {
2670
+ const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`;
2671
+ const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`;
2672
+ const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR;
2673
+ if (isActions) {
2674
+ logger.error(errorMsg);
2675
+ name = resolveAppName(url, platform);
2676
+ logger.warn(`✼ Inside github actions, use the default name: ${name}`);
2677
+ }
2678
+ else {
2679
+ throw new BghitappError(errorMsg);
2680
+ }
2681
+ }
2682
+ const resolvedName = name || 'bghitapp-app';
2683
+ const appOptions = {
2684
+ ...options,
2685
+ name: resolvedName,
2686
+ identifier: resolveIdentifier(url, options.name, options.identifier),
2687
+ };
2688
+ const iconPath = await handleIcon(appOptions, url);
2689
+ appOptions.icon = iconPath || '';
2690
+ return appOptions;
2691
+ }
2692
+
2693
+ const DEFAULT_BGHITAPP_OPTIONS = {
2694
+ icon: '',
2695
+ height: 780,
2696
+ width: 1200,
2697
+ fullscreen: false,
2698
+ maximize: false,
2699
+ hideTitleBar: false,
2700
+ alwaysOnTop: false,
2701
+ appVersion: '1.0.0',
2702
+ darkMode: false,
2703
+ disabledWebShortcuts: false,
2704
+ activationShortcut: '',
2705
+ userAgent: '',
2706
+ showSystemTray: false,
2707
+ multiArch: false,
2708
+ targets: (() => {
2709
+ switch (process.platform) {
2710
+ case 'linux':
2711
+ return 'deb,appimage';
2712
+ case 'darwin':
2713
+ return 'dmg';
2714
+ case 'win32':
2715
+ return 'msi';
2716
+ default:
2717
+ return 'deb';
2718
+ }
2719
+ })(),
2720
+ useLocalFile: false,
2721
+ systemTrayIcon: '',
2722
+ proxyUrl: '',
2723
+ debug: false,
2724
+ inject: [],
2725
+ installerLanguage: 'en-US',
2726
+ hideOnClose: undefined, // Platform-specific: true for macOS, false for others
2727
+ incognito: false,
2728
+ wasm: false,
2729
+ enableDragDrop: false,
2730
+ keepBinary: false,
2731
+ multiInstance: false,
2732
+ multiWindow: false,
2733
+ startToTray: false,
2734
+ forceInternalNavigation: false,
2735
+ internalUrlRegex: '',
2736
+ enableFind: false,
2737
+ iterativeBuild: false,
2738
+ zoom: 100,
2739
+ minWidth: 0,
2740
+ minHeight: 0,
2741
+ ignoreCertificateErrors: false,
2742
+ newWindow: false,
2743
+ install: false,
2744
+ camera: false,
2745
+ microphone: false,
2746
+ splash: '',
2747
+ autoSplash: false,
2748
+ offline: false,
2749
+ };
2750
+
2751
+ function validateNumberInput(value) {
2752
+ const parsedValue = Number(value);
2753
+ if (isNaN(parsedValue)) {
2754
+ throw new InvalidArgumentError('Not a number.');
2755
+ }
2756
+ return parsedValue;
2757
+ }
2758
+ function validateUrlInput(url) {
2759
+ const isFile = fs$1.existsSync(url);
2760
+ if (!isFile) {
2761
+ try {
2762
+ return normalizeUrl(url);
2763
+ }
2764
+ catch (error) {
2765
+ if (error instanceof Error) {
2766
+ throw new InvalidArgumentError(error.message);
2767
+ }
2768
+ throw error;
2769
+ }
2770
+ }
2771
+ return url;
2772
+ }
2773
+
2774
+ function getCliProgram() {
2775
+ const { green, yellow } = chalk;
2776
+ const logo = `${chalk.green(' ____ _')}
2777
+ ${green('| _ \\ __ _| | _____')}
2778
+ ${green('| |_) / _` | |/ / _ \\')}
2779
+ ${green('| __/ (_| | < __/')} ${yellow('https://github.com/BghitCode/bghitapp')}
2780
+ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with Rust.')}
2781
+ `;
2782
+ return program$1
2783
+ .addHelpText('beforeAll', logo)
2784
+ .usage(`[url] [options]`)
2785
+ .showHelpAfterError()
2786
+ .argument('[url]', 'The web URL you want to package', validateUrlInput)
2787
+ .option('--name <string>', 'Application name')
2788
+ .addOption(new Option('--identifier <string>', 'Application identifier / bundle ID').hideHelp())
2789
+ .option('--icon <string>', 'Application icon', DEFAULT_BGHITAPP_OPTIONS.icon)
2790
+ .option('--width <number>', 'Window width', validateNumberInput, DEFAULT_BGHITAPP_OPTIONS.width)
2791
+ .option('--height <number>', 'Window height', validateNumberInput, DEFAULT_BGHITAPP_OPTIONS.height)
2792
+ .option('--use-local-file', 'Use local file packaging', DEFAULT_BGHITAPP_OPTIONS.useLocalFile)
2793
+ .option('--fullscreen', 'Start in full screen', DEFAULT_BGHITAPP_OPTIONS.fullscreen)
2794
+ .option('--hide-title-bar', 'For Mac, hide title bar', DEFAULT_BGHITAPP_OPTIONS.hideTitleBar)
2795
+ .option('--multi-arch', 'For Mac, both Intel and M1', DEFAULT_BGHITAPP_OPTIONS.multiArch)
2796
+ .option('--inject <files>', 'Inject local CSS/JS files into the page', (val, previous) => {
2797
+ if (!val)
2798
+ return DEFAULT_BGHITAPP_OPTIONS.inject;
2799
+ // Split by comma and trim whitespace, filter out empty strings
2800
+ const files = val
2801
+ .split(',')
2802
+ .map((item) => item.trim())
2803
+ .filter((item) => item.length > 0);
2804
+ // If previous values exist (from multiple --inject options), merge them
2805
+ return previous ? [...previous, ...files] : files;
2806
+ }, DEFAULT_BGHITAPP_OPTIONS.inject)
2807
+ .option('--debug', 'Debug build and more output', DEFAULT_BGHITAPP_OPTIONS.debug)
2808
+ .addOption(new Option('--proxy-url <url>', 'Proxy URL for all network requests (http://, https://, socks5://)')
2809
+ .default(DEFAULT_BGHITAPP_OPTIONS.proxyUrl)
2810
+ .hideHelp())
2811
+ .addOption(new Option('--user-agent <string>', 'Custom user agent')
2812
+ .default(DEFAULT_BGHITAPP_OPTIONS.userAgent)
2813
+ .hideHelp())
2814
+ .addOption(new Option('--targets <string>', 'Build target format for your system').default(DEFAULT_BGHITAPP_OPTIONS.targets))
2815
+ .addOption(new Option('--app-version <string>', 'App version, the same as package.json version')
2816
+ .default(DEFAULT_BGHITAPP_OPTIONS.appVersion)
2817
+ .hideHelp())
2818
+ .addOption(new Option('--always-on-top', 'Always on the top level')
2819
+ .default(DEFAULT_BGHITAPP_OPTIONS.alwaysOnTop)
2820
+ .hideHelp())
2821
+ .addOption(new Option('--maximize', 'Start window maximized')
2822
+ .default(DEFAULT_BGHITAPP_OPTIONS.maximize)
2823
+ .hideHelp())
2824
+ .addOption(new Option('--dark-mode', 'Force Mac app to use dark mode')
2825
+ .default(DEFAULT_BGHITAPP_OPTIONS.darkMode)
2826
+ .hideHelp())
2827
+ .addOption(new Option('--disabled-web-shortcuts', 'Disabled webPage shortcuts')
2828
+ .default(DEFAULT_BGHITAPP_OPTIONS.disabledWebShortcuts)
2829
+ .hideHelp())
2830
+ .addOption(new Option('--activation-shortcut <string>', 'Shortcut key to active App')
2831
+ .default(DEFAULT_BGHITAPP_OPTIONS.activationShortcut)
2832
+ .hideHelp())
2833
+ .addOption(new Option('--show-system-tray', 'Show system tray in app')
2834
+ .default(DEFAULT_BGHITAPP_OPTIONS.showSystemTray)
2835
+ .hideHelp())
2836
+ .addOption(new Option('--system-tray-icon <string>', 'Custom system tray icon')
2837
+ .default(DEFAULT_BGHITAPP_OPTIONS.systemTrayIcon)
2838
+ .hideHelp())
2839
+ .addOption(new Option('--hide-on-close [boolean]', 'Hide window on close instead of exiting (default: true for macOS, false for others)')
2840
+ .default(DEFAULT_BGHITAPP_OPTIONS.hideOnClose)
2841
+ .argParser((value) => {
2842
+ if (value === undefined)
2843
+ return true; // --hide-on-close without value
2844
+ if (value === 'true')
2845
+ return true;
2846
+ if (value === 'false')
2847
+ return false;
2848
+ throw new Error('--hide-on-close must be true or false');
2849
+ })
2850
+ .hideHelp())
2851
+ .addOption(new Option('--title <string>', 'Window title').hideHelp())
2852
+ .addOption(new Option('--incognito', 'Launch app in incognito/private mode')
2853
+ .default(DEFAULT_BGHITAPP_OPTIONS.incognito)
2854
+ .hideHelp())
2855
+ .addOption(new Option('--wasm', 'Enable WebAssembly support (Flutter Web, etc.)')
2856
+ .default(DEFAULT_BGHITAPP_OPTIONS.wasm)
2857
+ .hideHelp())
2858
+ .addOption(new Option('--enable-drag-drop', 'Enable drag and drop functionality')
2859
+ .default(DEFAULT_BGHITAPP_OPTIONS.enableDragDrop)
2860
+ .hideHelp())
2861
+ .addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
2862
+ .default(DEFAULT_BGHITAPP_OPTIONS.keepBinary)
2863
+ .hideHelp())
2864
+ .addOption(new Option('--multi-instance', 'Allow multiple app instances')
2865
+ .default(DEFAULT_BGHITAPP_OPTIONS.multiInstance)
2866
+ .hideHelp())
2867
+ .addOption(new Option('--multi-window', 'Allow opening multiple windows within one app instance')
2868
+ .default(DEFAULT_BGHITAPP_OPTIONS.multiWindow)
2869
+ .hideHelp())
2870
+ .addOption(new Option('--start-to-tray', 'Start app minimized to tray')
2871
+ .default(DEFAULT_BGHITAPP_OPTIONS.startToTray)
2872
+ .hideHelp())
2873
+ .addOption(new Option('--force-internal-navigation', 'Keep every link inside the BghitApp window instead of opening external handlers')
2874
+ .default(DEFAULT_BGHITAPP_OPTIONS.forceInternalNavigation)
2875
+ .hideHelp())
2876
+ .addOption(new Option('--internal-url-regex <string>', 'Regex pattern to match URLs that should be considered internal')
2877
+ .default(DEFAULT_BGHITAPP_OPTIONS.internalUrlRegex)
2878
+ .hideHelp())
2879
+ .addOption(new Option('--enable-find', 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts')
2880
+ .default(DEFAULT_BGHITAPP_OPTIONS.enableFind)
2881
+ .hideHelp())
2882
+ .addOption(new Option('--installer-language <string>', 'Installer language')
2883
+ .default(DEFAULT_BGHITAPP_OPTIONS.installerLanguage)
2884
+ .hideHelp())
2885
+ .addOption(new Option('--zoom <number>', 'Initial page zoom level (50-200)')
2886
+ .default(DEFAULT_BGHITAPP_OPTIONS.zoom)
2887
+ .argParser((value) => {
2888
+ const zoom = parseInt(value);
2889
+ if (isNaN(zoom) || zoom < 50 || zoom > 200) {
2890
+ throw new Error('--zoom must be a number between 50 and 200');
2891
+ }
2892
+ return zoom;
2893
+ })
2894
+ .hideHelp())
2895
+ .addOption(new Option('--min-width <number>', 'Minimum window width')
2896
+ .default(DEFAULT_BGHITAPP_OPTIONS.minWidth)
2897
+ .argParser(validateNumberInput)
2898
+ .hideHelp())
2899
+ .addOption(new Option('--min-height <number>', 'Minimum window height')
2900
+ .default(DEFAULT_BGHITAPP_OPTIONS.minHeight)
2901
+ .argParser(validateNumberInput)
2902
+ .hideHelp())
2903
+ .addOption(new Option('--ignore-certificate-errors', 'Ignore certificate errors (for self-signed certificates)')
2904
+ .default(DEFAULT_BGHITAPP_OPTIONS.ignoreCertificateErrors)
2905
+ .hideHelp())
2906
+ .addOption(new Option('--iterative-build', 'Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging')
2907
+ .default(DEFAULT_BGHITAPP_OPTIONS.iterativeBuild)
2908
+ .hideHelp())
2909
+ .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)')
2910
+ .default(DEFAULT_BGHITAPP_OPTIONS.newWindow)
2911
+ .hideHelp())
2912
+ .addOption(new Option('--install', 'Auto-install app to /Applications (macOS) after build and remove local bundle')
2913
+ .default(DEFAULT_BGHITAPP_OPTIONS.install)
2914
+ .hideHelp())
2915
+ .addOption(new Option('--camera', 'Request camera permission on macOS')
2916
+ .default(DEFAULT_BGHITAPP_OPTIONS.camera)
2917
+ .hideHelp())
2918
+ .addOption(new Option('--microphone', 'Request microphone permission on macOS')
2919
+ .default(DEFAULT_BGHITAPP_OPTIONS.microphone)
2920
+ .hideHelp())
2921
+ .addOption(new Option('--splash <path_or_url>', 'Splash screen image (local path or URL)')
2922
+ .default(DEFAULT_BGHITAPP_OPTIONS.splash)
2923
+ .hideHelp())
2924
+ .addOption(new Option('--auto-splash', 'Auto-fetch og:image from target URL for splash')
2925
+ .default(DEFAULT_BGHITAPP_OPTIONS.autoSplash)
2926
+ .hideHelp())
2927
+ .addOption(new Option('--offline', 'Enable offline fallback page')
2928
+ .default(DEFAULT_BGHITAPP_OPTIONS.offline)
2929
+ .hideHelp())
2930
+ .version(packageJson.version, '-v, --version')
2931
+ .configureHelp({
2932
+ sortSubcommands: true,
2933
+ optionTerm: (option) => {
2934
+ if (option.flags === '-v, --version' || option.flags === '-h, --help')
2935
+ return '';
2936
+ return option.flags;
2937
+ },
2938
+ optionDescription: (option) => {
2939
+ if (option.flags === '-v, --version' || option.flags === '-h, --help')
2940
+ return '';
2941
+ return option.description;
2942
+ },
2943
+ });
2944
+ }
2945
+
2946
+ const program = getCliProgram();
2947
+ async function checkUpdateTips() {
2948
+ updateNotifier({ pkg: packageJson, updateCheckInterval: 1000 * 60 }).notify({
2949
+ isGlobal: true,
2950
+ });
2951
+ }
2952
+ program.action(async (url, options) => {
2953
+ try {
2954
+ await checkUpdateTips();
2955
+ if (!url) {
2956
+ program.help({
2957
+ error: false,
2958
+ });
2959
+ return;
2960
+ }
2961
+ log.setDefaultLevel('info');
2962
+ log.setLevel('info');
2963
+ if (options.debug) {
2964
+ log.setLevel('debug');
2965
+ }
2966
+ const appOptions = await handleOptions(options, url);
2967
+ const builder = BuilderProvider.create(appOptions);
2968
+ await builder.prepare();
2969
+ await builder.build(url);
2970
+ }
2971
+ catch (error) {
2972
+ if (isBghitappError(error)) {
2973
+ console.error(chalk.red(error.message));
2974
+ }
2975
+ else if (error instanceof Error) {
2976
+ console.error(chalk.red(`✕ ${error.message}`));
2977
+ if (options?.debug && error.stack) {
2978
+ console.error(chalk.gray(error.stack));
2979
+ }
2980
+ }
2981
+ else {
2982
+ console.error(chalk.red(`✕ Unexpected error: ${String(error)}`));
2983
+ }
2984
+ process.exit(1);
2985
+ }
2986
+ });
2987
+ program.parseAsync().catch((error) => {
2988
+ if (error instanceof Error) {
2989
+ console.error(chalk.red(`✕ ${error.message}`));
2990
+ }
2991
+ else {
2992
+ console.error(chalk.red(`✕ Unexpected error: ${String(error)}`));
2993
+ }
2994
+ process.exit(1);
2995
+ });