pake-cli 3.15.5 โ†’ 3.15.6

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
@@ -202,7 +202,7 @@ Use Pake (npm i -g pake-cli) to package webpages as desktop apps. Read https://u
202
202
 
203
203
  ## Development
204
204
 
205
- Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=18` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
205
+ Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=20` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead.
206
206
 
207
207
  ```bash
208
208
  # Install dependencies
package/dist/cli.js CHANGED
@@ -20,10 +20,10 @@ 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.15.5";
23
+ var version = "3.15.6";
24
24
  var description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚";
25
25
  var engines = {
26
- node: ">=18.0.0"
26
+ node: ">=20.0.0"
27
27
  };
28
28
  var packageManager = "pnpm@10.26.2";
29
29
  var bin = {
@@ -78,14 +78,14 @@ var dependencies = {
78
78
  chalk: "^5.6.2",
79
79
  commander: "^14.0.3",
80
80
  execa: "^9.6.1",
81
- "file-type": "^21.3.0",
81
+ "file-type": "^21.3.4",
82
82
  "fs-extra": "^11.3.3",
83
83
  "icon-gen": "^5.0.0",
84
84
  loglevel: "^1.9.2",
85
85
  ora: "^9.3.0",
86
86
  prompts: "^2.4.2",
87
87
  psl: "^1.15.0",
88
- sharp: "^0.34.5",
88
+ sharp: "^0.35.0",
89
89
  "tmp-promise": "^3.0.3",
90
90
  "update-notifier": "^7.3.1"
91
91
  };
@@ -111,8 +111,9 @@ var devDependencies = {
111
111
  };
112
112
  var pnpm = {
113
113
  overrides: {
114
- sharp: "^0.34.5",
115
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
114
+ sharp: "^0.35.0",
115
+ "@img/sharp-libvips-darwin-arm64": "1.3.0",
116
+ tmp: "0.2.7"
116
117
  },
117
118
  onlyBuiltDependencies: [
118
119
  "esbuild",
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.15.5",
3
+ "version": "3.15.6",
4
4
  "description": "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with one command. ๐Ÿคฑ๐Ÿป ไธ€้”ฎๆ‰“ๅŒ…็ฝ‘้กต็”Ÿๆˆ่ฝป้‡ๆกŒ้ขๅบ”็”จใ€‚",
5
5
  "engines": {
6
- "node": ">=18.0.0"
6
+ "node": ">=20.0.0"
7
7
  },
8
8
  "packageManager": "pnpm@10.26.2",
9
9
  "bin": {
@@ -58,14 +58,14 @@
58
58
  "chalk": "^5.6.2",
59
59
  "commander": "^14.0.3",
60
60
  "execa": "^9.6.1",
61
- "file-type": "^21.3.0",
61
+ "file-type": "^21.3.4",
62
62
  "fs-extra": "^11.3.3",
63
63
  "icon-gen": "^5.0.0",
64
64
  "loglevel": "^1.9.2",
65
65
  "ora": "^9.3.0",
66
66
  "prompts": "^2.4.2",
67
67
  "psl": "^1.15.0",
68
- "sharp": "^0.34.5",
68
+ "sharp": "^0.35.0",
69
69
  "tmp-promise": "^3.0.3",
70
70
  "update-notifier": "^7.3.1"
71
71
  },
@@ -91,8 +91,9 @@
91
91
  },
92
92
  "pnpm": {
93
93
  "overrides": {
94
- "sharp": "^0.34.5",
95
- "@img/sharp-libvips-darwin-arm64": "1.2.4"
94
+ "sharp": "^0.35.0",
95
+ "@img/sharp-libvips-darwin-arm64": "1.3.0",
96
+ "tmp": "0.2.7"
96
97
  },
97
98
  "onlyBuiltDependencies": [
98
99
  "esbuild",
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.15.5"
2567
+ version = "3.15.6"
2568
2568
  dependencies = [
2569
2569
  "block2",
2570
2570
  "dispatch",
@@ -2584,6 +2584,8 @@ dependencies = [
2584
2584
  "tauri-plugin-single-instance",
2585
2585
  "tauri-plugin-window-state",
2586
2586
  "tokio",
2587
+ "webkit2gtk",
2588
+ "webview2-com",
2587
2589
  "windows-sys 0.61.2",
2588
2590
  ]
2589
2591
 
@@ -3042,9 +3044,9 @@ dependencies = [
3042
3044
 
3043
3045
  [[package]]
3044
3046
  name = "quinn-proto"
3045
- version = "0.11.13"
3047
+ version = "0.11.14"
3046
3048
  source = "registry+https://github.com/rust-lang/crates.io-index"
3047
- checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
3049
+ checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
3048
3050
  dependencies = [
3049
3051
  "bytes",
3050
3052
  "getrandom 0.3.4",
@@ -3072,7 +3074,7 @@ dependencies = [
3072
3074
  "once_cell",
3073
3075
  "socket2",
3074
3076
  "tracing",
3075
- "windows-sys 0.52.0",
3077
+ "windows-sys 0.59.0",
3076
3078
  ]
3077
3079
 
3078
3080
  [[package]]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.15.5"
3
+ version = "3.15.6"
4
4
  description = "๐Ÿคฑ๐Ÿป Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "GPL-3.0-or-later"
@@ -42,12 +42,20 @@ objc2 = "0.6"
42
42
  objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSDockTile"] }
43
43
  objc2-foundation = { version = "0.3", features = ["NSString"] }
44
44
 
45
+ [target.'cfg(target_os = "linux")'.dependencies]
46
+ # Used for native WebKitGTK go_back/go_forward on error pages (no page JS).
47
+ # Version and features must match what wry resolves, or PlatformWebview::inner
48
+ # hands back a WebView from a different crate instance.
49
+ webkit2gtk = { version = "=2.0.2", features = ["v2_38"] }
50
+
45
51
  [target.'cfg(windows)'.dependencies]
46
52
  windows-sys = { version = "0.61.2", features = [
47
53
  "Win32_Foundation",
48
54
  "Win32_UI_Shell",
49
55
  "Win32_UI_WindowsAndMessaging",
50
56
  ] }
57
+ # Used for native WebView2 GoBack/GoForward on error pages (no page JS).
58
+ webview2-com = "0.38"
51
59
 
52
60
  [features]
53
61
  # this feature is used for development builds from development cli
@@ -1,3 +1,4 @@
1
+ use crate::app::navigation::{history_step, reload_window};
1
2
  use crate::util::{
2
3
  check_file_or_append, get_download_message_with_lang, sanitize_download_filename, show_toast,
3
4
  MessageType,
@@ -8,6 +9,7 @@ use std::str::FromStr;
8
9
  use std::sync::atomic::{AtomicI64, Ordering};
9
10
  use tauri::http::Method;
10
11
  use tauri::{command, AppHandle, Manager, Url, WebviewWindow};
12
+ use tauri_plugin_http::reqwest::header::{HeaderValue, COOKIE};
11
13
  use tauri_plugin_http::reqwest::{ClientBuilder, Request};
12
14
 
13
15
  use tauri::Theme;
@@ -82,10 +84,30 @@ pub struct NotificationParams {
82
84
  icon: String,
83
85
  }
84
86
 
85
- #[command]
86
- pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result<(), String> {
87
- let window: WebviewWindow = app.get_webview_window("pake").ok_or("Window not found")?;
87
+ /// Build a Cookie header from the webview session so authenticated downloads
88
+ /// match what the page itself would request. Best-effort: missing cookies
89
+ /// fall through to an anonymous request.
90
+ fn cookie_header_for_url(window: &WebviewWindow, url: &Url) -> Option<HeaderValue> {
91
+ let cookies = window.cookies_for_url(url.clone()).ok()?;
92
+ if cookies.is_empty() {
93
+ return None;
94
+ }
95
+ let header = cookies
96
+ .iter()
97
+ .map(|cookie| format!("{}={}", cookie.name(), cookie.value()))
98
+ .collect::<Vec<_>>()
99
+ .join("; ");
100
+ HeaderValue::from_str(&header).ok()
101
+ }
88
102
 
103
+ #[command]
104
+ pub async fn download_file(
105
+ window: WebviewWindow,
106
+ app: AppHandle,
107
+ params: DownloadFileParams,
108
+ ) -> Result<(), String> {
109
+ // Toast on the calling window (secondary windows included), not a hard-coded
110
+ // main-window label. Tauri injects the invoker as `window`.
89
111
  show_toast(
90
112
  &window,
91
113
  &get_download_message_with_lang(MessageType::Start, params.language.clone()),
@@ -108,7 +130,10 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result
108
130
 
109
131
  let url = Url::from_str(&params.url).map_err(|e| format!("Invalid URL: {}", e))?;
110
132
 
111
- let request = Request::new(Method::GET, url);
133
+ let mut request = Request::new(Method::GET, url.clone());
134
+ if let Some(cookie_header) = cookie_header_for_url(&window, &url) {
135
+ request.headers_mut().insert(COOKIE, cookie_header);
136
+ }
112
137
 
113
138
  let response = client.execute(request).await;
114
139
 
@@ -195,12 +220,12 @@ pub fn set_dock_badge_label(app: AppHandle, label: Option<String>) -> Result<(),
195
220
 
196
221
  #[command]
197
222
  pub async fn update_theme_mode(app: AppHandle, mode: String) {
198
- if let Some(window) = app.get_webview_window("pake") {
199
- let theme = if mode == "dark" {
200
- Theme::Dark
201
- } else {
202
- Theme::Light
203
- };
223
+ let theme = if mode == "dark" {
224
+ Theme::Dark
225
+ } else {
226
+ Theme::Light
227
+ };
228
+ for window in app.webview_windows().values() {
204
229
  let _ = window.set_theme(Some(theme));
205
230
  }
206
231
  }
@@ -216,3 +241,27 @@ pub fn set_zoom(window: WebviewWindow, percent: f64) -> Result<(), String> {
216
241
  .set_zoom(factor)
217
242
  .map_err(|e| format!("Failed to set zoom: {}", e))
218
243
  }
244
+
245
+ /// Native navigation for injected shortcuts (Linux/Windows Ctrl+R / [ / ]).
246
+ /// Blank error pages have no JS context, so page `history` / `location` calls
247
+ /// are no-ops; these use the platform webview API instead.
248
+ #[command]
249
+ pub fn webview_navigate(window: WebviewWindow, action: String) -> Result<(), String> {
250
+ match action.as_str() {
251
+ "reload" => {
252
+ reload_window(&window);
253
+ Ok(())
254
+ }
255
+ "back" => {
256
+ history_step(&window, true);
257
+ Ok(())
258
+ }
259
+ "forward" => {
260
+ history_step(&window, false);
261
+ Ok(())
262
+ }
263
+ other => Err(format!(
264
+ "Unknown webview_navigate action '{other}' (expected reload|back|forward)"
265
+ )),
266
+ }
267
+ }
@@ -1,5 +1,8 @@
1
1
  // Menu functionality is only used on macOS; the module is gated in app/mod.rs.
2
+ use crate::app::navigation::{history_step, reload_window};
2
3
  use crate::app::window::{open_additional_window_safe, MultiWindowState};
4
+ use std::io::Write;
5
+ use std::process::{Command, Stdio};
3
6
  use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu};
4
7
  use tauri::{AppHandle, Manager, WebviewWindow, Wry};
5
8
  use tauri_plugin_opener::OpenerExt;
@@ -254,6 +257,18 @@ fn focused_webview_window(app_handle: &AppHandle) -> Option<WebviewWindow> {
254
257
  .or_else(|| windows.get("pake").cloned())
255
258
  }
256
259
 
260
+ /// Copy text via pbcopy so it works when the page has no JS context (error
261
+ /// shells) and when the Clipboard API is blocked by the site CSP.
262
+ fn copy_text_to_pasteboard(text: &str) {
263
+ let Ok(mut child) = Command::new("pbcopy").stdin(Stdio::piped()).spawn() else {
264
+ return;
265
+ };
266
+ if let Some(stdin) = child.stdin.as_mut() {
267
+ let _ = stdin.write_all(text.as_bytes());
268
+ }
269
+ let _ = child.wait();
270
+ }
271
+
257
272
  pub fn handle_menu_click(app_handle: &AppHandle, id: &str) {
258
273
  match id {
259
274
  "new_window" => {
@@ -266,7 +281,8 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) {
266
281
  }
267
282
  "reload" => {
268
283
  if let Some(window) = focused_webview_window(app_handle) {
269
- let _ = window.eval("window.location.reload()");
284
+ // Native reload works on blank error pages where eval cannot.
285
+ reload_window(&window);
270
286
  }
271
287
  }
272
288
  "toggle_devtools" => {
@@ -296,12 +312,12 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) {
296
312
  }
297
313
  "go_back" => {
298
314
  if let Some(window) = focused_webview_window(app_handle) {
299
- let _ = window.eval("window.history.back()");
315
+ history_step(&window, true);
300
316
  }
301
317
  }
302
318
  "go_forward" => {
303
319
  if let Some(window) = focused_webview_window(app_handle) {
304
- let _ = window.eval("window.history.forward()");
320
+ history_step(&window, false);
305
321
  }
306
322
  }
307
323
  "go_home" => {
@@ -321,7 +337,11 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) {
321
337
  }
322
338
  "copy_url" => {
323
339
  if let Some(window) = focused_webview_window(app_handle) {
324
- let _ = window.eval("navigator.clipboard.writeText(window.location.href)");
340
+ // Prefer the native webview URL so copy still works on error
341
+ // pages that have no document.location / clipboard API.
342
+ if let Ok(url) = window.url() {
343
+ copy_text_to_pasteboard(url.as_str());
344
+ }
325
345
  }
326
346
  }
327
347
  "paste_and_match_style" => {
@@ -4,5 +4,6 @@ pub mod config;
4
4
  pub mod invoke;
5
5
  #[cfg(target_os = "macos")]
6
6
  pub mod menu;
7
+ pub mod navigation;
7
8
  pub mod setup;
8
9
  pub mod window;
@@ -0,0 +1,83 @@
1
+ //! Cross-platform webview navigation that does not depend on page JS.
2
+ //! Blank error shells have no JS context, so `eval("history.back()")` and
3
+ //! `location.reload()` are dead exactly when the user is stuck.
4
+
5
+ use tauri::WebviewWindow;
6
+
7
+ /// Reload the current document via the native webview API.
8
+ pub fn reload_window(window: &WebviewWindow) {
9
+ if let Err(error) = window.reload() {
10
+ eprintln!("[Pake] Failed to reload webview: {error}");
11
+ }
12
+ }
13
+
14
+ /// Step the webview history without requiring a live page JS context.
15
+ pub fn history_step(window: &WebviewWindow, back: bool) {
16
+ let stepped = window.with_webview(move |webview| {
17
+ history_step_platform(&webview, back);
18
+ });
19
+
20
+ if stepped.is_err() {
21
+ // Last resort when with_webview is unavailable (rare). Still fails on
22
+ // blank error shells, but preserves SPA history when the page is live.
23
+ let script = if back {
24
+ "window.history.back()"
25
+ } else {
26
+ "window.history.forward()"
27
+ };
28
+ if let Err(error) = window.eval(script) {
29
+ eprintln!("[Pake] Failed to step webview history: {error}");
30
+ }
31
+ }
32
+ }
33
+
34
+ #[cfg(target_os = "macos")]
35
+ fn history_step_platform(webview: &tauri::webview::PlatformWebview, back: bool) {
36
+ use objc2::msg_send;
37
+ use objc2::runtime::AnyObject;
38
+
39
+ let ptr = webview.inner() as *mut AnyObject;
40
+ if ptr.is_null() {
41
+ return;
42
+ }
43
+ unsafe {
44
+ if back {
45
+ let _: *mut AnyObject = msg_send![ptr, goBack];
46
+ } else {
47
+ let _: *mut AnyObject = msg_send![ptr, goForward];
48
+ }
49
+ }
50
+ }
51
+
52
+ #[cfg(target_os = "linux")]
53
+ fn history_step_platform(webview: &tauri::webview::PlatformWebview, back: bool) {
54
+ use webkit2gtk::WebViewExt;
55
+
56
+ let gtk_webview = webview.inner();
57
+ if back {
58
+ gtk_webview.go_back();
59
+ } else {
60
+ gtk_webview.go_forward();
61
+ }
62
+ }
63
+
64
+ #[cfg(windows)]
65
+ fn history_step_platform(webview: &tauri::webview::PlatformWebview, back: bool) {
66
+ use webview2_com::Microsoft::Web::WebView2::Win32::ICoreWebView2Controller;
67
+
68
+ let controller = webview.controller();
69
+ unsafe {
70
+ // ICoreWebView2Controller::CoreWebView2 returns the underlying browser.
71
+ let Ok(core) = controller.CoreWebView2() else {
72
+ return;
73
+ };
74
+ if back {
75
+ let _ = core.GoBack();
76
+ } else {
77
+ let _ = core.GoForward();
78
+ }
79
+ }
80
+ }
81
+
82
+ #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
83
+ fn history_step_platform(_webview: &tauri::webview::PlatformWebview, _back: bool) {}
@@ -1,11 +1,13 @@
1
- use crate::app::window::{open_additional_window_safe, reapply_window_icon};
1
+ use crate::app::window::{
2
+ hide_all_app_windows, open_additional_window_safe, show_all_app_windows, toggle_all_app_windows,
3
+ };
2
4
  use crate::cancel_startup_reveal;
3
5
  use std::str::FromStr;
4
6
  use std::sync::{atomic::AtomicBool, Arc, Mutex};
5
7
  use std::time::{Duration, Instant};
6
8
  use tauri::{
7
9
  menu::{MenuBuilder, MenuItemBuilder},
8
- tray::{TrayIconBuilder, TrayIconEvent},
10
+ tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
9
11
  AppHandle, Manager,
10
12
  };
11
13
  use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut};
@@ -53,22 +55,13 @@ pub fn set_system_tray(
53
55
  open_additional_window_safe(app);
54
56
  }
55
57
  "hide_app" => {
56
- if let Some(window) = app.get_webview_window("pake") {
57
- cancel_startup_reveal(&menu_revealed);
58
- let _ = window.minimize();
59
- }
58
+ // Hide every webview (main + multi-window clones), not only "pake".
59
+ cancel_startup_reveal(&menu_revealed);
60
+ hide_all_app_windows(app);
60
61
  }
61
62
  "show_app" => {
62
- if let Some(window) = app.get_webview_window("pake") {
63
- cancel_startup_reveal(&menu_revealed);
64
- let _ = window.show();
65
- reapply_window_icon(&window);
66
- #[cfg(target_os = "linux")]
67
- if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) {
68
- let _ = window.set_fullscreen(true);
69
- let _ = window.set_focus();
70
- }
71
- }
63
+ cancel_startup_reveal(&menu_revealed);
64
+ show_all_app_windows(app, _init_fullscreen);
72
65
  }
73
66
  "quit" => {
74
67
  let flags = if _init_fullscreen {
@@ -82,24 +75,19 @@ pub fn set_system_tray(
82
75
  _ => (),
83
76
  })
84
77
  .on_tray_icon_event(move |tray, event| {
85
- if let TrayIconEvent::Click { button, .. } = event {
86
- if button == tauri::tray::MouseButton::Left {
87
- if let Some(window) = tray.app_handle().get_webview_window("pake") {
88
- // Any tray toggle claims visibility control from startup reveal.
89
- cancel_startup_reveal(&click_revealed);
90
- let is_visible = window.is_visible().unwrap_or(false);
91
- if is_visible {
92
- let _ = window.hide();
93
- } else {
94
- let _ = window.show();
95
- reapply_window_icon(&window);
96
- let _ = window.set_focus();
97
- #[cfg(target_os = "linux")]
98
- if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) {
99
- let _ = window.set_fullscreen(true);
100
- }
101
- }
102
- }
78
+ if let TrayIconEvent::Click {
79
+ button,
80
+ button_state,
81
+ ..
82
+ } = event
83
+ {
84
+ // Windows emits Click twice per physical click (Down then Up).
85
+ // Reacting to both runs the toggle twice, so a hidden window is
86
+ // shown and immediately re-hidden and the tray looks dead (#1343).
87
+ if button == MouseButton::Left && button_state == MouseButtonState::Up {
88
+ // Any tray toggle claims visibility control from startup reveal.
89
+ cancel_startup_reveal(&click_revealed);
90
+ toggle_all_app_windows(tray.app_handle(), _init_fullscreen);
103
91
  }
104
92
  }
105
93
  });
@@ -159,21 +147,8 @@ pub fn set_global_shortcut(
159
147
  *last_triggered = Instant::now();
160
148
 
161
149
  if shortcut_hotkey.eq(event) {
162
- if let Some(window) = app.get_webview_window("pake") {
163
- cancel_startup_reveal(&startup_revealed);
164
- let is_visible = window.is_visible().unwrap_or(false);
165
- if is_visible {
166
- let _ = window.hide();
167
- } else {
168
- let _ = window.show();
169
- reapply_window_icon(&window);
170
- let _ = window.set_focus();
171
- #[cfg(target_os = "linux")]
172
- if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) {
173
- let _ = window.set_fullscreen(true);
174
- }
175
- }
176
- }
150
+ cancel_startup_reveal(&startup_revealed);
151
+ toggle_all_app_windows(app, _init_fullscreen);
177
152
  }
178
153
  }
179
154
  })
@@ -186,15 +186,21 @@ fn open_requested_window(
186
186
  Ok(window)
187
187
  }
188
188
 
189
+ /// Open a multi-window clone of the home app. The window is built hidden and
190
+ /// revealed on its first real page load (see `lib.rs` on_page_load) so Cmd+N
191
+ /// does not flash an empty shell the way the main window used to.
189
192
  pub fn open_additional_window_safe(app: &AppHandle) {
190
193
  #[cfg(target_os = "windows")]
191
194
  {
192
195
  let app_handle = app.clone();
193
196
  std::thread::spawn(move || {
194
197
  if let Ok(window) = open_additional_window(&app_handle) {
195
- let _ = window.show();
196
- reapply_window_icon(&window);
197
- let _ = window.set_focus();
198
+ // Fallback if PageLoadEvent::Finished never arrives.
199
+ let fallback = window.clone();
200
+ tauri::async_runtime::spawn(async move {
201
+ tokio::time::sleep(tokio::time::Duration::from_millis(3_000)).await;
202
+ reveal_built_window(&fallback);
203
+ });
198
204
  }
199
205
  });
200
206
  }
@@ -202,13 +208,79 @@ pub fn open_additional_window_safe(app: &AppHandle) {
202
208
  #[cfg(not(target_os = "windows"))]
203
209
  {
204
210
  if let Ok(window) = open_additional_window(app) {
205
- let _ = window.show();
206
- reapply_window_icon(&window);
207
- let _ = window.set_focus();
211
+ let fallback = window.clone();
212
+ tauri::async_runtime::spawn(async move {
213
+ tokio::time::sleep(tokio::time::Duration::from_millis(3_000)).await;
214
+ reveal_built_window(&fallback);
215
+ });
208
216
  }
209
217
  }
210
218
  }
211
219
 
220
+ /// Show a window that was built hidden once content is ready (or the fallback
221
+ /// timer fires). No-ops when already visible so page-load and fallback can race.
222
+ pub fn reveal_built_window(window: &WebviewWindow) {
223
+ if window.is_visible().unwrap_or(true) {
224
+ return;
225
+ }
226
+ let _ = window.show();
227
+ reapply_window_icon(window);
228
+ let _ = window.set_focus();
229
+ }
230
+
231
+ /// True when any Pake webview window is currently on screen.
232
+ ///
233
+ /// A minimized window does not count. Windows keeps `IsWindowVisible` true while
234
+ /// a window is iconic, and `hide_on_close` minimizes before hiding, so treating
235
+ /// minimized as visible makes the tray toggle hide an already-invisible window
236
+ /// instead of restoring it (#1343).
237
+ pub fn any_app_window_visible(app: &AppHandle) -> bool {
238
+ app.webview_windows().values().any(|window| {
239
+ window.is_visible().unwrap_or(false) && !window.is_minimized().unwrap_or(false)
240
+ })
241
+ }
242
+
243
+ /// Hide every webview window (main + multi-window clones). Used by tray Hide
244
+ /// and the activation shortcut so secondary windows are not left on screen.
245
+ pub fn hide_all_app_windows(app: &AppHandle) {
246
+ for window in app.webview_windows().values() {
247
+ let _ = window.hide();
248
+ }
249
+ }
250
+
251
+ /// Show every webview window, reassert icons, and focus the main window.
252
+ pub fn show_all_app_windows(app: &AppHandle, init_fullscreen: bool) {
253
+ let windows = app.webview_windows();
254
+ for window in windows.values() {
255
+ let _ = window.unminimize();
256
+ let _ = window.show();
257
+ reapply_window_icon(window);
258
+ #[cfg(target_os = "linux")]
259
+ if init_fullscreen && !window.is_fullscreen().unwrap_or(false) {
260
+ let _ = window.set_fullscreen(true);
261
+ }
262
+ }
263
+ #[cfg(not(target_os = "linux"))]
264
+ let _ = init_fullscreen;
265
+
266
+ if let Some(main) = windows.get("pake") {
267
+ let _ = main.set_focus();
268
+ } else if let Some(any) = windows.values().next() {
269
+ let _ = any.set_focus();
270
+ }
271
+ }
272
+
273
+ /// Tray-click / activation-shortcut toggle: hide all if anything is visible,
274
+ /// otherwise show all. Cancels startup reveal when the caller has already done
275
+ /// so; this helper only touches visibility.
276
+ pub fn toggle_all_app_windows(app: &AppHandle, init_fullscreen: bool) {
277
+ if any_app_window_visible(app) {
278
+ hide_all_app_windows(app);
279
+ } else {
280
+ show_all_app_windows(app, init_fullscreen);
281
+ }
282
+ }
283
+
212
284
  fn build_window_with_label(
213
285
  app: &AppHandle,
214
286
  config: &PakeConfig,
@@ -553,7 +625,7 @@ fn build_window(
553
625
  // catching it here is independent of the page CSP and the IPC channel.
554
626
  {
555
627
  let download_handle = app.clone();
556
- window_builder = window_builder.on_download(move |_webview, event| match event {
628
+ window_builder = window_builder.on_download(move |webview, event| match event {
557
629
  DownloadEvent::Requested { url, destination } => {
558
630
  match download_handle.path().download_dir() {
559
631
  Ok(download_dir) => {
@@ -585,7 +657,12 @@ fn build_window(
585
657
  path: _,
586
658
  success,
587
659
  } => {
588
- if let Some(window) = download_handle.get_webview_window("pake") {
660
+ // Toast on the window that started the download (including
661
+ // secondary multi-window labels), not a hard-coded "pake".
662
+ let toast_window = download_handle
663
+ .get_webview_window(webview.label())
664
+ .or_else(|| download_handle.get_webview_window("pake"));
665
+ if let Some(window) = toast_window {
589
666
  let message_type = if success {
590
667
  MessageType::Success
591
668
  } else {
@@ -1,11 +1,35 @@
1
+ // Prefer native webview navigation over page JS so Ctrl+R / [ / ] still work
2
+ // on blank error shells (no JS context). Falls back to history/location when
3
+ // the IPC bridge is unavailable.
4
+ function nativeNavigate(action) {
5
+ const invoke = window.__TAURI__?.core?.invoke;
6
+ if (invoke) {
7
+ invoke("webview_navigate", { action }).catch(() => {
8
+ fallbackNavigate(action);
9
+ });
10
+ return;
11
+ }
12
+ fallbackNavigate(action);
13
+ }
14
+
15
+ function fallbackNavigate(action) {
16
+ if (action === "reload") {
17
+ window.location.reload();
18
+ } else if (action === "back") {
19
+ window.history.back();
20
+ } else if (action === "forward") {
21
+ window.history.forward();
22
+ }
23
+ }
24
+
1
25
  const shortcuts = {
2
- "[": () => window.history.back(),
3
- "]": () => window.history.forward(),
26
+ "[": () => nativeNavigate("back"),
27
+ "]": () => nativeNavigate("forward"),
4
28
  "-": () => zoomOut(),
5
29
  "=": () => zoomIn(),
6
30
  "+": () => zoomIn(),
7
31
  0: () => setZoom("100%"),
8
- r: () => window.location.reload(),
32
+ r: () => nativeNavigate("reload"),
9
33
  ArrowUp: () => scrollTo(0, 0),
10
34
  ArrowDown: () => scrollTo(0, document.body.scrollHeight),
11
35
  };
@@ -370,34 +394,12 @@ const DOWNLOADABLE_FILE_EXTENSIONS = {
370
394
  "apk",
371
395
  "ipa",
372
396
  ],
373
- data: [
374
- "json",
375
- "xml",
376
- "csv",
377
- "sql",
378
- "db",
379
- "sqlite",
380
- "yaml",
381
- "yml",
382
- "toml",
383
- "ini",
384
- "cfg",
385
- "conf",
386
- "log",
387
- ],
388
- code: [
389
- "js",
390
- "ts",
391
- "jsx",
392
- "tsx",
393
- "css",
394
- "scss",
395
- "sass",
396
- "less",
397
- "sh",
398
- "bat",
399
- "ps1",
400
- ],
397
+ // Navigable web formats (json/xml/js/css/html and friends) are intentionally
398
+ // omitted: documentation and SPA content negotiation often serve them as
399
+ // in-app pages. Real file downloads still match via the download attribute,
400
+ // ?download / ?attachment, or binary extensions below.
401
+ data: ["csv", "sql", "db", "sqlite"],
402
+ scripts: ["sh", "bat", "ps1"],
401
403
  fonts: ["ttf", "otf", "woff", "woff2", "eot"],
402
404
  design: ["ai", "psd", "sketch", "fig", "xd"],
403
405
  system: [
@@ -446,12 +448,12 @@ const PREVIEWABLE_MEDIA_EXTENSIONS = [
446
448
  "m4a",
447
449
  ];
448
450
 
449
- // Path fragments that often host real file downloads. Do not add broad static
450
- // asset roots such as "/assets/" or "/dist/": many SPAs use those as in-app
451
- // routes (e.g. MEXC /assets/future) and would be intercepted as downloads.
452
- // Extensionless links under these paths still match; prefer real extensions,
453
- // download attributes, or ?download / ?attachment query hints when possible.
454
- const DOWNLOAD_PATH_PATTERNS = ["/download/", "/files/", "/attachments/"];
451
+ // Path fragments that often host real file downloads. Keep this list narrow:
452
+ // SPA roots such as "/assets/", "/dist/", "/files/", "/releases/", and
453
+ // "/attachments/" have already been mistaken for downloads in the wild.
454
+ // Prefer real file extensions, the download attribute, or ?download /
455
+ // ?attachment query hints whenever possible.
456
+ const DOWNLOAD_PATH_PATTERNS = ["/download/"];
455
457
 
456
458
  // Language detection utilities
457
459
  function getUserLanguage() {
@@ -527,6 +529,52 @@ function isDownloadableFile(url) {
527
529
  }
528
530
  }
529
531
 
532
+ // Public suffixes where the registrable domain needs more than two labels.
533
+ // Not exhaustive; covers common packaging targets that last-two-label
534
+ // matching would collapse incorrectly (e.g. amazon.co.uk vs evil.co.uk,
535
+ // or every *.github.io site into one "domain").
536
+ const MULTI_PART_PUBLIC_SUFFIXES = [
537
+ "co.uk",
538
+ "org.uk",
539
+ "ac.uk",
540
+ "gov.uk",
541
+ "com.au",
542
+ "net.au",
543
+ "org.au",
544
+ "co.jp",
545
+ "ne.jp",
546
+ "or.jp",
547
+ "co.kr",
548
+ "co.in",
549
+ "com.br",
550
+ "com.cn",
551
+ "com.tw",
552
+ "com.hk",
553
+ "com.sg",
554
+ "github.io",
555
+ "gitlab.io",
556
+ "pages.dev",
557
+ ];
558
+
559
+ function getRootDomain(hostname) {
560
+ const normalized = String(hostname || "").toLowerCase();
561
+ if (!normalized) {
562
+ return "";
563
+ }
564
+
565
+ const parts = normalized.split(".").filter(Boolean);
566
+ if (parts.length <= 1) {
567
+ return normalized;
568
+ }
569
+
570
+ const lastTwo = parts.slice(-2).join(".");
571
+ if (MULTI_PART_PUBLIC_SUFFIXES.includes(lastTwo) && parts.length >= 3) {
572
+ return parts.slice(-3).join(".");
573
+ }
574
+
575
+ return lastTwo;
576
+ }
577
+
530
578
  function normalizeAnchorHref(rawHref) {
531
579
  return typeof rawHref === "string" ? rawHref.trim() : "";
532
580
  }
@@ -660,8 +708,11 @@ document.addEventListener("DOMContentLoaded", () => {
660
708
  const isSpecialDownload = (url) =>
661
709
  ["blob", "data"].some((protocol) => url.startsWith(protocol));
662
710
 
663
- const isDownloadRequired = (url, anchorElement, e) =>
664
- anchorElement.download || e.metaKey || e.ctrlKey || isDownloadableFile(url);
711
+ // Cmd/Ctrl+click is a browser "open related" gesture, not "save as".
712
+ // Only the download attribute and downloadable-file heuristics force a
713
+ // download; modifiers must not rewrite ordinary navigation.
714
+ const isDownloadRequired = (url, anchorElement, _e) =>
715
+ Boolean(anchorElement.download) || isDownloadableFile(url);
665
716
 
666
717
  const handleExternalLink = (url) => {
667
718
  // Don't try to open blob: or data: URLs with shell
@@ -685,12 +736,8 @@ document.addEventListener("DOMContentLoaded", () => {
685
736
 
686
737
  if (linkUrl.hostname === currentUrl.hostname) return true;
687
738
 
688
- // Extract root domain (e.g., bilibili.com from www.bilibili.com)
689
- const getRootDomain = (hostname) => {
690
- const parts = hostname.split(".");
691
- return parts.length >= 2 ? parts.slice(-2).join(".") : hostname;
692
- };
693
-
739
+ // e.g. www.bilibili.com and m.bilibili.com share bilibili.com;
740
+ // amazon.co.uk must not share a root with evil.co.uk.
694
741
  return (
695
742
  getRootDomain(currentUrl.hostname) === getRootDomain(linkUrl.hostname)
696
743
  );
@@ -28,10 +28,13 @@ const GDK_BACKEND: &str = "GDK_BACKEND";
28
28
  use app::{
29
29
  invoke::{
30
30
  clear_dock_badge, download_file, increment_dock_badge, send_notification, set_dock_badge,
31
- set_dock_badge_label, set_zoom, update_theme_mode,
31
+ set_dock_badge_label, set_zoom, update_theme_mode, webview_navigate,
32
32
  },
33
33
  setup::{set_global_shortcut, set_system_tray},
34
- window::{open_additional_window_safe, reapply_window_icon, set_window, MultiWindowState},
34
+ window::{
35
+ open_additional_window_safe, reapply_window_icon, reveal_built_window, set_window,
36
+ MultiWindowState,
37
+ },
35
38
  };
36
39
  use util::get_pake_config;
37
40
 
@@ -244,16 +247,14 @@ pub fn run_app() {
244
247
  ));
245
248
  }
246
249
 
247
- // Reveal the main window after the first real document finishes loading so
250
+ // Reveal hidden windows after the first real document finishes loading so
248
251
  // slow WKWebView cold starts do not expose an empty but interactive shell.
249
- // start_to_tray keeps the window hidden for the whole session until the user
250
- // opens it from the tray / shortcut.
251
- if !start_to_tray {
252
+ // - Main label "pake": once-only latch + start_to_tray opt-out.
253
+ // - Secondary multi-window labels ("pake-N"): reveal if still hidden (Cmd+N).
254
+ // start_to_tray keeps the main window hidden until the user opens it.
255
+ {
252
256
  let page_load_revealed = startup_window_revealed.clone();
253
257
  app_builder = app_builder.on_page_load(move |webview, payload| {
254
- if webview.label() != "pake" {
255
- return;
256
- }
257
258
  if !matches!(payload.event(), PageLoadEvent::Finished) {
258
259
  return;
259
260
  }
@@ -262,8 +263,24 @@ pub fn run_app() {
262
263
  if is_placeholder_startup_url(payload.url()) {
263
264
  return;
264
265
  }
265
- if let Some(window) = webview.app_handle().get_webview_window("pake") {
266
- reveal_startup_window(window, init_fullscreen, &page_load_revealed);
266
+
267
+ let label = webview.label();
268
+ if label == "pake" {
269
+ if start_to_tray {
270
+ return;
271
+ }
272
+ if let Some(window) = webview.app_handle().get_webview_window("pake") {
273
+ reveal_startup_window(window, init_fullscreen, &page_load_revealed);
274
+ }
275
+ return;
276
+ }
277
+
278
+ // Multi-window clones (pake-1, pake-2, โ€ฆ) built hidden by
279
+ // open_additional_window_safe.
280
+ if label.starts_with("pake-") {
281
+ if let Some(window) = webview.app_handle().get_webview_window(label) {
282
+ reveal_built_window(&window);
283
+ }
267
284
  }
268
285
  });
269
286
  }
@@ -283,6 +300,7 @@ pub fn run_app() {
283
300
  clear_dock_badge,
284
301
  update_theme_mode,
285
302
  set_zoom,
303
+ webview_navigate,
286
304
  ])
287
305
  .setup(move |app| {
288
306
  app.manage(MultiWindowState::new(
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.15.5",
4
+ "version": "3.15.6",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {