pake-cli 3.11.6 โ†’ 3.11.8

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/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander';
20
20
  import fs$1 from 'fs';
21
21
 
22
22
  var name = "pake-cli";
23
- var version = "3.11.6";
23
+ var version = "3.11.8";
24
24
  var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚";
25
25
  var engines = {
26
26
  node: ">=18.0.0"
@@ -31,7 +31,7 @@ var bin = {
31
31
  };
32
32
  var repository = {
33
33
  type: "git",
34
- url: "git+https://github.com/tw93/pake.git"
34
+ url: "git+https://github.com/tw93/Pake.git"
35
35
  };
36
36
  var author = {
37
37
  name: "Tw93",
@@ -62,6 +62,7 @@ var scripts = {
62
62
  test: "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js",
63
63
  format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose",
64
64
  "format:check": "prettier --check . --ignore-unknown",
65
+ "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts",
65
66
  update: "pnpm update --verbose && cd src-tauri && cargo update",
66
67
  prepublishOnly: "pnpm run cli:build"
67
68
  };
@@ -247,38 +248,10 @@ async function shellExec(command, timeout = 300000, env) {
247
248
  if (error.timedOut) {
248
249
  throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`);
249
250
  }
250
- let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`;
251
- // Provide helpful guidance for common Linux AppImage build failures
252
- // caused by strip tool incompatibility with modern glibc (2.38+)
253
- const lowerError = errorMessage.toLowerCase();
254
- if (process.platform === 'linux' &&
255
- (lowerError.includes('linuxdeploy') ||
256
- lowerError.includes('appimage') ||
257
- lowerError.includes('strip'))) {
258
- errorMsg +=
259
- '\n\nโ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n' +
260
- 'Linux AppImage Build Failed\n' +
261
- 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”\n\n' +
262
- 'Cause: Strip tool incompatibility with glibc 2.38+\n' +
263
- ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n' +
264
- 'Quick fix:\n' +
265
- ' NO_STRIP=1 pake <url> --targets appimage --debug\n\n' +
266
- 'Alternatives:\n' +
267
- ' โ€ข Use DEB format: pake <url> --targets deb\n' +
268
- ' โ€ข Update binutils: sudo apt install binutils (or pacman -S binutils)\n' +
269
- ' โ€ข Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' +
270
- 'โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”';
271
- if (lowerError.includes('fuse') ||
272
- lowerError.includes('operation not permitted') ||
273
- lowerError.includes('/dev/fuse')) {
274
- errorMsg +=
275
- '\n\nDocker / Container hint:\n' +
276
- ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' +
277
- ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' +
278
- ' or run on the host directly.';
279
- }
280
- }
281
- throw new Error(errorMsg);
251
+ // AppImage/linuxdeploy guidance is added by the caller (BaseBuilder), which
252
+ // knows the build target. We only have the command line here (the tool's
253
+ // diagnostics stream to the terminal via stdio:inherit, not into the error).
254
+ throw new Error(`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`);
282
255
  }
283
256
  }
284
257
 
@@ -806,6 +779,24 @@ function getBuildTimeout() {
806
779
  return 900000;
807
780
  }
808
781
  let packageManagerCache = null;
782
+ function parseMajorVersion(version) {
783
+ const match = version.match(/^v?(\d+)/);
784
+ return match ? Number(match[1]) : null;
785
+ }
786
+ function getPinnedPnpmMajorVersion() {
787
+ const packageManager = packageJson.packageManager;
788
+ const match = packageManager?.match(/^pnpm@(\d+)/);
789
+ return match ? Number(match[1]) : null;
790
+ }
791
+ async function detectNpm(execa) {
792
+ try {
793
+ await execa('npm', ['--version'], { stdio: 'ignore' });
794
+ return true;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
809
800
  /**
810
801
  * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found.
811
802
  * Cached after the first successful detection so tests can call repeatedly.
@@ -815,23 +806,37 @@ async function detectPackageManager() {
815
806
  return packageManagerCache;
816
807
  }
817
808
  const { execa } = await import('execa');
809
+ let pnpmVersion;
818
810
  try {
819
- await execa('pnpm', ['--version'], { stdio: 'ignore' });
820
- logger.info('โœบ Using pnpm for package management.');
821
- packageManagerCache = 'pnpm';
822
- return 'pnpm';
811
+ const { stdout } = await execa('pnpm', ['--version']);
812
+ pnpmVersion = stdout.trim();
823
813
  }
824
814
  catch {
825
- try {
826
- await execa('npm', ['--version'], { stdio: 'ignore' });
815
+ if (await detectNpm(execa)) {
827
816
  logger.info('โœบ pnpm not available, using npm for package management.');
828
817
  packageManagerCache = 'npm';
829
818
  return 'npm';
830
819
  }
831
- catch {
832
- throw new Error('Neither pnpm nor npm is available. Please install a package manager.');
820
+ throw new Error('Neither pnpm nor npm is available. Please install a package manager.');
821
+ }
822
+ const normalizedPnpmVersion = pnpmVersion.startsWith('v')
823
+ ? pnpmVersion
824
+ : `v${pnpmVersion}`;
825
+ const pnpmMajor = parseMajorVersion(pnpmVersion);
826
+ const pinnedPnpmMajor = getPinnedPnpmMajorVersion();
827
+ if (pnpmMajor !== null &&
828
+ pinnedPnpmMajor !== null &&
829
+ pnpmMajor !== pinnedPnpmMajor) {
830
+ if (!(await detectNpm(execa))) {
831
+ throw new Error(`Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}. Install npm so Pake can fall back, or use pnpm ${pinnedPnpmMajor}.x to match the project pin.`);
833
832
  }
833
+ logger.warn(`โœผ Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}; using npm for package management instead.`);
834
+ packageManagerCache = 'npm';
835
+ return 'npm';
834
836
  }
837
+ logger.info('โœบ Using pnpm for package management.');
838
+ packageManagerCache = 'pnpm';
839
+ return 'pnpm';
835
840
  }
836
841
  function getInstallCommand(packageManager, useCnMirror) {
837
842
  const registryOption = useCnMirror
@@ -887,23 +892,29 @@ async function configureCargoRegistry(tauriSrcPath, useCnMirror) {
887
892
  logger.warn(`โœผ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`);
888
893
  }
889
894
  }
890
- /**
891
- * Returns true when an error string looks like the well-known Tauri+linuxdeploy
892
- * strip failure that we automatically retry with NO_STRIP=1.
893
- */
894
- function isLinuxDeployStripError(error) {
895
- if (!(error instanceof Error) || !error.message) {
896
- return false;
897
- }
898
- const message = error.message.toLowerCase();
899
- return (message.includes('linuxdeploy') ||
900
- message.includes('failed to run linuxdeploy') ||
901
- message.includes('strip:') ||
902
- message.includes('unable to recognise the format of the input file') ||
903
- message.includes('appimage tool failed') ||
904
- message.includes('strip tool'));
905
- }
906
895
 
896
+ // Appended to the error when a Linux AppImage build fails for good. linuxdeploy's
897
+ // diagnostics stream to the terminal (stdio: 'inherit') and never reach
898
+ // error.message, so we cannot name the exact cause. We only reach here after
899
+ // NO_STRIP=1 has been applied and still failed, so strip is shown as ruled out.
900
+ const APPIMAGE_BAR = 'โ”'.repeat(56);
901
+ const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` +
902
+ 'Linux AppImage Build Failed\n' +
903
+ `${APPIMAGE_BAR}\n\n` +
904
+ 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' +
905
+ ' โ€ข Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' +
906
+ ' the build still failed, so strip is likely not the cause.\n' +
907
+ ' โ€ข Missing gdk-pixbuf loaders (e.g. "cannot stat\n' +
908
+ " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" +
909
+ ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' +
910
+ ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' +
911
+ ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' +
912
+ ' then: gdk-pixbuf-query-loaders --update-cache\n' +
913
+ ' โ€ข Running in Docker/container: AppImage needs /dev/fuse:\n' +
914
+ ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' +
915
+ 'Still stuck? Build a DEB instead: pake <url> --targets deb\n' +
916
+ 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' +
917
+ APPIMAGE_BAR;
907
918
  class BaseBuilder {
908
919
  constructor(options) {
909
920
  this.options = options;
@@ -995,14 +1006,11 @@ class BaseBuilder {
995
1006
  ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}),
996
1007
  };
997
1008
  const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined;
998
- // Warn users about potential AppImage build failures on modern Linux systems.
999
- // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't
1000
- // recognize the .relr.dyn section introduced in glibc 2.38+.
1001
- if (process.platform === 'linux' && target === 'appimage') {
1002
- if (!buildEnv.NO_STRIP) {
1003
- logger.warn('โš  Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+');
1004
- logger.warn('โš  If build fails, retry with: NO_STRIP=1 pake <url> --targets appimage');
1005
- }
1009
+ const isLinuxAppImage = process.platform === 'linux' && target === 'appimage';
1010
+ // AppImage builds can fail at the linuxdeploy strip step on glibc 2.38+.
1011
+ // A real failure now prints full guidance, so only hint in debug mode.
1012
+ if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) {
1013
+ logger.warn('โš  AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.');
1006
1014
  }
1007
1015
  const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`;
1008
1016
  const buildTimeout = getBuildTimeout();
@@ -1010,21 +1018,26 @@ class BaseBuilder {
1010
1018
  await shellExec(buildCommand, buildTimeout, resolveExecEnv());
1011
1019
  }
1012
1020
  catch (error) {
1013
- const shouldRetryWithoutStrip = process.platform === 'linux' &&
1014
- target === 'appimage' &&
1015
- !buildEnv.NO_STRIP &&
1016
- isLinuxDeployStripError(error);
1017
- if (shouldRetryWithoutStrip) {
1018
- logger.warn('โš  AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.');
1019
- buildEnv = {
1020
- ...buildEnv,
1021
- NO_STRIP: '1',
1022
- };
1023
- await shellExec(buildCommand, buildTimeout, resolveExecEnv());
1021
+ if (!isLinuxAppImage) {
1022
+ throw error;
1024
1023
  }
1025
- else {
1024
+ // linuxdeploy's diagnostics stream to the terminal (stdio: 'inherit') and
1025
+ // never reach error.message, so we cannot classify the cause. strip is the
1026
+ // most common AppImage failure, so retry once with NO_STRIP=1; if that
1027
+ // (or an already-NO_STRIP run) still fails, surface all known causes.
1028
+ if (buildEnv.NO_STRIP) {
1029
+ error.message += APPIMAGE_FAILURE_GUIDANCE;
1026
1030
  throw error;
1027
1031
  }
1032
+ logger.warn('โš  AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).');
1033
+ buildEnv = { ...buildEnv, NO_STRIP: '1' };
1034
+ try {
1035
+ await shellExec(buildCommand, buildTimeout, resolveExecEnv());
1036
+ }
1037
+ catch (retryError) {
1038
+ retryError.message += APPIMAGE_FAILURE_GUIDANCE;
1039
+ throw retryError;
1040
+ }
1028
1041
  }
1029
1042
  // Copy app
1030
1043
  const fileName = this.getFileName();
@@ -2308,8 +2321,8 @@ function resolveLocalAppName(filePath, platform) {
2308
2321
  return generateLinuxPackageName(baseName) || 'pake-app';
2309
2322
  }
2310
2323
  const normalized = baseName
2311
- .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '')
2312
- .replace(/^[ -]+/, '')
2324
+ .replace(/[^a-zA-Z0-9\u4e00-\u9fff .-]/g, '')
2325
+ .replace(/^[ .-]+/, '')
2313
2326
  .replace(/\s+/g, ' ')
2314
2327
  .trim();
2315
2328
  return normalized || 'pake-app';
@@ -2317,7 +2330,7 @@ function resolveLocalAppName(filePath, platform) {
2317
2330
  function isValidName(name, platform) {
2318
2331
  const reg = platform === 'linux'
2319
2332
  ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/
2320
- : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/;
2333
+ : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff .-]*$/;
2321
2334
  return !!name && reg.test(name);
2322
2335
  }
2323
2336
  async function handleOptions(options, url) {
@@ -2338,7 +2351,7 @@ async function handleOptions(options, url) {
2338
2351
  }
2339
2352
  if (name && !isValidName(name, platform)) {
2340
2353
  const LINUX_NAME_ERROR = `โœ• Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`;
2341
- const DEFAULT_NAME_ERROR = `โœ• Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`;
2354
+ const DEFAULT_NAME_ERROR = `โœ• Name should only include letters, numbers, dots, dashes, and spaces (not leading dots, dashes, and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, Vectorizer.AI, 123.`;
2342
2355
  const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR;
2343
2356
  if (isActions) {
2344
2357
  logger.error(errorMsg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.11.6",
3
+ "version": "3.11.8",
4
4
  "description": "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
@@ -11,7 +11,7 @@
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "git+https://github.com/tw93/pake.git"
14
+ "url": "git+https://github.com/tw93/Pake.git"
15
15
  },
16
16
  "author": {
17
17
  "name": "Tw93",
@@ -42,6 +42,7 @@
42
42
  "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js",
43
43
  "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose",
44
44
  "format:check": "prettier --check . --ignore-unknown",
45
+ "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts",
45
46
  "update": "pnpm update --verbose && cd src-tauri && cargo update",
46
47
  "prepublishOnly": "pnpm run cli:build"
47
48
  },
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.11.6"
2567
+ version = "3.11.8"
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.11.6"
3
+ version = "3.11.8"
4
4
  description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "MIT"
@@ -1,5 +1,5 @@
1
1
  use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType};
2
- use std::fs::{self, File};
2
+ use std::fs::File;
3
3
  use std::io::Write;
4
4
  use std::str::FromStr;
5
5
  use std::sync::atomic::{AtomicI64, Ordering};
@@ -73,13 +73,6 @@ pub struct DownloadFileParams {
73
73
  language: Option<String>,
74
74
  }
75
75
 
76
- #[derive(serde::Deserialize)]
77
- pub struct BinaryDownloadParams {
78
- filename: String,
79
- binary: Vec<u8>,
80
- language: Option<String>,
81
- }
82
-
83
76
  #[derive(serde::Deserialize)]
84
77
  pub struct NotificationParams {
85
78
  title: String,
@@ -147,47 +140,6 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result
147
140
  }
148
141
  }
149
142
 
150
- #[command]
151
- pub async fn download_file_by_binary(
152
- app: AppHandle,
153
- params: BinaryDownloadParams,
154
- ) -> Result<(), String> {
155
- let window: WebviewWindow = app.get_webview_window("pake").ok_or("Window not found")?;
156
-
157
- show_toast(
158
- &window,
159
- &get_download_message_with_lang(MessageType::Start, params.language.clone()),
160
- );
161
-
162
- let download_dir = app
163
- .path()
164
- .download_dir()
165
- .map_err(|e| format!("Failed to get download dir: {}", e))?;
166
-
167
- let output_path = download_dir.join(&params.filename);
168
-
169
- let path_str = output_path.to_str().ok_or("Invalid output path")?;
170
-
171
- let file_path = check_file_or_append(path_str);
172
-
173
- match fs::write(file_path, &params.binary) {
174
- Ok(_) => {
175
- show_toast(
176
- &window,
177
- &get_download_message_with_lang(MessageType::Success, params.language.clone()),
178
- );
179
- Ok(())
180
- }
181
- Err(e) => {
182
- show_toast(
183
- &window,
184
- &get_download_message_with_lang(MessageType::Failure, params.language),
185
- );
186
- Err(e.to_string())
187
- }
188
- }
189
- }
190
-
191
143
  #[command]
192
144
  pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<(), String> {
193
145
  use tauri_plugin_notification::NotificationExt;
@@ -1,12 +1,14 @@
1
1
  use crate::app::config::PakeConfig;
2
- use crate::util::get_data_dir;
2
+ use crate::util::{
3
+ check_file_or_append, get_data_dir, get_download_message_with_lang, show_toast, MessageType,
4
+ };
3
5
  use std::{
4
6
  path::PathBuf,
5
7
  str::FromStr,
6
8
  sync::atomic::{AtomicU32, Ordering},
7
9
  };
8
10
  use tauri::{
9
- webview::{NewWindowFeatures, NewWindowResponse},
11
+ webview::{DownloadEvent, NewWindowFeatures, NewWindowResponse},
10
12
  AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder,
11
13
  };
12
14
 
@@ -426,6 +428,61 @@ fn build_window(
426
428
  }
427
429
  }
428
430
 
431
+ // Capture webview-initiated downloads (blob:, data:, Content-Disposition,
432
+ // etc.) and write them to the OS Downloads folder. This is essential for
433
+ // sites with a strict Content-Security-Policy (e.g. Gemini): their
434
+ // `connect-src` blocks Tauri's IPC origin, so downloads cannot be routed
435
+ // through the JS bridge, and downloads triggered from a sandboxed iframe
436
+ // can't reach the IPC either. Letting the browser download natively and
437
+ // catching it here is independent of the page CSP and the IPC channel.
438
+ {
439
+ let download_handle = app.clone();
440
+ window_builder = window_builder.on_download(move |_webview, event| match event {
441
+ DownloadEvent::Requested { url, destination } => {
442
+ match download_handle.path().download_dir() {
443
+ Ok(download_dir) => {
444
+ let filename = destination
445
+ .file_name()
446
+ .map(|name| name.to_string_lossy().to_string())
447
+ .filter(|name| !name.is_empty())
448
+ .or_else(|| {
449
+ url.path_segments()
450
+ .and_then(|mut segments| segments.next_back())
451
+ .map(|segment| segment.to_string())
452
+ .filter(|segment| !segment.is_empty())
453
+ })
454
+ .unwrap_or_else(|| "download".to_string());
455
+
456
+ let target = download_dir.join(filename);
457
+ if let Some(path_str) = target.to_str() {
458
+ *destination = PathBuf::from(check_file_or_append(path_str));
459
+ }
460
+ }
461
+ Err(error) => {
462
+ eprintln!("[Pake] Failed to resolve download dir: {error}");
463
+ }
464
+ }
465
+ true
466
+ }
467
+ DownloadEvent::Finished {
468
+ url: _,
469
+ path: _,
470
+ success,
471
+ } => {
472
+ if let Some(window) = download_handle.get_webview_window("pake") {
473
+ let message_type = if success {
474
+ MessageType::Success
475
+ } else {
476
+ MessageType::Failure
477
+ };
478
+ show_toast(&window, &get_download_message_with_lang(message_type, None));
479
+ }
480
+ true
481
+ }
482
+ _ => true,
483
+ });
484
+ }
485
+
429
486
  window_builder = window_builder.on_navigation(|_| true);
430
487
 
431
488
  window_builder.build()
@@ -340,118 +340,19 @@ document.addEventListener("DOMContentLoaded", () => {
340
340
  true,
341
341
  );
342
342
 
343
- // Collect blob urls to blob by overriding window.URL.createObjectURL
344
- function collectUrlToBlobs() {
345
- const backupCreateObjectURL = window.URL.createObjectURL;
346
- window.blobToUrlCaches = new Map();
347
- window.URL.createObjectURL = (blob) => {
348
- const url = backupCreateObjectURL.call(window.URL, blob);
349
- window.blobToUrlCaches.set(url, blob);
350
- return url;
351
- };
352
- }
353
-
354
- function convertBlobUrlToBinary(blobUrl) {
355
- return new Promise((resolve, reject) => {
356
- const blob = window.blobToUrlCaches.get(blobUrl);
357
- if (!blob) {
358
- fetch(blobUrl)
359
- .then((res) => res.arrayBuffer())
360
- .then((buffer) => resolve(Array.from(new Uint8Array(buffer))))
361
- .catch(reject);
362
- return;
363
- }
364
- const reader = new FileReader();
365
- reader.readAsArrayBuffer(blob);
366
- reader.onload = () => {
367
- resolve(Array.from(new Uint8Array(reader.result)));
368
- };
369
- reader.onerror = () => reject(reader.error);
370
- });
371
- }
372
-
373
- function downloadFromDataUri(dataURI, filename) {
374
- try {
375
- const byteString = atob(dataURI.split(",")[1]);
376
- // write the bytes of the string to an ArrayBuffer
377
- const bufferArray = new ArrayBuffer(byteString.length);
378
-
379
- // create a view into the buffer
380
- const binary = new Uint8Array(bufferArray);
381
-
382
- // set the bytes of the buffer to the correct values
383
- for (let i = 0; i < byteString.length; i++) {
384
- binary[i] = byteString.charCodeAt(i);
385
- }
386
-
387
- // write the ArrayBuffer to a binary, and you're done
388
- const userLanguage = getUserLanguage();
389
- invoke("download_file_by_binary", {
390
- params: {
391
- filename,
392
- binary: Array.from(binary),
393
- language: userLanguage,
394
- },
395
- }).catch((error) => {
396
- console.error("Failed to download data URI file:", filename, error);
397
- showDownloadError(filename);
398
- });
399
- } catch (error) {
400
- console.error("Failed to process data URI:", dataURI, error);
401
- showDownloadError(filename || "file");
402
- }
403
- }
404
-
405
- function downloadFromBlobUrl(blobUrl, filename) {
406
- convertBlobUrlToBinary(blobUrl)
407
- .then((binary) => {
408
- const userLanguage = getUserLanguage();
409
- invoke("download_file_by_binary", {
410
- params: {
411
- filename,
412
- binary,
413
- language: userLanguage,
414
- },
415
- }).catch((error) => {
416
- console.error("Failed to download blob file:", filename, error);
417
- showDownloadError(filename);
418
- });
419
- })
420
- .catch((error) => {
421
- console.error("Failed to convert blob to binary:", blobUrl, error);
422
- showDownloadError(filename);
423
- });
424
- }
425
-
426
- // detect blob download by createElement("a")
427
- function detectDownloadByCreateAnchor() {
428
- const createEle = document.createElement;
429
- document.createElement = (el) => {
430
- if (el !== "a") return createEle.call(document, el);
431
- const anchorEle = createEle.call(document, el);
432
-
433
- // use addEventListener to avoid overriding the original click event.
434
- anchorEle.addEventListener(
435
- "click",
436
- (e) => {
437
- const url = anchorEle.href;
438
- const filename = anchorEle.download || getFilenameFromUrl(url);
439
- if (window.blobToUrlCaches.has(url)) {
440
- e.preventDefault();
441
- e.stopImmediatePropagation();
442
- downloadFromBlobUrl(url, filename);
443
- // case: download from dataURL -> convert dataURL ->
444
- } else if (url.startsWith("data:")) {
445
- e.preventDefault();
446
- e.stopImmediatePropagation();
447
- downloadFromDataUri(url, filename);
448
- }
449
- },
450
- true,
451
- );
452
-
453
- return anchorEle;
454
- };
343
+ // Trigger a native browser download via a transient anchor click. The Rust
344
+ // on_download handler then writes the file to the Downloads folder. This is
345
+ // used for blob:/data: URLs because routing their bytes through the Tauri
346
+ // IPC fails on strict-CSP sites (e.g. Gemini), whose connect-src blocks the
347
+ // IPC origin. The native download path is independent of the page CSP.
348
+ function triggerNativeDownload(url, filename) {
349
+ const anchor = document.createElement("a");
350
+ anchor.href = url;
351
+ anchor.download = filename || "";
352
+ anchor.style.display = "none";
353
+ document.body.appendChild(anchor);
354
+ anchor.click();
355
+ document.body.removeChild(anchor);
455
356
  }
456
357
 
457
358
  // process special download protocol['data:','blob:']
@@ -587,11 +488,16 @@ document.addEventListener("DOMContentLoaded", () => {
587
488
  return;
588
489
  }
589
490
 
590
- // Process download links for Rust to handle.
591
- if (
592
- isDownloadRequired(absoluteUrl, anchorElement, e) &&
593
- !isSpecialDownload(absoluteUrl)
594
- ) {
491
+ // Process download links.
492
+ if (isDownloadRequired(absoluteUrl, anchorElement, e)) {
493
+ // Let the browser download blob:/data: URLs natively; the Rust
494
+ // on_download handler saves them to the Downloads folder. Routing them
495
+ // through the IPC fails on strict-CSP sites (e.g. Gemini), whose
496
+ // connect-src blocks the IPC origin, and on downloads triggered from a
497
+ // sandboxed iframe where the IPC can't be reached.
498
+ if (isSpecialDownload(absoluteUrl)) {
499
+ return;
500
+ }
595
501
  e.preventDefault();
596
502
  e.stopImmediatePropagation();
597
503
  const userLanguage = getUserLanguage();
@@ -625,9 +531,6 @@ document.addEventListener("DOMContentLoaded", () => {
625
531
  // Prevent some special websites from executing in advance, before the click event is triggered.
626
532
  document.addEventListener("click", detectAnchorElementClick, true);
627
533
 
628
- collectUrlToBlobs();
629
- detectDownloadByCreateAnchor();
630
-
631
534
  // Rewrite the window.open function.
632
535
  const originalWindowOpen = window.open;
633
536
  window.open = function (url, name, specs) {
@@ -863,12 +766,10 @@ document.addEventListener("DOMContentLoaded", () => {
863
766
  const filename = getFilenameFromUrl(imageUrl) || "image";
864
767
 
865
768
  // Handle different URL types
866
- if (imageUrl.startsWith("data:")) {
867
- downloadFromDataUri(imageUrl, filename);
868
- } else if (imageUrl.startsWith("blob:")) {
869
- if (window.blobToUrlCaches && window.blobToUrlCaches.has(imageUrl)) {
870
- downloadFromBlobUrl(imageUrl, filename);
871
- }
769
+ if (isSpecialDownload(imageUrl)) {
770
+ // Download blob:/data: natively so it works under strict CSP; the Rust
771
+ // on_download handler saves it to the Downloads folder.
772
+ triggerNativeDownload(imageUrl, filename);
872
773
  } else {
873
774
  // Regular HTTP(S) image
874
775
  const userLanguage = getUserLanguage();