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 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) with friends or give it a star.
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.0";
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
- return ("window.addEventListener('DOMContentLoaded', (_event) => { " +
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
- async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) {
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
- const desktopContent = `[Desktop Entry]
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=${iconName}
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
- const validTargets = [
638
- ...LINUX_TARGET_TYPES,
639
- ...LINUX_TARGET_TYPES.map((target) => `${target}-arm64`),
640
- ];
641
- const baseTarget = options.targets.includes('-arm64')
642
- ? options.targets.replace('-arm64', '')
643
- : options.targets;
644
- if (validTargets.includes(options.targets)) {
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 ${validTargets.join(', ')}, the default 'deb' will be used.`);
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
- let trayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
713
- if (options.systemTrayIcon.length > 0) {
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
- if (this.buildArch === 'x64') {
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 && ext === '.png') ||
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.isFinite(zoom) || zoom < 50 || zoom > 200) {
2867
- throw new Error('--zoom must be a number between 50 and 200');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.13.0",
3
+ "version": "3.13.1",
4
4
  "description": "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.13.0"
2567
+ version = "3.13.1"
2568
2568
  dependencies = [
2569
2569
  "objc2",
2570
2570
  "objc2-app-kit",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.13.0"
3
+ version = "3.13.1"
4
4
  description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "GPL-3.0-or-later"
@@ -1,4 +1,7 @@
1
- use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType};
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(&params.filename);
99
+ let output_path = download_dir.join(sanitize_download_filename(&params.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, show_toast, MessageType,
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; without it, deferring to the native handler sends the
490
- // _blank target to the system browser and strands SSO callbacks.
491
- // Navigate in place so internal links stay inside the webview.
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
- e.preventDefault();
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
- const mimeType = url.substring(11, url.indexOf(";"));
1128
- filename = `image-${timestamp}.${mimeType}`;
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,
@@ -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()));
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.13.0",
4
+ "version": "3.13.1",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {