pake-cli 3.13.0 โ 3.13.1
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/README.md +1 -2
- package/dist/cli.js +119 -59
- package/package.json +1 -1
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/app/invoke.rs +5 -2
- package/src-tauri/src/app/window.rs +13 -4
- package/src-tauri/src/inject/event.js +218 -8
- package/src-tauri/src/inject/style.js +1 -1
- package/src-tauri/src/util.rs +49 -0
- package/src-tauri/tauri.conf.json +1 -1
package/README.md
CHANGED
|
@@ -203,8 +203,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c
|
|
|
203
203
|
## Support
|
|
204
204
|
|
|
205
205
|
- The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid Mac cleanup app.
|
|
206
|
-
- If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux)
|
|
207
|
-
- Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model.
|
|
206
|
+
- If Pake helped you, give it a star, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux), or open an issue or PR.
|
|
208
207
|
- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them <a href="https://cats.tw93.fun?name=Pake" target="_blank">canned food ๐ฅฉ</a>.
|
|
209
208
|
|
|
210
209
|
<details>
|
package/dist/cli.js
CHANGED
|
@@ -20,7 +20,7 @@ import * as psl from 'psl';
|
|
|
20
20
|
import { InvalidArgumentError, program as program$1, Option } from 'commander';
|
|
21
21
|
|
|
22
22
|
var name = "pake-cli";
|
|
23
|
-
var version = "3.13.
|
|
23
|
+
var version = "3.13.1";
|
|
24
24
|
var description = "๐คฑ๐ป Turn any webpage into a desktop app with one command. ๐คฑ๐ป ไธ้ฎๆๅ
็ฝ้กต็ๆ่ฝป้ๆก้ขๅบ็จใ";
|
|
25
25
|
var engines = {
|
|
26
26
|
node: ">=18.0.0"
|
|
@@ -435,9 +435,13 @@ async function combineFiles(files, output) {
|
|
|
435
435
|
});`;
|
|
436
436
|
}
|
|
437
437
|
const fileContent = await fs$1.readFile(file);
|
|
438
|
-
|
|
438
|
+
// Keep the closing `});` on its own line. If the injected file ends in a
|
|
439
|
+
// line comment without a trailing newline, appending ` });` on the same
|
|
440
|
+
// line would comment it out and break the wrapper (mirrors the .css
|
|
441
|
+
// branch above, which already closes on a separate line).
|
|
442
|
+
return ("window.addEventListener('DOMContentLoaded', (_event) => {\n" +
|
|
439
443
|
fileContent +
|
|
440
|
-
'
|
|
444
|
+
'\n});');
|
|
441
445
|
}));
|
|
442
446
|
await fs$1.writeFile(output, contents.join('\n'));
|
|
443
447
|
return files;
|
|
@@ -508,6 +512,18 @@ function filterLinuxTargets(targets) {
|
|
|
508
512
|
function needsTemporaryDebForZst(targets) {
|
|
509
513
|
return targets.includes('zst') && !targets.includes('deb');
|
|
510
514
|
}
|
|
515
|
+
// Resolves the Tauri `bundle.targets` list for a Linux build from a
|
|
516
|
+
// comma-separated --targets string (e.g. the distro-aware default
|
|
517
|
+
// "deb,appimage"). zst is repacked from the deb payload, so it maps to a deb
|
|
518
|
+
// bundle. hasValidTarget is false only when no known target is present, which
|
|
519
|
+
// is the single case that should warn and fall back to the default.
|
|
520
|
+
function resolveLinuxBundleTargets(targets) {
|
|
521
|
+
const requested = filterLinuxTargets(targets);
|
|
522
|
+
const bundleTargets = [
|
|
523
|
+
...new Set(requested.map((target) => (target === 'zst' ? 'deb' : target))),
|
|
524
|
+
];
|
|
525
|
+
return { bundleTargets, hasValidTarget: requested.length > 0 };
|
|
526
|
+
}
|
|
511
527
|
|
|
512
528
|
/**
|
|
513
529
|
* Pure transform from CLI options to the window-config slice that gets
|
|
@@ -596,30 +612,31 @@ async function handleLocalFile(url, useLocalFile, tauriConf) {
|
|
|
596
612
|
tauriConf.pake.windows[0].url_type = 'web';
|
|
597
613
|
}
|
|
598
614
|
}
|
|
599
|
-
|
|
600
|
-
const linuxBundle = tauriConf.bundle.linux;
|
|
601
|
-
if (!linuxBundle) {
|
|
602
|
-
throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.');
|
|
603
|
-
}
|
|
604
|
-
delete linuxBundle.deb.files;
|
|
605
|
-
const linuxName = generateLinuxPackageName(name);
|
|
606
|
-
const desktopFileName = `com.pake.${linuxName}.desktop`;
|
|
607
|
-
const iconName = `${linuxName}_512`;
|
|
608
|
-
const { title } = options;
|
|
615
|
+
function buildLinuxDesktopContent(name, title, linuxBinaryName) {
|
|
609
616
|
const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
|
|
610
|
-
|
|
617
|
+
return `[Desktop Entry]
|
|
611
618
|
Version=1.0
|
|
612
619
|
Type=Application
|
|
613
620
|
Name=${name}
|
|
614
621
|
${chineseName ? `Name[zh_CN]=${chineseName}` : ''}
|
|
615
622
|
Comment=${name}
|
|
616
623
|
Exec=${linuxBinaryName}
|
|
617
|
-
Icon=${
|
|
624
|
+
Icon=${linuxBinaryName}
|
|
618
625
|
Categories=Network;WebBrowser;Utility;
|
|
619
626
|
MimeType=text/html;text/xml;application/xhtml_xml;
|
|
620
627
|
StartupNotify=true
|
|
621
628
|
Terminal=false
|
|
622
629
|
`;
|
|
630
|
+
}
|
|
631
|
+
async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) {
|
|
632
|
+
const linuxBundle = tauriConf.bundle.linux;
|
|
633
|
+
if (!linuxBundle) {
|
|
634
|
+
throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.');
|
|
635
|
+
}
|
|
636
|
+
delete linuxBundle.deb.files;
|
|
637
|
+
const linuxName = generateLinuxPackageName(name);
|
|
638
|
+
const desktopFileName = `com.pake.${linuxName}.desktop`;
|
|
639
|
+
const desktopContent = buildLinuxDesktopContent(name, options.title, linuxBinaryName);
|
|
623
640
|
const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets');
|
|
624
641
|
const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName);
|
|
625
642
|
await fsExtra.ensureDir(srcAssetsDir);
|
|
@@ -634,19 +651,44 @@ Terminal=false
|
|
|
634
651
|
linuxBundle.rpm.files = {
|
|
635
652
|
[desktopInstallPath]: `assets/${desktopFileName}`,
|
|
636
653
|
};
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
// zst is repacked from the deb payload, so Tauri itself bundles a deb.
|
|
646
|
-
tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget];
|
|
654
|
+
// options.targets reaches here already stripped of any -arm64 suffix by the
|
|
655
|
+
// LinuxBuilder constructor, and may carry several comma-separated formats
|
|
656
|
+
// (e.g. the distro-aware default "deb,appimage"). Validate the parsed list
|
|
657
|
+
// rather than string-matching the whole value, so a valid multi-target
|
|
658
|
+
// default no longer trips the "must be one of ..." warning on every build.
|
|
659
|
+
const { bundleTargets, hasValidTarget } = resolveLinuxBundleTargets(options.targets);
|
|
660
|
+
if (hasValidTarget) {
|
|
661
|
+
tauriConf.bundle.targets = bundleTargets;
|
|
647
662
|
}
|
|
648
663
|
else {
|
|
649
|
-
logger.warn(`โผ The target must be one of ${
|
|
664
|
+
logger.warn(`โผ The target must be one of ${LINUX_TARGET_TYPES.join(', ')}, the default 'deb' will be used.`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function resolveSystemTrayIconPath(systemTrayIcon, defaultTrayIconPath, safeAppName, iconOutputDir = path.join(npmDirectory, 'src-tauri/png')) {
|
|
668
|
+
if (systemTrayIcon.length === 0) {
|
|
669
|
+
return defaultTrayIconPath;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
const iconExt = path.extname(systemTrayIcon).toLowerCase();
|
|
673
|
+
if (iconExt !== '.png' && iconExt !== '.ico') {
|
|
674
|
+
logger.warn(`โผ System tray icon must be .ico or .png, but you provided ${iconExt}.`);
|
|
675
|
+
logger.warn(`โผ Default system tray icon will be used.`);
|
|
676
|
+
return defaultTrayIconPath;
|
|
677
|
+
}
|
|
678
|
+
if (!(await fsExtra.pathExists(systemTrayIcon))) {
|
|
679
|
+
logger.warn(`โผ System tray icon "${systemTrayIcon}" was not found.`);
|
|
680
|
+
logger.warn(`โผ Default system tray icon will be used.`);
|
|
681
|
+
return defaultTrayIconPath;
|
|
682
|
+
}
|
|
683
|
+
const trayIconPath = `png/${safeAppName}${iconExt}`;
|
|
684
|
+
const trayIcoPath = path.join(iconOutputDir, `${safeAppName}${iconExt}`);
|
|
685
|
+
await fsExtra.copy(systemTrayIcon, trayIcoPath);
|
|
686
|
+
return trayIconPath;
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
logger.warn(`โผ Failed to apply system tray icon "${systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`);
|
|
690
|
+
logger.warn(`โผ Default system tray icon will remain unchanged.`);
|
|
691
|
+
return defaultTrayIconPath;
|
|
650
692
|
}
|
|
651
693
|
}
|
|
652
694
|
async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
|
|
@@ -709,26 +751,8 @@ async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
|
|
|
709
751
|
tauriConf.bundle.icon = [iconInfo.defaultIcon];
|
|
710
752
|
}
|
|
711
753
|
// Set tray icon path.
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
try {
|
|
715
|
-
await fsExtra.pathExists(options.systemTrayIcon);
|
|
716
|
-
const iconExt = path.extname(options.systemTrayIcon).toLowerCase();
|
|
717
|
-
if (iconExt === '.png' || iconExt === '.ico') {
|
|
718
|
-
const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`);
|
|
719
|
-
trayIconPath = `png/${safeAppName}${iconExt}`;
|
|
720
|
-
await fsExtra.copy(options.systemTrayIcon, trayIcoPath);
|
|
721
|
-
}
|
|
722
|
-
else {
|
|
723
|
-
logger.warn(`โผ System tray icon must be .ico or .png, but you provided ${iconExt}.`);
|
|
724
|
-
logger.warn(`โผ Default system tray icon will be used.`);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
catch (err) {
|
|
728
|
-
logger.warn(`โผ Failed to apply system tray icon "${options.systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`);
|
|
729
|
-
logger.warn(`โผ Default system tray icon will remain unchanged.`);
|
|
730
|
-
}
|
|
731
|
-
}
|
|
754
|
+
const defaultTrayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
|
|
755
|
+
const trayIconPath = await resolveSystemTrayIconPath(options.systemTrayIcon, defaultTrayIconPath, safeAppName);
|
|
732
756
|
tauriConf.pake.system_tray_path = trayIconPath;
|
|
733
757
|
delete tauriConf.app.trayIcon;
|
|
734
758
|
}
|
|
@@ -1141,6 +1165,16 @@ class BaseBuilder {
|
|
|
1141
1165
|
throw retryError;
|
|
1142
1166
|
}
|
|
1143
1167
|
}
|
|
1168
|
+
// With --no-bundle there is no installer to copy; surface the raw
|
|
1169
|
+
// executable the build produced instead.
|
|
1170
|
+
if (this.options.bundle === false) {
|
|
1171
|
+
await this.copyRawBinary(npmDirectory, name);
|
|
1172
|
+
if (logSuccess) {
|
|
1173
|
+
logger.success('โ Build success!');
|
|
1174
|
+
logger.success('โ Raw binary located in', path.resolve(this.getRawBinaryPath(name)));
|
|
1175
|
+
}
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1144
1178
|
// Copy app
|
|
1145
1179
|
const fileName = this.getFileName();
|
|
1146
1180
|
const fileType = this.getFileType(target);
|
|
@@ -1508,17 +1542,11 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
1508
1542
|
arch =
|
|
1509
1543
|
buildType === 'rpm' || buildType === 'appimage' ? 'aarch64' : 'arm64';
|
|
1510
1544
|
}
|
|
1545
|
+
else if (this.buildArch === 'x64') {
|
|
1546
|
+
arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
|
|
1547
|
+
}
|
|
1511
1548
|
else {
|
|
1512
|
-
|
|
1513
|
-
arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
|
|
1514
|
-
}
|
|
1515
|
-
else {
|
|
1516
|
-
arch = this.buildArch;
|
|
1517
|
-
if (this.buildArch === 'arm64' &&
|
|
1518
|
-
(buildType === 'rpm' || buildType === 'appimage')) {
|
|
1519
|
-
arch = 'aarch64';
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1549
|
+
arch = this.buildArch;
|
|
1522
1550
|
}
|
|
1523
1551
|
if (this.currentBuildType === 'rpm') {
|
|
1524
1552
|
return `${name}-${version}-1.${arch}`;
|
|
@@ -1526,6 +1554,11 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
1526
1554
|
return `${name}_${version}_${arch}`;
|
|
1527
1555
|
}
|
|
1528
1556
|
async build(url) {
|
|
1557
|
+
// --no-bundle: build the executable once with no per-format packaging loop.
|
|
1558
|
+
if (this.options.bundle === false) {
|
|
1559
|
+
await this.buildAndCopy(url, 'deb');
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1529
1562
|
const targets = filterLinuxTargets(this.options.targets);
|
|
1530
1563
|
if (targets.length === 0) {
|
|
1531
1564
|
throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`);
|
|
@@ -1684,6 +1717,11 @@ post_remove() {
|
|
|
1684
1717
|
? (this.getTauriTarget(this.buildArch, 'linux') ?? undefined)
|
|
1685
1718
|
: undefined;
|
|
1686
1719
|
let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
|
|
1720
|
+
// --no-bundle: build the executable only, skipping .deb/.rpm/.appimage
|
|
1721
|
+
// packaging entirely (e.g. RPM-based distros where the bundler aborts).
|
|
1722
|
+
if (this.options.bundle === false) {
|
|
1723
|
+
return `${fullCommand} --no-bundle`;
|
|
1724
|
+
}
|
|
1687
1725
|
if (this.currentBuildType) {
|
|
1688
1726
|
fullCommand += ` --bundles ${this.currentBuildType}`;
|
|
1689
1727
|
}
|
|
@@ -2245,6 +2283,19 @@ async function convertIconFormat(inputPath, appName) {
|
|
|
2245
2283
|
return null;
|
|
2246
2284
|
}
|
|
2247
2285
|
}
|
|
2286
|
+
async function isLinuxBundleIconReady(iconPath) {
|
|
2287
|
+
if (!IS_LINUX || path.extname(iconPath).toLowerCase() !== '.png') {
|
|
2288
|
+
return false;
|
|
2289
|
+
}
|
|
2290
|
+
try {
|
|
2291
|
+
const { width, height } = await sharp(iconPath).metadata();
|
|
2292
|
+
return (width === PLATFORM_CONFIG.linux.size &&
|
|
2293
|
+
height === PLATFORM_CONFIG.linux.size);
|
|
2294
|
+
}
|
|
2295
|
+
catch {
|
|
2296
|
+
return false;
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2248
2299
|
/**
|
|
2249
2300
|
* Processes downloaded or local icon for platform-specific format
|
|
2250
2301
|
*/
|
|
@@ -2254,7 +2305,7 @@ async function processIcon(iconPath, appName) {
|
|
|
2254
2305
|
// Check if already in correct platform format
|
|
2255
2306
|
const ext = path.extname(iconPath).toLowerCase();
|
|
2256
2307
|
const isCorrectFormat = (IS_WIN && ext === '.ico') ||
|
|
2257
|
-
(IS_LINUX &&
|
|
2308
|
+
(IS_LINUX && (await isLinuxBundleIconReady(iconPath))) ||
|
|
2258
2309
|
(!IS_WIN && !IS_LINUX && ext === '.icns');
|
|
2259
2310
|
if (isCorrectFormat) {
|
|
2260
2311
|
return await copyWindowsIconIfNeeded(iconPath, appName);
|
|
@@ -2660,6 +2711,11 @@ async function handleOptions(options, url) {
|
|
|
2660
2711
|
if (!options.internalUrlRegex && options.safeDomain) {
|
|
2661
2712
|
appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain);
|
|
2662
2713
|
}
|
|
2714
|
+
// --no-bundle is Linux-only; keep normal packaging on other platforms.
|
|
2715
|
+
if (appOptions.bundle === false && platform !== 'linux') {
|
|
2716
|
+
logger.warn('โผ --no-bundle is only supported on Linux; ignoring it.');
|
|
2717
|
+
appOptions.bundle = true;
|
|
2718
|
+
}
|
|
2663
2719
|
const iconPath = await handleIcon(appOptions, url);
|
|
2664
2720
|
appOptions.icon = iconPath || '';
|
|
2665
2721
|
return appOptions;
|
|
@@ -2702,6 +2758,7 @@ const DEFAULT_PAKE_OPTIONS = {
|
|
|
2702
2758
|
incognito: false,
|
|
2703
2759
|
wasm: false,
|
|
2704
2760
|
enableDragDrop: false,
|
|
2761
|
+
bundle: true,
|
|
2705
2762
|
keepBinary: false,
|
|
2706
2763
|
multiInstance: false,
|
|
2707
2764
|
multiWindow: false,
|
|
@@ -2841,6 +2898,9 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
2841
2898
|
.addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
|
|
2842
2899
|
.default(DEFAULT_PAKE_OPTIONS.keepBinary)
|
|
2843
2900
|
.hideHelp())
|
|
2901
|
+
.addOption(new Option('--no-bundle', 'Skip packaging, output only the raw executable (Linux; for RPM distros where the bundler aborts)')
|
|
2902
|
+
.default(DEFAULT_PAKE_OPTIONS.bundle)
|
|
2903
|
+
.hideHelp())
|
|
2844
2904
|
.addOption(new Option('--multi-instance', 'Allow multiple app instances')
|
|
2845
2905
|
.default(DEFAULT_PAKE_OPTIONS.multiInstance)
|
|
2846
2906
|
.hideHelp())
|
|
@@ -2863,8 +2923,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
2863
2923
|
.default(DEFAULT_PAKE_OPTIONS.zoom)
|
|
2864
2924
|
.argParser((value) => {
|
|
2865
2925
|
const zoom = Number(value);
|
|
2866
|
-
if (!Number.
|
|
2867
|
-
throw new Error('--zoom must be
|
|
2926
|
+
if (!Number.isInteger(zoom) || zoom < 50 || zoom > 200) {
|
|
2927
|
+
throw new Error('--zoom must be an integer between 50 and 200');
|
|
2868
2928
|
}
|
|
2869
2929
|
return zoom;
|
|
2870
2930
|
})
|
package/package.json
CHANGED
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
use crate::util::{
|
|
1
|
+
use crate::util::{
|
|
2
|
+
check_file_or_append, get_download_message_with_lang, sanitize_download_filename, show_toast,
|
|
3
|
+
MessageType,
|
|
4
|
+
};
|
|
2
5
|
use std::fs::File;
|
|
3
6
|
use std::io::Write;
|
|
4
7
|
use std::str::FromStr;
|
|
@@ -93,7 +96,7 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result
|
|
|
93
96
|
.download_dir()
|
|
94
97
|
.map_err(|e| format!("Failed to get download dir: {}", e))?;
|
|
95
98
|
|
|
96
|
-
let output_path = download_dir.join(¶ms.filename);
|
|
99
|
+
let output_path = download_dir.join(sanitize_download_filename(¶ms.filename));
|
|
97
100
|
|
|
98
101
|
let path_str = output_path.to_str().ok_or("Invalid output path")?;
|
|
99
102
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
use crate::app::config::PakeConfig;
|
|
2
2
|
use crate::util::{
|
|
3
|
-
check_file_or_append, get_data_dir, get_download_message_with_lang,
|
|
3
|
+
check_file_or_append, get_data_dir, get_download_message_with_lang, sanitize_download_filename,
|
|
4
|
+
show_toast, MessageType,
|
|
4
5
|
};
|
|
5
6
|
use std::{
|
|
6
7
|
path::PathBuf,
|
|
@@ -289,9 +290,17 @@ fn build_window(
|
|
|
289
290
|
// any script that reads it (e.g. fullscreen polyfill checks for an opt-out
|
|
290
291
|
// flag), and toast must register `window.pakeToast` before Rust code
|
|
291
292
|
// calls show_toast().
|
|
293
|
+
window_builder = window_builder.initialization_script(&config_script);
|
|
294
|
+
|
|
295
|
+
// find.js is opt-in via --enable-find and no-ops at runtime when disabled,
|
|
296
|
+
// so only inject its ~700 lines when the feature is on. Avoids parsing the
|
|
297
|
+
// find UI on every page load in the common (find-off) case. Matches the
|
|
298
|
+
// enable_find gating already applied to the Find menu item.
|
|
299
|
+
if window_config.enable_find {
|
|
300
|
+
window_builder = window_builder.initialization_script(include_str!("../inject/find.js"));
|
|
301
|
+
}
|
|
302
|
+
|
|
292
303
|
window_builder = window_builder
|
|
293
|
-
.initialization_script(&config_script)
|
|
294
|
-
.initialization_script(include_str!("../inject/find.js"))
|
|
295
304
|
.initialization_script(include_str!("../inject/toast.js"))
|
|
296
305
|
.initialization_script(include_str!("../inject/fullscreen.js"))
|
|
297
306
|
.initialization_script(include_str!("../inject/event.js"))
|
|
@@ -456,7 +465,7 @@ fn build_window(
|
|
|
456
465
|
})
|
|
457
466
|
.unwrap_or_else(|| "download".to_string());
|
|
458
467
|
|
|
459
|
-
let target = download_dir.join(filename);
|
|
468
|
+
let target = download_dir.join(sanitize_download_filename(&filename));
|
|
460
469
|
if let Some(path_str) = target.to_str() {
|
|
461
470
|
*destination = PathBuf::from(check_file_or_append(path_str));
|
|
462
471
|
}
|
|
@@ -53,6 +53,177 @@ function handleShortcut(event) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
function isNonMacDesktop() {
|
|
57
|
+
return /windows|linux/i.test(navigator.userAgent);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isEditableElement(element) {
|
|
61
|
+
if (!element) return false;
|
|
62
|
+
|
|
63
|
+
const tagName = element.tagName;
|
|
64
|
+
return (
|
|
65
|
+
tagName === "INPUT" || tagName === "TEXTAREA" || element.isContentEditable
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function hasSelectedText() {
|
|
70
|
+
return Boolean(window.getSelection?.()?.toString());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const NON_TEXT_INPUT_TYPES = new Set([
|
|
74
|
+
"button",
|
|
75
|
+
"checkbox",
|
|
76
|
+
"color",
|
|
77
|
+
"file",
|
|
78
|
+
"hidden",
|
|
79
|
+
"image",
|
|
80
|
+
"radio",
|
|
81
|
+
"range",
|
|
82
|
+
"reset",
|
|
83
|
+
"submit",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
function isTextInputElement(element) {
|
|
87
|
+
return (
|
|
88
|
+
element?.tagName === "INPUT" &&
|
|
89
|
+
!NON_TEXT_INPUT_TYPES.has((element.type || "text").toLowerCase())
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function selectEditableElement(element) {
|
|
94
|
+
if (typeof element.select === "function") {
|
|
95
|
+
element.select();
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (element.isContentEditable) {
|
|
100
|
+
const range = document.createRange();
|
|
101
|
+
range.selectNodeContents(element);
|
|
102
|
+
const selection = window.getSelection?.();
|
|
103
|
+
if (!selection) return false;
|
|
104
|
+
|
|
105
|
+
selection.removeAllRanges();
|
|
106
|
+
selection.addRange(range);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function canPasteIntoEditableElement(element) {
|
|
114
|
+
if (!isEditableElement(element)) return false;
|
|
115
|
+
|
|
116
|
+
if (element.tagName === "INPUT") {
|
|
117
|
+
return (
|
|
118
|
+
isTextInputElement(element) &&
|
|
119
|
+
element.disabled !== true &&
|
|
120
|
+
element.readOnly !== true
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (element.tagName === "TEXTAREA") {
|
|
125
|
+
return element.disabled !== true && element.readOnly !== true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function insertTextIntoEditableElement(element, text) {
|
|
132
|
+
if (!text) return false;
|
|
133
|
+
|
|
134
|
+
if (document.execCommand("insertText", false, text)) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
element &&
|
|
140
|
+
(isTextInputElement(element) || element.tagName === "TEXTAREA") &&
|
|
141
|
+
typeof element.setRangeText === "function"
|
|
142
|
+
) {
|
|
143
|
+
const valueLength =
|
|
144
|
+
typeof element.value === "string" ? element.value.length : 0;
|
|
145
|
+
const start =
|
|
146
|
+
typeof element.selectionStart === "number"
|
|
147
|
+
? element.selectionStart
|
|
148
|
+
: valueLength;
|
|
149
|
+
const end =
|
|
150
|
+
typeof element.selectionEnd === "number" ? element.selectionEnd : start;
|
|
151
|
+
element.setRangeText(text, start, end, "end");
|
|
152
|
+
element.dispatchEvent?.(new Event("input", { bubbles: true }));
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function runBrowserPasteCommand() {
|
|
160
|
+
try {
|
|
161
|
+
return document.execCommand("paste") === true;
|
|
162
|
+
} catch (error) {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pasteClipboardText(activeElement) {
|
|
168
|
+
if (runBrowserPasteCommand()) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const readText = navigator.clipboard?.readText;
|
|
173
|
+
if (typeof readText !== "function") {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
readText
|
|
178
|
+
.call(navigator.clipboard)
|
|
179
|
+
.then((text) => {
|
|
180
|
+
insertTextIntoEditableElement(activeElement, text);
|
|
181
|
+
})
|
|
182
|
+
.catch(() => {});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function handleClipboardShortcut(event) {
|
|
186
|
+
if (
|
|
187
|
+
event.isTrusted !== true ||
|
|
188
|
+
!isNonMacDesktop() ||
|
|
189
|
+
!event.ctrlKey ||
|
|
190
|
+
event.metaKey ||
|
|
191
|
+
event.altKey ||
|
|
192
|
+
event.shiftKey
|
|
193
|
+
) {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const key = event.key?.toLowerCase();
|
|
198
|
+
const activeElement = document.activeElement;
|
|
199
|
+
const isEditable = isEditableElement(activeElement);
|
|
200
|
+
|
|
201
|
+
if (key === "c" && (isEditable || hasSelectedText())) {
|
|
202
|
+
document.execCommand("copy");
|
|
203
|
+
event.preventDefault();
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (key === "x" && isEditable) {
|
|
208
|
+
document.execCommand("cut");
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (key === "v" && canPasteIntoEditableElement(activeElement)) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
pasteClipboardText(activeElement);
|
|
216
|
+
return true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (key === "a" && isEditable && selectEditableElement(activeElement)) {
|
|
220
|
+
event.preventDefault();
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
56
227
|
const DOWNLOADABLE_FILE_EXTENSIONS = {
|
|
57
228
|
documents: [
|
|
58
229
|
"pdf",
|
|
@@ -275,12 +446,34 @@ function canNavigateAuthUrl(url) {
|
|
|
275
446
|
return normalizedUrl !== "" && normalizedUrl !== "about:blank";
|
|
276
447
|
}
|
|
277
448
|
|
|
449
|
+
function isAppleAuthPopup(url, name) {
|
|
450
|
+
if (name === "AppleAuthentication") {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
try {
|
|
455
|
+
return (
|
|
456
|
+
new URL(url, window.location.href).hostname.toLowerCase() ===
|
|
457
|
+
"appleid.apple.com"
|
|
458
|
+
);
|
|
459
|
+
} catch (error) {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
278
464
|
function navigateInCurrentWindow(url) {
|
|
279
465
|
window.location.href = url;
|
|
280
466
|
return window;
|
|
281
467
|
}
|
|
282
468
|
|
|
283
469
|
function openAuthNavigation(originalWindowOpen, url, name, specs) {
|
|
470
|
+
if (isAppleAuthPopup(url, name)) {
|
|
471
|
+
const authWindow = originalWindowOpen.call(window, url, name, specs);
|
|
472
|
+
if (authWindow) {
|
|
473
|
+
return authWindow;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
284
477
|
if (shouldNavigateAuthInCurrentWindow() && canNavigateAuthUrl(url)) {
|
|
285
478
|
return navigateInCurrentWindow(url);
|
|
286
479
|
}
|
|
@@ -345,6 +538,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
345
538
|
});
|
|
346
539
|
}
|
|
347
540
|
|
|
541
|
+
document.addEventListener("keydown", handleClipboardShortcut, true);
|
|
542
|
+
|
|
348
543
|
document.addEventListener(
|
|
349
544
|
"paste",
|
|
350
545
|
(event) => {
|
|
@@ -486,13 +681,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
486
681
|
|
|
487
682
|
if (isInternalUrl(absoluteUrl)) {
|
|
488
683
|
// With --new-window the Rust on_new_window handler opens an in-app
|
|
489
|
-
// window
|
|
490
|
-
//
|
|
491
|
-
//
|
|
684
|
+
// window. Without it, leaving target="_blank" untouched lets the
|
|
685
|
+
// native webview escalate the click to a system-browser "new window".
|
|
686
|
+
//
|
|
687
|
+
// Many SPAs (e.g. Plane) tag in-app links with target="_blank" but
|
|
688
|
+
// route the click themselves via a React onClick that calls
|
|
689
|
+
// preventDefault + client-side navigation. Forcing a full
|
|
690
|
+
// window.location reload here (and stopping propagation) would defeat
|
|
691
|
+
// that handler and reload the whole app on every click. Instead,
|
|
692
|
+
// retarget the link to "_self" so the webview never opens a browser
|
|
693
|
+
// window, then let the page's own handler run. If nothing intercepts
|
|
694
|
+
// the click, the default _self navigation keeps it inside the app.
|
|
492
695
|
if (!window.pakeConfig?.new_window) {
|
|
493
|
-
|
|
494
|
-
e.stopImmediatePropagation();
|
|
495
|
-
window.location.href = absoluteUrl;
|
|
696
|
+
anchorElement.target = "_self";
|
|
496
697
|
}
|
|
497
698
|
return;
|
|
498
699
|
}
|
|
@@ -1124,8 +1325,17 @@ function getFilenameFromUrl(url) {
|
|
|
1124
1325
|
|
|
1125
1326
|
// Detect image type from URL or data URI
|
|
1126
1327
|
if (url.startsWith("data:image/")) {
|
|
1127
|
-
|
|
1128
|
-
|
|
1328
|
+
// Read only the MIME subtype: stop at ';' (params) or ',' (data),
|
|
1329
|
+
// whichever comes first, so we never fold the encoding/payload into
|
|
1330
|
+
// the extension. Map structured suffixes (svg+xml -> svg) and jpeg.
|
|
1331
|
+
const semicolon = url.indexOf(";");
|
|
1332
|
+
const comma = url.indexOf(",");
|
|
1333
|
+
let end = url.length;
|
|
1334
|
+
if (semicolon !== -1) end = Math.min(end, semicolon);
|
|
1335
|
+
if (comma !== -1) end = Math.min(end, comma);
|
|
1336
|
+
let ext = url.substring(11, end).split("+")[0];
|
|
1337
|
+
if (ext === "jpeg") ext = "jpg";
|
|
1338
|
+
filename = `image-${timestamp}.${ext}`;
|
|
1129
1339
|
} else {
|
|
1130
1340
|
// Default to common image extensions based on common patterns
|
|
1131
1341
|
if (url.includes("jpg") || url.includes("jpeg")) {
|
|
@@ -10,7 +10,7 @@ window.addEventListener("DOMContentLoaded", (_event) => {
|
|
|
10
10
|
#Bottom > div.content > div.inner,
|
|
11
11
|
#Rightbar .sep20:nth-of-type(5),
|
|
12
12
|
#Rightbar > div.box:nth-child(4),
|
|
13
|
-
#Main > div.box:nth-child(8) > div
|
|
13
|
+
#Main > div.box:nth-child(8) > div,
|
|
14
14
|
#Wrapper > div.sep20,
|
|
15
15
|
#Main > div.box:nth-child(8),
|
|
16
16
|
#masthead-ad,
|
package/src-tauri/src/util.rs
CHANGED
|
@@ -111,6 +111,22 @@ pub fn get_download_message_with_lang(
|
|
|
111
111
|
.to_string()
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
pub fn sanitize_download_filename(filename: &str) -> String {
|
|
115
|
+
let Some(candidate) = filename
|
|
116
|
+
.rsplit(['/', '\\'])
|
|
117
|
+
.find(|part| !part.trim().is_empty())
|
|
118
|
+
.map(str::trim)
|
|
119
|
+
else {
|
|
120
|
+
return "download".to_string();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if candidate == "." || candidate == ".." {
|
|
124
|
+
"download".to_string()
|
|
125
|
+
} else {
|
|
126
|
+
candidate.to_string()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
114
130
|
/// Check if the file exists. If it does, append `-N` to the stem until a free
|
|
115
131
|
/// path is found.
|
|
116
132
|
///
|
|
@@ -218,6 +234,39 @@ mod tests {
|
|
|
218
234
|
let _ = fs::remove_dir_all(path.parent().unwrap());
|
|
219
235
|
}
|
|
220
236
|
|
|
237
|
+
#[test]
|
|
238
|
+
fn sanitize_download_filename_keeps_plain_names() {
|
|
239
|
+
assert_eq!(sanitize_download_filename("report.pdf"), "report.pdf");
|
|
240
|
+
assert_eq!(sanitize_download_filename(" report.pdf "), "report.pdf");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#[test]
|
|
244
|
+
fn sanitize_download_filename_takes_the_final_path_segment() {
|
|
245
|
+
assert_eq!(
|
|
246
|
+
sanitize_download_filename("../../private/report.pdf"),
|
|
247
|
+
"report.pdf"
|
|
248
|
+
);
|
|
249
|
+
assert_eq!(
|
|
250
|
+
sanitize_download_filename("..\\private\\report.pdf"),
|
|
251
|
+
"report.pdf"
|
|
252
|
+
);
|
|
253
|
+
assert_eq!(
|
|
254
|
+
sanitize_download_filename("nested/path/archive.tar.gz"),
|
|
255
|
+
"archive.tar.gz"
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
#[test]
|
|
260
|
+
fn sanitize_download_filename_falls_back_for_empty_or_parent_segments() {
|
|
261
|
+
for filename in ["", " ", "/", "\\", ".", "..", "../..", "..\\.."] {
|
|
262
|
+
assert_eq!(
|
|
263
|
+
sanitize_download_filename(filename),
|
|
264
|
+
"download",
|
|
265
|
+
"{filename}"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
221
270
|
#[test]
|
|
222
271
|
fn download_message_falls_back_to_english_for_unknown_locale() {
|
|
223
272
|
let msg = get_download_message_with_lang(MessageType::Start, Some("fr-FR".to_string()));
|