pake-cli 3.13.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -130,6 +130,22 @@
130
130
  <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Excalidraw.png width=600/></td>
131
131
  <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/XiaoHongShu.png width=600/></td>
132
132
  </tr>
133
+ <tr>
134
+ <td>Notion
135
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion.dmg">Mac</a>
136
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion_x64.msi">Windows</a>
137
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Notion_x86_64.deb">Linux</a>
138
+ </td>
139
+ <td>Flomo
140
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo.dmg">Mac</a>
141
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo_x64.msi">Windows</a>
142
+ <a href="https://github.com/tw93/Pake/releases/latest/download/Flomo_x86_64.deb">Linux</a>
143
+ </td>
144
+ </tr>
145
+ <tr>
146
+ <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Notion.png width=600/></td>
147
+ <td><img src=https://raw.githubusercontent.com/tw93/static/main/pake/Flomo.png width=600/></td>
148
+ </tr>
133
149
  </table>
134
150
 
135
151
  <details>
@@ -153,8 +169,9 @@
153
169
  | <kbd>⌘</kbd> + <kbd>⇧</kbd> + <kbd>H</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>H</kbd> | Go to Home Page |
154
170
  | <kbd>⌘</kbd> + <kbd>⌥</kbd> + <kbd>I</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>I</kbd> | Toggle Developer Tools (Debug Only) |
155
171
  | <kbd>⌘</kbd> + <kbd>⇧</kbd> + <kbd>⌫</kbd> | <kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>Del</kbd> | Clear Cache & Restart |
172
+ | <kbd>⌃</kbd> + <kbd>⌘</kbd> + <kbd>F</kbd> | <kbd>F11</kbd> | Toggle native window fullscreen |
156
173
 
157
- In addition, double-click the title bar to switch to full-screen mode. For Mac users, you can also use the gesture to go to the previous or next page and drag the title bar to move the window. The new menu also offers options for navigation, zoom, and window controls.
174
+ In addition, double-click the title bar to switch to full-screen mode. On Windows and Linux, use `--hide-window-decorations` for a frameless window with a top drag region. For Mac users, you can also use the gesture to go to the previous or next page and drag the title bar to move the window. The new menu also offers options for navigation, zoom, and window controls.
158
175
 
159
176
  </details>
160
177
 
@@ -203,8 +220,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c
203
220
  ## Support
204
221
 
205
222
  - 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.
223
+ - 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
224
  - 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
225
 
210
226
  <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.14.0";
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
@@ -519,6 +535,7 @@ function needsTemporaryDebForZst(targets) {
519
535
  function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) {
520
536
  const platformHideOnClose = options.hideOnClose ?? platform === 'darwin';
521
537
  const platformHideTitleBar = platform === 'darwin' ? options.hideTitleBar : false;
538
+ const platformHideWindowDecorations = platform !== 'darwin' ? options.hideWindowDecorations : false;
522
539
  return {
523
540
  width: options.width,
524
541
  height: options.height,
@@ -526,6 +543,7 @@ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(proc
526
543
  maximize: options.maximize,
527
544
  resizable: options.resizable ?? true,
528
545
  hide_title_bar: platformHideTitleBar,
546
+ hide_window_decorations: platformHideWindowDecorations,
529
547
  activation_shortcut: options.activationShortcut,
530
548
  always_on_top: options.alwaysOnTop,
531
549
  dark_mode: options.darkMode,
@@ -596,30 +614,31 @@ async function handleLocalFile(url, useLocalFile, tauriConf) {
596
614
  tauriConf.pake.windows[0].url_type = 'web';
597
615
  }
598
616
  }
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;
617
+ function buildLinuxDesktopContent(name, title, linuxBinaryName) {
609
618
  const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null;
610
- const desktopContent = `[Desktop Entry]
619
+ return `[Desktop Entry]
611
620
  Version=1.0
612
621
  Type=Application
613
622
  Name=${name}
614
623
  ${chineseName ? `Name[zh_CN]=${chineseName}` : ''}
615
624
  Comment=${name}
616
625
  Exec=${linuxBinaryName}
617
- Icon=${iconName}
626
+ Icon=${linuxBinaryName}
618
627
  Categories=Network;WebBrowser;Utility;
619
628
  MimeType=text/html;text/xml;application/xhtml_xml;
620
629
  StartupNotify=true
621
630
  Terminal=false
622
631
  `;
632
+ }
633
+ async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) {
634
+ const linuxBundle = tauriConf.bundle.linux;
635
+ if (!linuxBundle) {
636
+ throw new Error('Linux bundle configuration is missing from tauri.linux.conf.json; cannot build Linux target.');
637
+ }
638
+ delete linuxBundle.deb.files;
639
+ const linuxName = generateLinuxPackageName(name);
640
+ const desktopFileName = `com.pake.${linuxName}.desktop`;
641
+ const desktopContent = buildLinuxDesktopContent(name, options.title, linuxBinaryName);
623
642
  const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets');
624
643
  const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName);
625
644
  await fsExtra.ensureDir(srcAssetsDir);
@@ -634,19 +653,44 @@ Terminal=false
634
653
  linuxBundle.rpm.files = {
635
654
  [desktopInstallPath]: `assets/${desktopFileName}`,
636
655
  };
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];
656
+ // options.targets reaches here already stripped of any -arm64 suffix by the
657
+ // LinuxBuilder constructor, and may carry several comma-separated formats
658
+ // (e.g. the distro-aware default "deb,appimage"). Validate the parsed list
659
+ // rather than string-matching the whole value, so a valid multi-target
660
+ // default no longer trips the "must be one of ..." warning on every build.
661
+ const { bundleTargets, hasValidTarget } = resolveLinuxBundleTargets(options.targets);
662
+ if (hasValidTarget) {
663
+ tauriConf.bundle.targets = bundleTargets;
647
664
  }
648
665
  else {
649
- logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`);
666
+ logger.warn(`✼ The target must be one of ${LINUX_TARGET_TYPES.join(', ')}, the default 'deb' will be used.`);
667
+ }
668
+ }
669
+ async function resolveSystemTrayIconPath(systemTrayIcon, defaultTrayIconPath, safeAppName, iconOutputDir = path.join(npmDirectory, 'src-tauri/png')) {
670
+ if (systemTrayIcon.length === 0) {
671
+ return defaultTrayIconPath;
672
+ }
673
+ try {
674
+ const iconExt = path.extname(systemTrayIcon).toLowerCase();
675
+ if (iconExt !== '.png' && iconExt !== '.ico') {
676
+ logger.warn(`✼ System tray icon must be .ico or .png, but you provided ${iconExt}.`);
677
+ logger.warn(`✼ Default system tray icon will be used.`);
678
+ return defaultTrayIconPath;
679
+ }
680
+ if (!(await fsExtra.pathExists(systemTrayIcon))) {
681
+ logger.warn(`✼ System tray icon "${systemTrayIcon}" was not found.`);
682
+ logger.warn(`✼ Default system tray icon will be used.`);
683
+ return defaultTrayIconPath;
684
+ }
685
+ const trayIconPath = `png/${safeAppName}${iconExt}`;
686
+ const trayIcoPath = path.join(iconOutputDir, `${safeAppName}${iconExt}`);
687
+ await fsExtra.copy(systemTrayIcon, trayIcoPath);
688
+ return trayIconPath;
689
+ }
690
+ catch (err) {
691
+ logger.warn(`✼ Failed to apply system tray icon "${systemTrayIcon}": ${err instanceof Error ? err.message : String(err)}`);
692
+ logger.warn(`✼ Default system tray icon will remain unchanged.`);
693
+ return defaultTrayIconPath;
650
694
  }
651
695
  }
652
696
  async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
@@ -709,26 +753,8 @@ async function mergeIcons(options, name, tauriConf, platform, safeAppName) {
709
753
  tauriConf.bundle.icon = [iconInfo.defaultIcon];
710
754
  }
711
755
  // 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
- }
756
+ const defaultTrayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0];
757
+ const trayIconPath = await resolveSystemTrayIconPath(options.systemTrayIcon, defaultTrayIconPath, safeAppName);
732
758
  tauriConf.pake.system_tray_path = trayIconPath;
733
759
  delete tauriConf.app.trayIcon;
734
760
  }
@@ -803,6 +829,9 @@ async function mergeConfig(url, options, tauriConf) {
803
829
  if (options.hideTitleBar && platform !== 'darwin') {
804
830
  logger.warn('✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.');
805
831
  }
832
+ if (options.hideWindowDecorations && platform === 'darwin') {
833
+ logger.warn('✼ --hide-window-decorations is only supported on Windows and Linux and will be ignored on this platform.');
834
+ }
806
835
  const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform);
807
836
  Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions });
808
837
  tauriConf.productName = name;
@@ -1141,6 +1170,16 @@ class BaseBuilder {
1141
1170
  throw retryError;
1142
1171
  }
1143
1172
  }
1173
+ // With --no-bundle there is no installer to copy; surface the raw
1174
+ // executable the build produced instead.
1175
+ if (this.options.bundle === false) {
1176
+ await this.copyRawBinary(npmDirectory, name);
1177
+ if (logSuccess) {
1178
+ logger.success('✔ Build success!');
1179
+ logger.success('✔ Raw binary located in', path.resolve(this.getRawBinaryPath(name)));
1180
+ }
1181
+ return;
1182
+ }
1144
1183
  // Copy app
1145
1184
  const fileName = this.getFileName();
1146
1185
  const fileType = this.getFileType(target);
@@ -1508,17 +1547,11 @@ class LinuxBuilder extends BaseBuilder {
1508
1547
  arch =
1509
1548
  buildType === 'rpm' || buildType === 'appimage' ? 'aarch64' : 'arm64';
1510
1549
  }
1550
+ else if (this.buildArch === 'x64') {
1551
+ arch = buildType === 'rpm' ? 'x86_64' : 'amd64';
1552
+ }
1511
1553
  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
- }
1554
+ arch = this.buildArch;
1522
1555
  }
1523
1556
  if (this.currentBuildType === 'rpm') {
1524
1557
  return `${name}-${version}-1.${arch}`;
@@ -1526,6 +1559,11 @@ class LinuxBuilder extends BaseBuilder {
1526
1559
  return `${name}_${version}_${arch}`;
1527
1560
  }
1528
1561
  async build(url) {
1562
+ // --no-bundle: build the executable once with no per-format packaging loop.
1563
+ if (this.options.bundle === false) {
1564
+ await this.buildAndCopy(url, 'deb');
1565
+ return;
1566
+ }
1529
1567
  const targets = filterLinuxTargets(this.options.targets);
1530
1568
  if (targets.length === 0) {
1531
1569
  throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`);
@@ -1684,6 +1722,11 @@ post_remove() {
1684
1722
  ? (this.getTauriTarget(this.buildArch, 'linux') ?? undefined)
1685
1723
  : undefined;
1686
1724
  let fullCommand = this.buildBaseCommand(packageManager, configPath, buildTarget);
1725
+ // --no-bundle: build the executable only, skipping .deb/.rpm/.appimage
1726
+ // packaging entirely (e.g. RPM-based distros where the bundler aborts).
1727
+ if (this.options.bundle === false) {
1728
+ return `${fullCommand} --no-bundle`;
1729
+ }
1687
1730
  if (this.currentBuildType) {
1688
1731
  fullCommand += ` --bundles ${this.currentBuildType}`;
1689
1732
  }
@@ -2245,6 +2288,19 @@ async function convertIconFormat(inputPath, appName) {
2245
2288
  return null;
2246
2289
  }
2247
2290
  }
2291
+ async function isLinuxBundleIconReady(iconPath) {
2292
+ if (!IS_LINUX || path.extname(iconPath).toLowerCase() !== '.png') {
2293
+ return false;
2294
+ }
2295
+ try {
2296
+ const { width, height } = await sharp(iconPath).metadata();
2297
+ return (width === PLATFORM_CONFIG.linux.size &&
2298
+ height === PLATFORM_CONFIG.linux.size);
2299
+ }
2300
+ catch {
2301
+ return false;
2302
+ }
2303
+ }
2248
2304
  /**
2249
2305
  * Processes downloaded or local icon for platform-specific format
2250
2306
  */
@@ -2254,7 +2310,7 @@ async function processIcon(iconPath, appName) {
2254
2310
  // Check if already in correct platform format
2255
2311
  const ext = path.extname(iconPath).toLowerCase();
2256
2312
  const isCorrectFormat = (IS_WIN && ext === '.ico') ||
2257
- (IS_LINUX && ext === '.png') ||
2313
+ (IS_LINUX && (await isLinuxBundleIconReady(iconPath))) ||
2258
2314
  (!IS_WIN && !IS_LINUX && ext === '.icns');
2259
2315
  if (isCorrectFormat) {
2260
2316
  return await copyWindowsIconIfNeeded(iconPath, appName);
@@ -2660,6 +2716,11 @@ async function handleOptions(options, url) {
2660
2716
  if (!options.internalUrlRegex && options.safeDomain) {
2661
2717
  appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain);
2662
2718
  }
2719
+ // --no-bundle is Linux-only; keep normal packaging on other platforms.
2720
+ if (appOptions.bundle === false && platform !== 'linux') {
2721
+ logger.warn('✼ --no-bundle is only supported on Linux; ignoring it.');
2722
+ appOptions.bundle = true;
2723
+ }
2663
2724
  const iconPath = await handleIcon(appOptions, url);
2664
2725
  appOptions.icon = iconPath || '';
2665
2726
  return appOptions;
@@ -2672,6 +2733,7 @@ const DEFAULT_PAKE_OPTIONS = {
2672
2733
  fullscreen: false,
2673
2734
  maximize: false,
2674
2735
  hideTitleBar: false,
2736
+ hideWindowDecorations: false,
2675
2737
  alwaysOnTop: false,
2676
2738
  appVersion: '1.0.0',
2677
2739
  darkMode: false,
@@ -2702,6 +2764,7 @@ const DEFAULT_PAKE_OPTIONS = {
2702
2764
  incognito: false,
2703
2765
  wasm: false,
2704
2766
  enableDragDrop: false,
2767
+ bundle: true,
2705
2768
  keepBinary: false,
2706
2769
  multiInstance: false,
2707
2770
  multiWindow: false,
@@ -2772,6 +2835,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2772
2835
  .option('--use-local-file', 'Use local file packaging', DEFAULT_PAKE_OPTIONS.useLocalFile)
2773
2836
  .option('--fullscreen', 'Start in full screen', DEFAULT_PAKE_OPTIONS.fullscreen)
2774
2837
  .option('--hide-title-bar', 'For Mac, hide title bar', DEFAULT_PAKE_OPTIONS.hideTitleBar)
2838
+ .option('--hide-window-decorations', 'Hide native window decorations on Windows and Linux', DEFAULT_PAKE_OPTIONS.hideWindowDecorations)
2775
2839
  .option('--multi-arch', 'For Mac, both Intel and M1', DEFAULT_PAKE_OPTIONS.multiArch)
2776
2840
  .option('--inject <files>', 'Inject local CSS/JS files into the page', (val, previous) => {
2777
2841
  if (!val)
@@ -2841,6 +2905,9 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2841
2905
  .addOption(new Option('--keep-binary', 'Keep raw binary file alongside installer')
2842
2906
  .default(DEFAULT_PAKE_OPTIONS.keepBinary)
2843
2907
  .hideHelp())
2908
+ .addOption(new Option('--no-bundle', 'Skip packaging, output only the raw executable (Linux; for RPM distros where the bundler aborts)')
2909
+ .default(DEFAULT_PAKE_OPTIONS.bundle)
2910
+ .hideHelp())
2844
2911
  .addOption(new Option('--multi-instance', 'Allow multiple app instances')
2845
2912
  .default(DEFAULT_PAKE_OPTIONS.multiInstance)
2846
2913
  .hideHelp())
@@ -2863,8 +2930,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
2863
2930
  .default(DEFAULT_PAKE_OPTIONS.zoom)
2864
2931
  .argParser((value) => {
2865
2932
  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');
2933
+ if (!Number.isInteger(zoom) || zoom < 50 || zoom > 200) {
2934
+ throw new Error('--zoom must be an integer between 50 and 200');
2868
2935
  }
2869
2936
  return zoom;
2870
2937
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.13.0",
3
+ "version": "3.14.0",
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.14.0"
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.14.0"
4
4
  description = "🤱🏻 Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "GPL-3.0-or-later"
Binary file
Binary file
@@ -4,6 +4,7 @@
4
4
  "url": "https://weekly.tw93.fun/en",
5
5
  "url_type": "web",
6
6
  "hide_title_bar": true,
7
+ "hide_window_decorations": false,
7
8
  "fullscreen": false,
8
9
  "width": 1200,
9
10
  "height": 780,
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,6 +4,8 @@ use serde::{Deserialize, Serialize};
4
4
  pub struct WindowConfig {
5
5
  pub url: String,
6
6
  pub hide_title_bar: bool,
7
+ #[serde(default)]
8
+ pub hide_window_decorations: bool,
7
9
  pub fullscreen: bool,
8
10
  pub maximize: bool,
9
11
  pub width: f64,
@@ -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"))
@@ -371,6 +380,10 @@ fn build_window(
371
380
  {
372
381
  window_builder = window_builder.data_directory(_data_dir).theme(theme);
373
382
 
383
+ if window_config.hide_window_decorations {
384
+ window_builder = window_builder.decorations(false);
385
+ }
386
+
374
387
  if !config.proxy_url.is_empty() {
375
388
  if let Ok(proxy_url) = Url::from_str(&config.proxy_url) {
376
389
  parsed_proxy_url = Some(proxy_url.clone());
@@ -456,7 +469,7 @@ fn build_window(
456
469
  })
457
470
  .unwrap_or_else(|| "download".to_string());
458
471
 
459
- let target = download_dir.join(filename);
472
+ let target = download_dir.join(sanitize_download_filename(&filename));
460
473
  if let Some(path_str) = target.to_str() {
461
474
  *destination = PathBuf::from(check_file_or_append(path_str));
462
475
  }
@@ -15,25 +15,32 @@ function setZoom(zoom) {
15
15
  // CSS hacks. `transform: scale` and `html.style.zoom` break complex SPAs like
16
16
  // ChatGPT: the page shifts right on Windows and parts of the UI stop repainting
17
17
  // on macOS. Native zoom recalculates layout exactly like a browser does.
18
+ const zoomPercent = normalizeZoomPercent(zoom);
19
+ const normalizedZoom = `${zoomPercent}%`;
18
20
  const invoke = window.__TAURI__?.core?.invoke;
19
21
  if (invoke) {
20
- invoke("set_zoom", { percent: parseFloat(zoom) }).catch(() => {});
22
+ invoke("set_zoom", { percent: zoomPercent }).catch(() => {});
21
23
  }
22
24
 
23
- window.localStorage.setItem("htmlZoom", zoom);
25
+ window.localStorage.setItem("htmlZoom", normalizedZoom);
24
26
  }
25
27
 
26
28
  function zoomCommon(zoomChange) {
27
29
  const currentZoom = window.localStorage.getItem("htmlZoom") || "100%";
28
- setZoom(zoomChange(currentZoom));
30
+ setZoom(zoomChange(normalizeZoomPercent(currentZoom)));
29
31
  }
30
32
 
31
33
  function zoomIn() {
32
- zoomCommon((currentZoom) => `${Math.min(parseInt(currentZoom) + 10, 200)}%`);
34
+ zoomCommon((currentZoom) => `${Math.min(currentZoom + 10, 200)}%`);
33
35
  }
34
36
 
35
37
  function zoomOut() {
36
- zoomCommon((currentZoom) => `${Math.max(parseInt(currentZoom) - 10, 30)}%`);
38
+ zoomCommon((currentZoom) => `${Math.max(currentZoom - 10, 30)}%`);
39
+ }
40
+
41
+ function normalizeZoomPercent(zoom) {
42
+ const parsed = parseFloat(zoom);
43
+ return Number.isFinite(parsed) ? parsed : 100;
37
44
  }
38
45
 
39
46
  let pasteAsPlainTextPending = false;
@@ -53,6 +60,220 @@ function handleShortcut(event) {
53
60
  }
54
61
  }
55
62
 
63
+ function toggleNativeFullscreen(appWindow) {
64
+ appWindow
65
+ .isFullscreen()
66
+ .then((fullscreen) => appWindow.setFullscreen(!fullscreen))
67
+ .catch((error) => {
68
+ console.warn("[Pake] Failed to toggle native fullscreen:", error);
69
+ });
70
+ }
71
+
72
+ function handleWindowFullscreenShortcut(event) {
73
+ if (
74
+ !event.isTrusted ||
75
+ event.repeat ||
76
+ event.key !== "F11" ||
77
+ !isNonMacDesktop()
78
+ ) {
79
+ return;
80
+ }
81
+
82
+ const appWindow = window.__TAURI__?.window?.getCurrentWindow?.();
83
+ if (!appWindow) {
84
+ return;
85
+ }
86
+
87
+ event.preventDefault();
88
+ event.stopImmediatePropagation();
89
+ toggleNativeFullscreen(appWindow);
90
+ }
91
+
92
+ function getDesktopPlatform() {
93
+ return (
94
+ navigator.userAgentData?.platform ||
95
+ navigator.platform ||
96
+ navigator.userAgent
97
+ );
98
+ }
99
+
100
+ function isNonMacDesktop() {
101
+ return /win|linux/i.test(getDesktopPlatform());
102
+ }
103
+
104
+ function hasImmersiveHeader(config = window["pakeConfig"] || {}) {
105
+ return /mac/i.test(getDesktopPlatform())
106
+ ? config.hide_title_bar === true
107
+ : config.hide_window_decorations === true;
108
+ }
109
+
110
+ function isEditableElement(element) {
111
+ if (!element) return false;
112
+
113
+ const tagName = element.tagName;
114
+ return (
115
+ tagName === "INPUT" || tagName === "TEXTAREA" || element.isContentEditable
116
+ );
117
+ }
118
+
119
+ function hasSelectedText() {
120
+ return Boolean(window.getSelection?.()?.toString());
121
+ }
122
+
123
+ const NON_TEXT_INPUT_TYPES = new Set([
124
+ "button",
125
+ "checkbox",
126
+ "color",
127
+ "file",
128
+ "hidden",
129
+ "image",
130
+ "radio",
131
+ "range",
132
+ "reset",
133
+ "submit",
134
+ ]);
135
+
136
+ function isTextInputElement(element) {
137
+ return (
138
+ element?.tagName === "INPUT" &&
139
+ !NON_TEXT_INPUT_TYPES.has((element.type || "text").toLowerCase())
140
+ );
141
+ }
142
+
143
+ function selectEditableElement(element) {
144
+ if (typeof element.select === "function") {
145
+ element.select();
146
+ return true;
147
+ }
148
+
149
+ if (element.isContentEditable) {
150
+ const range = document.createRange();
151
+ range.selectNodeContents(element);
152
+ const selection = window.getSelection?.();
153
+ if (!selection) return false;
154
+
155
+ selection.removeAllRanges();
156
+ selection.addRange(range);
157
+ return true;
158
+ }
159
+
160
+ return false;
161
+ }
162
+
163
+ function canPasteIntoEditableElement(element) {
164
+ if (!isEditableElement(element)) return false;
165
+
166
+ if (element.tagName === "INPUT") {
167
+ return (
168
+ isTextInputElement(element) &&
169
+ element.disabled !== true &&
170
+ element.readOnly !== true
171
+ );
172
+ }
173
+
174
+ if (element.tagName === "TEXTAREA") {
175
+ return element.disabled !== true && element.readOnly !== true;
176
+ }
177
+
178
+ return true;
179
+ }
180
+
181
+ function insertTextIntoEditableElement(element, text) {
182
+ if (!text) return false;
183
+
184
+ if (document.execCommand("insertText", false, text)) {
185
+ return true;
186
+ }
187
+
188
+ if (
189
+ element &&
190
+ (isTextInputElement(element) || element.tagName === "TEXTAREA") &&
191
+ typeof element.setRangeText === "function"
192
+ ) {
193
+ const valueLength =
194
+ typeof element.value === "string" ? element.value.length : 0;
195
+ const start =
196
+ typeof element.selectionStart === "number"
197
+ ? element.selectionStart
198
+ : valueLength;
199
+ const end =
200
+ typeof element.selectionEnd === "number" ? element.selectionEnd : start;
201
+ element.setRangeText(text, start, end, "end");
202
+ element.dispatchEvent?.(new Event("input", { bubbles: true }));
203
+ return true;
204
+ }
205
+
206
+ return false;
207
+ }
208
+
209
+ function runBrowserPasteCommand() {
210
+ try {
211
+ return document.execCommand("paste") === true;
212
+ } catch (error) {
213
+ return false;
214
+ }
215
+ }
216
+
217
+ function pasteClipboardText(activeElement) {
218
+ if (runBrowserPasteCommand()) {
219
+ return;
220
+ }
221
+
222
+ const readText = navigator.clipboard?.readText;
223
+ if (typeof readText !== "function") {
224
+ return;
225
+ }
226
+
227
+ readText
228
+ .call(navigator.clipboard)
229
+ .then((text) => {
230
+ insertTextIntoEditableElement(activeElement, text);
231
+ })
232
+ .catch(() => {});
233
+ }
234
+
235
+ function handleClipboardShortcut(event) {
236
+ if (
237
+ event.isTrusted !== true ||
238
+ !isNonMacDesktop() ||
239
+ !event.ctrlKey ||
240
+ event.metaKey ||
241
+ event.altKey ||
242
+ event.shiftKey
243
+ ) {
244
+ return false;
245
+ }
246
+
247
+ const key = event.key?.toLowerCase();
248
+ const activeElement = document.activeElement;
249
+ const isEditable = isEditableElement(activeElement);
250
+
251
+ if (key === "c" && (isEditable || hasSelectedText())) {
252
+ document.execCommand("copy");
253
+ event.preventDefault();
254
+ return true;
255
+ }
256
+
257
+ if (key === "x" && isEditable) {
258
+ document.execCommand("cut");
259
+ event.preventDefault();
260
+ return true;
261
+ }
262
+
263
+ if (key === "v" && canPasteIntoEditableElement(activeElement)) {
264
+ event.preventDefault();
265
+ pasteClipboardText(activeElement);
266
+ return true;
267
+ }
268
+
269
+ if (key === "a" && isEditable && selectEditableElement(activeElement)) {
270
+ event.preventDefault();
271
+ return true;
272
+ }
273
+
274
+ return false;
275
+ }
276
+
56
277
  const DOWNLOADABLE_FILE_EXTENSIONS = {
57
278
  documents: [
58
279
  "pdf",
@@ -275,12 +496,34 @@ function canNavigateAuthUrl(url) {
275
496
  return normalizedUrl !== "" && normalizedUrl !== "about:blank";
276
497
  }
277
498
 
499
+ function isAppleAuthPopup(url, name) {
500
+ if (name === "AppleAuthentication") {
501
+ return true;
502
+ }
503
+
504
+ try {
505
+ return (
506
+ new URL(url, window.location.href).hostname.toLowerCase() ===
507
+ "appleid.apple.com"
508
+ );
509
+ } catch (error) {
510
+ return false;
511
+ }
512
+ }
513
+
278
514
  function navigateInCurrentWindow(url) {
279
515
  window.location.href = url;
280
516
  return window;
281
517
  }
282
518
 
283
519
  function openAuthNavigation(originalWindowOpen, url, name, specs) {
520
+ if (isAppleAuthPopup(url, name)) {
521
+ const authWindow = originalWindowOpen.call(window, url, name, specs);
522
+ if (authWindow) {
523
+ return authWindow;
524
+ }
525
+ }
526
+
284
527
  if (shouldNavigateAuthInCurrentWindow() && canNavigateAuthUrl(url)) {
285
528
  return navigateInCurrentWindow(url);
286
529
  }
@@ -309,7 +552,7 @@ document.addEventListener("DOMContentLoaded", () => {
309
552
  }
310
553
  }
311
554
 
312
- if (!document.getElementById("pake-top-dom")) {
555
+ if (!document.getElementById("pake-top-dom") && hasImmersiveHeader()) {
313
556
  const topDom = document.createElement("div");
314
557
  topDom.id = "pake-top-dom";
315
558
  document.body.appendChild(topDom);
@@ -317,24 +560,25 @@ document.addEventListener("DOMContentLoaded", () => {
317
560
 
318
561
  const domEl = document.getElementById("pake-top-dom");
319
562
 
320
- domEl.addEventListener("touchstart", () => {
321
- appWindow.startDragging();
322
- });
323
-
324
- domEl.addEventListener("mousedown", (e) => {
325
- e.preventDefault();
326
- if (e.buttons === 1 && e.detail !== 2) {
563
+ if (domEl) {
564
+ domEl.addEventListener("touchstart", () => {
327
565
  appWindow.startDragging();
328
- }
329
- });
566
+ });
330
567
 
331
- domEl.addEventListener("dblclick", () => {
332
- appWindow.isFullscreen().then((fullscreen) => {
333
- appWindow.setFullscreen(!fullscreen);
568
+ domEl.addEventListener("mousedown", (e) => {
569
+ e.preventDefault();
570
+ if (e.buttons === 1 && e.detail !== 2) {
571
+ appWindow.startDragging();
572
+ }
334
573
  });
335
- });
574
+
575
+ domEl.addEventListener("dblclick", () => {
576
+ toggleNativeFullscreen(appWindow);
577
+ });
578
+ }
336
579
 
337
580
  if (window["pakeConfig"]?.disabled_web_shortcuts !== true) {
581
+ document.addEventListener("keydown", handleWindowFullscreenShortcut, true);
338
582
  document.addEventListener("keyup", (event) => {
339
583
  if (/windows|linux/i.test(navigator.userAgent) && event.ctrlKey) {
340
584
  handleShortcut(event);
@@ -345,6 +589,8 @@ document.addEventListener("DOMContentLoaded", () => {
345
589
  });
346
590
  }
347
591
 
592
+ document.addEventListener("keydown", handleClipboardShortcut, true);
593
+
348
594
  document.addEventListener(
349
595
  "paste",
350
596
  (event) => {
@@ -486,13 +732,19 @@ document.addEventListener("DOMContentLoaded", () => {
486
732
 
487
733
  if (isInternalUrl(absoluteUrl)) {
488
734
  // 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.
735
+ // window. Without it, leaving target="_blank" untouched lets the
736
+ // native webview escalate the click to a system-browser "new window".
737
+ //
738
+ // Many SPAs (e.g. Plane) tag in-app links with target="_blank" but
739
+ // route the click themselves via a React onClick that calls
740
+ // preventDefault + client-side navigation. Forcing a full
741
+ // window.location reload here (and stopping propagation) would defeat
742
+ // that handler and reload the whole app on every click. Instead,
743
+ // retarget the link to "_self" so the webview never opens a browser
744
+ // window, then let the page's own handler run. If nothing intercepts
745
+ // the click, the default _self navigation keeps it inside the app.
492
746
  if (!window.pakeConfig?.new_window) {
493
- e.preventDefault();
494
- e.stopImmediatePropagation();
495
- window.location.href = absoluteUrl;
747
+ anchorElement.target = "_self";
496
748
  }
497
749
  return;
498
750
  }
@@ -1124,8 +1376,17 @@ function getFilenameFromUrl(url) {
1124
1376
 
1125
1377
  // Detect image type from URL or data URI
1126
1378
  if (url.startsWith("data:image/")) {
1127
- const mimeType = url.substring(11, url.indexOf(";"));
1128
- filename = `image-${timestamp}.${mimeType}`;
1379
+ // Read only the MIME subtype: stop at ';' (params) or ',' (data),
1380
+ // whichever comes first, so we never fold the encoding/payload into
1381
+ // the extension. Map structured suffixes (svg+xml -> svg) and jpeg.
1382
+ const semicolon = url.indexOf(";");
1383
+ const comma = url.indexOf(",");
1384
+ let end = url.length;
1385
+ if (semicolon !== -1) end = Math.min(end, semicolon);
1386
+ if (comma !== -1) end = Math.min(end, comma);
1387
+ let ext = url.substring(11, end).split("+")[0];
1388
+ if (ext === "jpeg") ext = "jpg";
1389
+ filename = `image-${timestamp}.${ext}`;
1129
1390
  } else {
1130
1391
  // Default to common image extensions based on common patterns
1131
1392
  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,
@@ -403,6 +403,11 @@ window.addEventListener("DOMContentLoaded", (_event) => {
403
403
  padding-top: 0px;
404
404
  }
405
405
 
406
+ #notion-app .notion-sidebar,#notion-app .notion-topbar{
407
+ padding-top: 20px;
408
+ box-sizing: content-box;
409
+ }
410
+
406
411
  #header-area > div > .css-gtiexd > div:nth-child(1) > div, #header-area .logoIcon .user-info{
407
412
  padding-top: 20px;
408
413
  }
@@ -497,9 +502,29 @@ window.addEventListener("DOMContentLoaded", (_event) => {
497
502
  }
498
503
  `;
499
504
  const isMac = /Mac/i.test(navigator.userAgent);
500
- if (window["pakeConfig"]?.hide_title_bar && isMac) {
505
+ if (hasImmersiveHeader(window["pakeConfig"])) {
501
506
  const topPaddingStyleElement = document.createElement("style");
502
- topPaddingStyleElement.textContent = topPaddingCSS;
507
+ topPaddingStyleElement.textContent = isMac
508
+ ? topPaddingCSS
509
+ : `
510
+ #pake-top-dom:active {
511
+ cursor: grabbing;
512
+ cursor: -webkit-grabbing;
513
+ }
514
+
515
+ #pake-top-dom {
516
+ position: fixed;
517
+ background: transparent;
518
+ top: 0;
519
+ width: 100%;
520
+ height: 20px;
521
+ cursor: grab;
522
+ -webkit-app-region: drag;
523
+ user-select: none;
524
+ -webkit-user-select: none;
525
+ z-index: 99999;
526
+ }
527
+ `;
503
528
  document.head.appendChild(topPaddingStyleElement);
504
529
  }
505
530
  });
@@ -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.14.0",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {