pake-cli 3.12.1 โ 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 +248 -74
- 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 +17 -2
- package/src-tauri/src/app/setup.rs +6 -1
- package/src-tauri/src/app/window.rs +13 -4
- package/src-tauri/src/inject/event.js +225 -21
- package/src-tauri/src/inject/style.js +1 -1
- package/src-tauri/src/lib.rs +5 -2
- 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
|
@@ -10,17 +10,17 @@ import os from 'os';
|
|
|
10
10
|
import { execa, execaSync } from 'execa';
|
|
11
11
|
import crypto from 'crypto';
|
|
12
12
|
import ora from 'ora';
|
|
13
|
-
import fs from 'fs
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import fs$1 from 'fs/promises';
|
|
14
15
|
import { dir } from 'tmp-promise';
|
|
15
16
|
import { fileTypeFromBuffer } from 'file-type';
|
|
16
17
|
import icongen from 'icon-gen';
|
|
17
18
|
import sharp from 'sharp';
|
|
18
19
|
import * as psl from 'psl';
|
|
19
20
|
import { InvalidArgumentError, program as program$1, Option } from 'commander';
|
|
20
|
-
import fs$1 from 'fs';
|
|
21
21
|
|
|
22
22
|
var name = "pake-cli";
|
|
23
|
-
var version = "3.
|
|
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"
|
|
@@ -230,6 +230,93 @@ const { platform: platform$1 } = process;
|
|
|
230
230
|
const IS_MAC = platform$1 === 'darwin';
|
|
231
231
|
const IS_WIN = platform$1 === 'win32';
|
|
232
232
|
const IS_LINUX = platform$1 === 'linux';
|
|
233
|
+
// Distro IDs / ID_LIKE families that ship an RPM-based package manager.
|
|
234
|
+
const RPM_FAMILY_IDS = new Set([
|
|
235
|
+
'rhel',
|
|
236
|
+
'fedora',
|
|
237
|
+
'centos',
|
|
238
|
+
'rocky',
|
|
239
|
+
'almalinux',
|
|
240
|
+
'ol', // Oracle Linux
|
|
241
|
+
'oracle',
|
|
242
|
+
'amzn', // Amazon Linux
|
|
243
|
+
'mariner',
|
|
244
|
+
'azurelinux',
|
|
245
|
+
'suse',
|
|
246
|
+
'opensuse',
|
|
247
|
+
'opensuse-leap',
|
|
248
|
+
'opensuse-tumbleweed',
|
|
249
|
+
'sles',
|
|
250
|
+
]);
|
|
251
|
+
// Distro IDs / ID_LIKE families that ship a DEB-based package manager.
|
|
252
|
+
const DEB_FAMILY_IDS = new Set([
|
|
253
|
+
'debian',
|
|
254
|
+
'ubuntu',
|
|
255
|
+
'linuxmint',
|
|
256
|
+
'pop',
|
|
257
|
+
'elementary',
|
|
258
|
+
'kali',
|
|
259
|
+
'raspbian',
|
|
260
|
+
'devuan',
|
|
261
|
+
]);
|
|
262
|
+
// Parse the shell-style key=value pairs of an /etc/os-release file, stripping
|
|
263
|
+
// the optional surrounding quotes around values.
|
|
264
|
+
function parseOsRelease(content) {
|
|
265
|
+
const fields = {};
|
|
266
|
+
for (const rawLine of content.split('\n')) {
|
|
267
|
+
const line = rawLine.trim();
|
|
268
|
+
if (!line || line.startsWith('#'))
|
|
269
|
+
continue;
|
|
270
|
+
const separator = line.indexOf('=');
|
|
271
|
+
if (separator === -1)
|
|
272
|
+
continue;
|
|
273
|
+
const key = line.slice(0, separator).trim();
|
|
274
|
+
let value = line.slice(separator + 1).trim();
|
|
275
|
+
if (value.length >= 2 &&
|
|
276
|
+
((value.startsWith('"') && value.endsWith('"')) ||
|
|
277
|
+
(value.startsWith("'") && value.endsWith("'")))) {
|
|
278
|
+
value = value.slice(1, -1);
|
|
279
|
+
}
|
|
280
|
+
if (key)
|
|
281
|
+
fields[key] = value;
|
|
282
|
+
}
|
|
283
|
+
return fields;
|
|
284
|
+
}
|
|
285
|
+
// Detect the package family from /etc/os-release. The distro's own ID wins over
|
|
286
|
+
// ID_LIKE hints, and an unknown distro falls back to 'deb' to preserve Pake's
|
|
287
|
+
// historical default. Accepts content directly so the decision is unit-testable
|
|
288
|
+
// without a real /etc/os-release.
|
|
289
|
+
function detectLinuxPackageFamily(osReleaseContent) {
|
|
290
|
+
let content = osReleaseContent;
|
|
291
|
+
if (content === undefined) {
|
|
292
|
+
try {
|
|
293
|
+
content = fs.readFileSync('/etc/os-release', 'utf-8');
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return 'deb';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const fields = parseOsRelease(content);
|
|
300
|
+
const id = (fields.ID ?? '').toLowerCase().trim();
|
|
301
|
+
const idLike = (fields.ID_LIKE ?? '')
|
|
302
|
+
.toLowerCase()
|
|
303
|
+
.split(/\s+/)
|
|
304
|
+
.filter(Boolean);
|
|
305
|
+
for (const token of [id, ...idLike]) {
|
|
306
|
+
if (DEB_FAMILY_IDS.has(token))
|
|
307
|
+
return 'deb';
|
|
308
|
+
if (RPM_FAMILY_IDS.has(token))
|
|
309
|
+
return 'rpm';
|
|
310
|
+
}
|
|
311
|
+
return 'deb';
|
|
312
|
+
}
|
|
313
|
+
// Default Linux bundle targets, chosen by the host distro's package family so
|
|
314
|
+
// RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) get a native .rpm
|
|
315
|
+
// instead of a .deb their package manager cannot install. AppImage stays as a
|
|
316
|
+
// universal fallback in both cases.
|
|
317
|
+
function getDefaultLinuxTargets() {
|
|
318
|
+
return detectLinuxPackageFamily() === 'rpm' ? 'rpm,appimage' : 'deb,appimage';
|
|
319
|
+
}
|
|
233
320
|
|
|
234
321
|
async function shellExec(command, timeout = 300000, env) {
|
|
235
322
|
try {
|
|
@@ -339,7 +426,7 @@ function checkRustInstalled() {
|
|
|
339
426
|
async function combineFiles(files, output) {
|
|
340
427
|
const contents = await Promise.all(files.map(async (file) => {
|
|
341
428
|
if (file.endsWith('.css')) {
|
|
342
|
-
const fileContent = await fs.readFile(file, 'utf-8');
|
|
429
|
+
const fileContent = await fs$1.readFile(file, 'utf-8');
|
|
343
430
|
return `window.addEventListener('DOMContentLoaded', (_event) => {
|
|
344
431
|
const css = ${JSON.stringify(fileContent)};
|
|
345
432
|
const style = document.createElement('style');
|
|
@@ -347,12 +434,16 @@ async function combineFiles(files, output) {
|
|
|
347
434
|
document.head.appendChild(style);
|
|
348
435
|
});`;
|
|
349
436
|
}
|
|
350
|
-
const fileContent = await fs.readFile(file);
|
|
351
|
-
|
|
437
|
+
const fileContent = await fs$1.readFile(file);
|
|
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" +
|
|
352
443
|
fileContent +
|
|
353
|
-
'
|
|
444
|
+
'\n});');
|
|
354
445
|
}));
|
|
355
|
-
await fs.writeFile(output, contents.join('\n'));
|
|
446
|
+
await fs$1.writeFile(output, contents.join('\n'));
|
|
356
447
|
return files;
|
|
357
448
|
}
|
|
358
449
|
|
|
@@ -421,6 +512,18 @@ function filterLinuxTargets(targets) {
|
|
|
421
512
|
function needsTemporaryDebForZst(targets) {
|
|
422
513
|
return targets.includes('zst') && !targets.includes('deb');
|
|
423
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
|
+
}
|
|
424
527
|
|
|
425
528
|
/**
|
|
426
529
|
* Pure transform from CLI options to the window-config slice that gets
|
|
@@ -509,30 +612,31 @@ async function handleLocalFile(url, useLocalFile, tauriConf) {
|
|
|
509
612
|
tauriConf.pake.windows[0].url_type = 'web';
|
|
510
613
|
}
|
|
511
614
|
}
|
|
512
|
-
|
|
513
|
-
const linuxBundle = tauriConf.bundle.linux;
|
|
514
|
-
if (!linuxBundle) {
|
|
515
|
-
throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.');
|
|
516
|
-
}
|
|
517
|
-
delete linuxBundle.deb.files;
|
|
518
|
-
const linuxName = generateLinuxPackageName(name);
|
|
519
|
-
const desktopFileName = `com.pake.${linuxName}.desktop`;
|
|
520
|
-
const iconName = `${linuxName}_512`;
|
|
521
|
-
const { title } = options;
|
|
615
|
+
function buildLinuxDesktopContent(name, title, linuxBinaryName) {
|
|
522
616
|
const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
|
|
523
|
-
|
|
617
|
+
return `[Desktop Entry]
|
|
524
618
|
Version=1.0
|
|
525
619
|
Type=Application
|
|
526
620
|
Name=${name}
|
|
527
621
|
${chineseName ? `Name[zh_CN]=${chineseName}` : ''}
|
|
528
622
|
Comment=${name}
|
|
529
623
|
Exec=${linuxBinaryName}
|
|
530
|
-
Icon=${
|
|
624
|
+
Icon=${linuxBinaryName}
|
|
531
625
|
Categories=Network;WebBrowser;Utility;
|
|
532
626
|
MimeType=text/html;text/xml;application/xhtml_xml;
|
|
533
627
|
StartupNotify=true
|
|
534
628
|
Terminal=false
|
|
535
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);
|
|
536
640
|
const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets');
|
|
537
641
|
const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName);
|
|
538
642
|
await fsExtra.ensureDir(srcAssetsDir);
|
|
@@ -547,19 +651,44 @@ Terminal=false
|
|
|
547
651
|
linuxBundle.rpm.files = {
|
|
548
652
|
[desktopInstallPath]: `assets/${desktopFileName}`,
|
|
549
653
|
};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
// zst is repacked from the deb payload, so Tauri itself bundles a deb.
|
|
559
|
-
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;
|
|
560
662
|
}
|
|
561
663
|
else {
|
|
562
|
-
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;
|
|
563
692
|
}
|
|
564
693
|
}
|
|
565
694
|
async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
|
|
@@ -622,26 +751,8 @@ async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
|
|
|
622
751
|
tauriConf.bundle.icon = [iconInfo.defaultIcon];
|
|
623
752
|
}
|
|
624
753
|
// Set tray icon path.
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
try {
|
|
628
|
-
await fsExtra.pathExists(options.systemTrayIcon);
|
|
629
|
-
const iconExt = path.extname(options.systemTrayIcon).toLowerCase();
|
|
630
|
-
if (iconExt === '.png' || iconExt === '.ico') {
|
|
631
|
-
const trayIcoPath = path.join(npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`);
|
|
632
|
-
trayIconPath = `png/${safeAppName}${iconExt}`;
|
|
633
|
-
await fsExtra.copy(options.systemTrayIcon, trayIcoPath);
|
|
634
|
-
}
|
|
635
|
-
else {
|
|
636
|
-
logger.warn(`โผ System tray icon must be .ico or .png, but you provided ${iconExt}.`);
|
|
637
|
-
logger.warn(`โผ Default system tray icon will be used.`);
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
catch (err) {
|
|
641
|
-
logger.warn(`โผ Failed to apply system tray icon "${options.systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`);
|
|
642
|
-
logger.warn(`โผ Default system tray icon will remain unchanged.`);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
754
|
+
const defaultTrayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
|
|
755
|
+
const trayIconPath = await resolveSystemTrayIconPath(options.systemTrayIcon, defaultTrayIconPath, safeAppName);
|
|
645
756
|
tauriConf.pake.system_tray_path = trayIconPath;
|
|
646
757
|
delete tauriConf.app.trayIcon;
|
|
647
758
|
}
|
|
@@ -1054,6 +1165,16 @@ class BaseBuilder {
|
|
|
1054
1165
|
throw retryError;
|
|
1055
1166
|
}
|
|
1056
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
|
+
}
|
|
1057
1178
|
// Copy app
|
|
1058
1179
|
const fileName = this.getFileName();
|
|
1059
1180
|
const fileType = this.getFileType(target);
|
|
@@ -1421,17 +1542,11 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
1421
1542
|
arch =
|
|
1422
1543
|
buildType === 'rpm' || buildType === 'appimage' ? 'aarch64' : 'arm64';
|
|
1423
1544
|
}
|
|
1545
|
+
else if (this.buildArch === 'x64') {
|
|
1546
|
+
arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
|
|
1547
|
+
}
|
|
1424
1548
|
else {
|
|
1425
|
-
|
|
1426
|
-
arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
|
|
1427
|
-
}
|
|
1428
|
-
else {
|
|
1429
|
-
arch = this.buildArch;
|
|
1430
|
-
if (this.buildArch === 'arm64' &&
|
|
1431
|
-
(buildType === 'rpm' || buildType === 'appimage')) {
|
|
1432
|
-
arch = 'aarch64';
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1549
|
+
arch = this.buildArch;
|
|
1435
1550
|
}
|
|
1436
1551
|
if (this.currentBuildType === 'rpm') {
|
|
1437
1552
|
return `${name}-${version}-1.${arch}`;
|
|
@@ -1439,25 +1554,57 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
1439
1554
|
return `${name}_${version}_${arch}`;
|
|
1440
1555
|
}
|
|
1441
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
|
+
}
|
|
1442
1562
|
const targets = filterLinuxTargets(this.options.targets);
|
|
1443
1563
|
if (targets.length === 0) {
|
|
1444
1564
|
throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`);
|
|
1445
1565
|
}
|
|
1446
1566
|
const useTemporaryDebForZst = needsTemporaryDebForZst(targets);
|
|
1567
|
+
// With a single explicit target, fail fast. With multiple targets (the
|
|
1568
|
+
// distro-aware default, or an explicit comma list) keep building the rest
|
|
1569
|
+
// when one fails, so a usable installer is still produced, e.g. AppImage
|
|
1570
|
+
// survives a .deb bundler abort on RPM-based distros.
|
|
1571
|
+
const isolateFailures = targets.length > 1;
|
|
1572
|
+
const failed = [];
|
|
1573
|
+
let firstError = null;
|
|
1447
1574
|
for (const target of targets) {
|
|
1448
1575
|
this.currentBuildType = target;
|
|
1449
|
-
|
|
1450
|
-
if (
|
|
1451
|
-
|
|
1576
|
+
try {
|
|
1577
|
+
if (target === 'zst') {
|
|
1578
|
+
if (useTemporaryDebForZst) {
|
|
1579
|
+
await this.buildAndCopy(url, 'deb', false);
|
|
1580
|
+
}
|
|
1581
|
+
await this.createArchPackageFromDeb({
|
|
1582
|
+
removeSourceDeb: useTemporaryDebForZst,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
else {
|
|
1586
|
+
await this.buildAndCopy(url, target);
|
|
1452
1587
|
}
|
|
1453
|
-
await this.createArchPackageFromDeb({
|
|
1454
|
-
removeSourceDeb: useTemporaryDebForZst,
|
|
1455
|
-
});
|
|
1456
1588
|
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1589
|
+
catch (error) {
|
|
1590
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
1591
|
+
if (!isolateFailures) {
|
|
1592
|
+
throw err;
|
|
1593
|
+
}
|
|
1594
|
+
if (!firstError) {
|
|
1595
|
+
firstError = err;
|
|
1596
|
+
}
|
|
1597
|
+
failed.push(target);
|
|
1598
|
+
logger.warn(`โผ Failed to build "${target}" target: ${err.message.split('\n')[0]}`);
|
|
1459
1599
|
}
|
|
1460
1600
|
}
|
|
1601
|
+
// Every requested target failed: surface the first real error.
|
|
1602
|
+
if (firstError && failed.length === targets.length) {
|
|
1603
|
+
throw firstError;
|
|
1604
|
+
}
|
|
1605
|
+
if (failed.length > 0) {
|
|
1606
|
+
logger.warn(`โผ Skipped failed Linux targets: ${failed.join(', ')}. Other formats built successfully.`);
|
|
1607
|
+
}
|
|
1461
1608
|
}
|
|
1462
1609
|
async ensureArchPackagingTools() {
|
|
1463
1610
|
const requiredTools = [
|
|
@@ -1570,6 +1717,11 @@ post_remove() {
|
|
|
1570
1717
|
? (this.getTauriTarget(this.buildArch, 'linux') ?? undefined)
|
|
1571
1718
|
: undefined;
|
|
1572
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
|
+
}
|
|
1573
1725
|
if (this.currentBuildType) {
|
|
1574
1726
|
fullCommand += ` --bundles ${this.currentBuildType}`;
|
|
1575
1727
|
}
|
|
@@ -2131,6 +2283,19 @@ async function convertIconFormat(inputPath, appName) {
|
|
|
2131
2283
|
return null;
|
|
2132
2284
|
}
|
|
2133
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
|
+
}
|
|
2134
2299
|
/**
|
|
2135
2300
|
* Processes downloaded or local icon for platform-specific format
|
|
2136
2301
|
*/
|
|
@@ -2140,7 +2305,7 @@ async function processIcon(iconPath, appName) {
|
|
|
2140
2305
|
// Check if already in correct platform format
|
|
2141
2306
|
const ext = path.extname(iconPath).toLowerCase();
|
|
2142
2307
|
const isCorrectFormat = (IS_WIN && ext === '.ico') ||
|
|
2143
|
-
(IS_LINUX &&
|
|
2308
|
+
(IS_LINUX && (await isLinuxBundleIconReady(iconPath))) ||
|
|
2144
2309
|
(!IS_WIN && !IS_LINUX && ext === '.icns');
|
|
2145
2310
|
if (isCorrectFormat) {
|
|
2146
2311
|
return await copyWindowsIconIfNeeded(iconPath, appName);
|
|
@@ -2546,6 +2711,11 @@ async function handleOptions(options, url) {
|
|
|
2546
2711
|
if (!options.internalUrlRegex && options.safeDomain) {
|
|
2547
2712
|
appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain);
|
|
2548
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
|
+
}
|
|
2549
2719
|
const iconPath = await handleIcon(appOptions, url);
|
|
2550
2720
|
appOptions.icon = iconPath || '';
|
|
2551
2721
|
return appOptions;
|
|
@@ -2569,7 +2739,7 @@ const DEFAULT_PAKE_OPTIONS = {
|
|
|
2569
2739
|
targets: (() => {
|
|
2570
2740
|
switch (process.platform) {
|
|
2571
2741
|
case 'linux':
|
|
2572
|
-
return
|
|
2742
|
+
return getDefaultLinuxTargets();
|
|
2573
2743
|
case 'darwin':
|
|
2574
2744
|
return 'dmg';
|
|
2575
2745
|
case 'win32':
|
|
@@ -2588,6 +2758,7 @@ const DEFAULT_PAKE_OPTIONS = {
|
|
|
2588
2758
|
incognito: false,
|
|
2589
2759
|
wasm: false,
|
|
2590
2760
|
enableDragDrop: false,
|
|
2761
|
+
bundle: true,
|
|
2591
2762
|
keepBinary: false,
|
|
2592
2763
|
multiInstance: false,
|
|
2593
2764
|
multiWindow: false,
|
|
@@ -2621,7 +2792,7 @@ function validateNumberInput(value) {
|
|
|
2621
2792
|
return parsedValue;
|
|
2622
2793
|
}
|
|
2623
2794
|
function validateUrlInput(url) {
|
|
2624
|
-
const isFile = fs
|
|
2795
|
+
const isFile = fs.existsSync(url);
|
|
2625
2796
|
if (!isFile) {
|
|
2626
2797
|
try {
|
|
2627
2798
|
return normalizeUrl(url);
|
|
@@ -2727,6 +2898,9 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
2727
2898
|
.addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
|
|
2728
2899
|
.default(DEFAULT_PAKE_OPTIONS.keepBinary)
|
|
2729
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())
|
|
2730
2904
|
.addOption(new Option('--multi-instance', 'Allow multiple app instances')
|
|
2731
2905
|
.default(DEFAULT_PAKE_OPTIONS.multiInstance)
|
|
2732
2906
|
.hideHelp())
|
|
@@ -2749,8 +2923,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
2749
2923
|
.default(DEFAULT_PAKE_OPTIONS.zoom)
|
|
2750
2924
|
.argParser((value) => {
|
|
2751
2925
|
const zoom = Number(value);
|
|
2752
|
-
if (!Number.
|
|
2753
|
-
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');
|
|
2754
2928
|
}
|
|
2755
2929
|
return zoom;
|
|
2756
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
|
|
|
@@ -191,3 +194,15 @@ pub async fn update_theme_mode(app: AppHandle, mode: String) {
|
|
|
191
194
|
let _ = window.set_theme(Some(theme));
|
|
192
195
|
}
|
|
193
196
|
}
|
|
197
|
+
|
|
198
|
+
// Apply native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor / WebKitGTK
|
|
199
|
+
// zoom level) instead of CSS hacks. CSS `transform: scale` and `html.style.zoom`
|
|
200
|
+
// break complex SPAs like ChatGPT (fixed positioning shifts, unrepainted layers);
|
|
201
|
+
// native zoom recalculates layout the same way a browser does for Cmd/Ctrl +/-.
|
|
202
|
+
#[command]
|
|
203
|
+
pub fn set_zoom(window: WebviewWindow, percent: f64) -> Result<(), String> {
|
|
204
|
+
let factor = (percent / 100.0).clamp(0.3, 2.0);
|
|
205
|
+
window
|
|
206
|
+
.set_zoom(factor)
|
|
207
|
+
.map_err(|e| format!("Failed to set zoom: {}", e))
|
|
208
|
+
}
|
|
@@ -61,7 +61,12 @@ pub fn set_system_tray(
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
"quit" => {
|
|
64
|
-
let
|
|
64
|
+
let flags = if _init_fullscreen {
|
|
65
|
+
StateFlags::all()
|
|
66
|
+
} else {
|
|
67
|
+
StateFlags::all() & !StateFlags::FULLSCREEN
|
|
68
|
+
};
|
|
69
|
+
let _ = app.save_window_state(flags);
|
|
65
70
|
app.exit(0);
|
|
66
71
|
}
|
|
67
72
|
_ => (),
|
|
@@ -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
|
}
|
|
@@ -11,19 +11,13 @@ const shortcuts = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
function setZoom(zoom) {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
21
|
-
body.style.transformOrigin = "top left";
|
|
22
|
-
body.style.width = `${100 / zoomValue}%`;
|
|
23
|
-
body.style.height = `${100 / zoomValue}%`;
|
|
24
|
-
} else {
|
|
25
|
-
html.style.zoom = zoom;
|
|
26
|
-
window.dispatchEvent(new Event("resize"));
|
|
14
|
+
// Use native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor) instead of
|
|
15
|
+
// CSS hacks. `transform: scale` and `html.style.zoom` break complex SPAs like
|
|
16
|
+
// ChatGPT: the page shifts right on Windows and parts of the UI stop repainting
|
|
17
|
+
// on macOS. Native zoom recalculates layout exactly like a browser does.
|
|
18
|
+
const invoke = window.__TAURI__?.core?.invoke;
|
|
19
|
+
if (invoke) {
|
|
20
|
+
invoke("set_zoom", { percent: parseFloat(zoom) }).catch(() => {});
|
|
27
21
|
}
|
|
28
22
|
|
|
29
23
|
window.localStorage.setItem("htmlZoom", zoom);
|
|
@@ -59,6 +53,177 @@ function handleShortcut(event) {
|
|
|
59
53
|
}
|
|
60
54
|
}
|
|
61
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
|
+
|
|
62
227
|
const DOWNLOADABLE_FILE_EXTENSIONS = {
|
|
63
228
|
documents: [
|
|
64
229
|
"pdf",
|
|
@@ -281,12 +446,34 @@ function canNavigateAuthUrl(url) {
|
|
|
281
446
|
return normalizedUrl !== "" && normalizedUrl !== "about:blank";
|
|
282
447
|
}
|
|
283
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
|
+
|
|
284
464
|
function navigateInCurrentWindow(url) {
|
|
285
465
|
window.location.href = url;
|
|
286
466
|
return window;
|
|
287
467
|
}
|
|
288
468
|
|
|
289
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
|
+
|
|
290
477
|
if (shouldNavigateAuthInCurrentWindow() && canNavigateAuthUrl(url)) {
|
|
291
478
|
return navigateInCurrentWindow(url);
|
|
292
479
|
}
|
|
@@ -351,6 +538,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
351
538
|
});
|
|
352
539
|
}
|
|
353
540
|
|
|
541
|
+
document.addEventListener("keydown", handleClipboardShortcut, true);
|
|
542
|
+
|
|
354
543
|
document.addEventListener(
|
|
355
544
|
"paste",
|
|
356
545
|
(event) => {
|
|
@@ -492,13 +681,19 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
492
681
|
|
|
493
682
|
if (isInternalUrl(absoluteUrl)) {
|
|
494
683
|
// With --new-window the Rust on_new_window handler opens an in-app
|
|
495
|
-
// window
|
|
496
|
-
//
|
|
497
|
-
//
|
|
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.
|
|
498
695
|
if (!window.pakeConfig?.new_window) {
|
|
499
|
-
|
|
500
|
-
e.stopImmediatePropagation();
|
|
501
|
-
window.location.href = absoluteUrl;
|
|
696
|
+
anchorElement.target = "_self";
|
|
502
697
|
}
|
|
503
698
|
return;
|
|
504
699
|
}
|
|
@@ -1130,8 +1325,17 @@ function getFilenameFromUrl(url) {
|
|
|
1130
1325
|
|
|
1131
1326
|
// Detect image type from URL or data URI
|
|
1132
1327
|
if (url.startsWith("data:image/")) {
|
|
1133
|
-
|
|
1134
|
-
|
|
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}`;
|
|
1135
1339
|
} else {
|
|
1136
1340
|
// Default to common image extensions based on common patterns
|
|
1137
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/lib.rs
CHANGED
|
@@ -22,7 +22,7 @@ const GDK_BACKEND: &str = "GDK_BACKEND";
|
|
|
22
22
|
use app::{
|
|
23
23
|
invoke::{
|
|
24
24
|
clear_dock_badge, download_file, increment_dock_badge, send_notification, set_dock_badge,
|
|
25
|
-
set_dock_badge_label, update_theme_mode,
|
|
25
|
+
set_dock_badge_label, set_zoom, update_theme_mode,
|
|
26
26
|
},
|
|
27
27
|
setup::{set_global_shortcut, set_system_tray},
|
|
28
28
|
window::{open_additional_window_safe, set_window, MultiWindowState},
|
|
@@ -155,7 +155,9 @@ pub fn run_app() {
|
|
|
155
155
|
StateFlags::FULLSCREEN
|
|
156
156
|
} else {
|
|
157
157
|
// Prevent flickering on the first open.
|
|
158
|
-
|
|
158
|
+
// Exclude FULLSCREEN so a prior --fullscreen build's persisted state
|
|
159
|
+
// doesn't force fullscreen on a rebuild without --fullscreen.
|
|
160
|
+
StateFlags::all() & !StateFlags::VISIBLE & !StateFlags::FULLSCREEN
|
|
159
161
|
})
|
|
160
162
|
.build();
|
|
161
163
|
|
|
@@ -192,6 +194,7 @@ pub fn run_app() {
|
|
|
192
194
|
set_dock_badge_label,
|
|
193
195
|
clear_dock_badge,
|
|
194
196
|
update_theme_mode,
|
|
197
|
+
set_zoom,
|
|
195
198
|
])
|
|
196
199
|
.setup(move |app| {
|
|
197
200
|
app.manage(MultiWindowState::new(
|
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()));
|