@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.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/dist/cli.js +2995 -0
- package/package.json +104 -0
- package/src-tauri/Cargo.lock +5966 -0
- package/src-tauri/Cargo.toml +59 -0
- package/src-tauri/Info.plist +14 -0
- package/src-tauri/assets/macos/dmg/background.png +0 -0
- package/src-tauri/assets/main.wxs +350 -0
- package/src-tauri/bghitapp.json +42 -0
- package/src-tauri/build.rs +5 -0
- package/src-tauri/capabilities/default.json +29 -0
- package/src-tauri/entitlements.plist +7 -0
- package/src-tauri/icons/chatgpt.icns +0 -0
- package/src-tauri/icons/deepseek.icns +0 -0
- package/src-tauri/icons/excalidraw.icns +0 -0
- package/src-tauri/icons/flomo.icns +0 -0
- package/src-tauri/icons/gemini.icns +0 -0
- package/src-tauri/icons/grok.icns +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/icons/lizhi.icns +0 -0
- package/src-tauri/icons/programmusic.icns +0 -0
- package/src-tauri/icons/qwerty.icns +0 -0
- package/src-tauri/icons/twitter.icns +0 -0
- package/src-tauri/icons/wechat.icns +0 -0
- package/src-tauri/icons/weekly.icns +0 -0
- package/src-tauri/icons/weread.icns +0 -0
- package/src-tauri/icons/xiaohongshu.icns +0 -0
- package/src-tauri/icons/youtube.icns +0 -0
- package/src-tauri/icons/youtubemusic.icns +0 -0
- package/src-tauri/rust_proxy.toml +10 -0
- package/src-tauri/src/app/config.rs +100 -0
- package/src-tauri/src/app/invoke.rs +242 -0
- package/src-tauri/src/app/menu.rs +324 -0
- package/src-tauri/src/app/mod.rs +6 -0
- package/src-tauri/src/app/setup.rs +172 -0
- package/src-tauri/src/app/window.rs +577 -0
- package/src-tauri/src/inject/auth.js +75 -0
- package/src-tauri/src/inject/custom.js +0 -0
- package/src-tauri/src/inject/event.js +1111 -0
- package/src-tauri/src/inject/find.js +708 -0
- package/src-tauri/src/inject/fullscreen.js +253 -0
- package/src-tauri/src/inject/offline.js +68 -0
- package/src-tauri/src/inject/splash-transition.js +13 -0
- package/src-tauri/src/inject/style.js +505 -0
- package/src-tauri/src/inject/theme_refresh.js +59 -0
- package/src-tauri/src/inject/toast.js +22 -0
- package/src-tauri/src/lib.rs +227 -0
- package/src-tauri/src/main.rs +8 -0
- package/src-tauri/src/util.rs +245 -0
- package/src-tauri/tauri.conf.json +20 -0
- package/src-tauri/tauri.linux.conf.json +12 -0
- package/src-tauri/tauri.macos.conf.json +28 -0
- 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
|
+
});
|