pake-cli 3.11.4 → 3.11.5

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.
@@ -119,49 +119,54 @@ pub fn set_global_shortcut(
119
119
  let app_handle = app.clone();
120
120
  let shortcut_hotkey = match Shortcut::from_str(&shortcut) {
121
121
  Ok(s) => s,
122
- Err(_) => return Ok(()),
122
+ Err(error) => {
123
+ eprintln!("[Pake] Invalid activation shortcut '{shortcut}': {error}");
124
+ return Ok(());
125
+ }
123
126
  };
124
127
  let last_triggered = Arc::new(Mutex::new(Instant::now()));
125
128
 
126
- app_handle
127
- .plugin(
128
- tauri_plugin_global_shortcut::Builder::new()
129
- .with_handler({
130
- let last_triggered = Arc::clone(&last_triggered);
131
- move |app, event, _shortcut| {
132
- let Ok(mut last_triggered) = last_triggered.lock() else {
133
- return;
134
- };
135
- if Instant::now().duration_since(*last_triggered)
136
- < Duration::from_millis(300)
137
- {
138
- return;
139
- }
140
- *last_triggered = Instant::now();
129
+ if let Err(error) = app_handle.plugin(
130
+ tauri_plugin_global_shortcut::Builder::new()
131
+ .with_handler({
132
+ let last_triggered = Arc::clone(&last_triggered);
133
+ move |app, event, _shortcut| {
134
+ let Ok(mut last_triggered) = last_triggered.lock() else {
135
+ return;
136
+ };
137
+ if Instant::now().duration_since(*last_triggered) < Duration::from_millis(300) {
138
+ return;
139
+ }
140
+ *last_triggered = Instant::now();
141
141
 
142
- if shortcut_hotkey.eq(event) {
143
- if let Some(window) = app.get_webview_window("pake") {
144
- let is_visible = window.is_visible().unwrap_or(false);
145
- if is_visible {
146
- let _ = window.hide();
147
- } else {
148
- let _ = window.show();
149
- let _ = window.set_focus();
150
- #[cfg(target_os = "linux")]
151
- if _init_fullscreen && !window.is_fullscreen().unwrap_or(false)
152
- {
153
- let _ = window.set_fullscreen(true);
154
- }
142
+ if shortcut_hotkey.eq(event) {
143
+ if let Some(window) = app.get_webview_window("pake") {
144
+ let is_visible = window.is_visible().unwrap_or(false);
145
+ if is_visible {
146
+ let _ = window.hide();
147
+ } else {
148
+ let _ = window.show();
149
+ let _ = window.set_focus();
150
+ #[cfg(target_os = "linux")]
151
+ if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) {
152
+ let _ = window.set_fullscreen(true);
155
153
  }
156
154
  }
157
155
  }
158
156
  }
159
- })
160
- .build(),
161
- )
162
- .expect("Failed to set global shortcut");
157
+ }
158
+ })
159
+ .build(),
160
+ ) {
161
+ eprintln!(
162
+ "[Pake] Failed to register global shortcut plugin '{shortcut}': {error}; continuing without it."
163
+ );
164
+ return Ok(());
165
+ }
163
166
 
164
- let _ = app.global_shortcut().register(shortcut_hotkey);
167
+ if let Err(error) = app.global_shortcut().register(shortcut_hotkey) {
168
+ eprintln!("[Pake] Failed to bind global shortcut '{shortcut}': {error}");
169
+ }
165
170
 
166
171
  Ok(())
167
172
  }
@@ -50,8 +50,12 @@ impl MultiWindowState {
50
50
  }
51
51
  }
52
52
 
53
- pub fn set_window(app: &AppHandle, config: &PakeConfig, tauri_config: &Config) -> WebviewWindow {
54
- build_window_with_label(app, config, tauri_config, "pake").expect("Failed to build window")
53
+ pub fn set_window(
54
+ app: &AppHandle,
55
+ config: &PakeConfig,
56
+ tauri_config: &Config,
57
+ ) -> tauri::Result<WebviewWindow> {
58
+ build_window_with_label(app, config, tauri_config, "pake")
55
59
  }
56
60
 
57
61
  pub fn open_additional_window(app: &AppHandle) -> tauri::Result<WebviewWindow> {
@@ -122,10 +126,12 @@ fn build_window_with_label(
122
126
  tauri_config: &Config,
123
127
  label: &str,
124
128
  ) -> tauri::Result<WebviewWindow> {
125
- let window_config = config
126
- .windows
127
- .first()
128
- .expect("At least one window configuration is required");
129
+ let window_config = config.windows.first().ok_or_else(|| {
130
+ tauri::Error::Io(std::io::Error::new(
131
+ std::io::ErrorKind::InvalidData,
132
+ "pake.json must define at least one window configuration",
133
+ ))
134
+ })?;
129
135
  let url = match window_config.url_type.as_str() {
130
136
  "web" => {
131
137
  let parsed = window_config.url.parse().map_err(|err| {
@@ -177,12 +183,14 @@ fn build_window(
177
183
  .product_name
178
184
  .clone()
179
185
  .unwrap_or_else(|| "pake".to_string());
180
- let _data_dir = get_data_dir(app, package_name);
186
+ let _data_dir = get_data_dir(app, package_name).map_err(tauri::Error::Io)?;
181
187
 
182
- let window_config = config
183
- .windows
184
- .first()
185
- .expect("At least one window configuration is required");
188
+ let window_config = config.windows.first().ok_or_else(|| {
189
+ tauri::Error::Io(std::io::Error::new(
190
+ std::io::ErrorKind::InvalidData,
191
+ "pake.json must define at least one window configuration",
192
+ ))
193
+ })?;
186
194
 
187
195
  let user_agent = config.user_agent.get();
188
196
 
@@ -273,10 +281,14 @@ fn build_window(
273
281
  });
274
282
  }
275
283
 
276
- // Add initialization scripts
284
+ // Add initialization scripts. Order matters: pakeConfig must land before
285
+ // any script that reads it (e.g. fullscreen polyfill checks for an opt-out
286
+ // flag), and toast must register `window.pakeToast` before Rust code
287
+ // calls show_toast().
277
288
  window_builder = window_builder
278
289
  .initialization_script(&config_script)
279
- .initialization_script(include_str!("../inject/component.js"))
290
+ .initialization_script(include_str!("../inject/toast.js"))
291
+ .initialization_script(include_str!("../inject/fullscreen.js"))
280
292
  .initialization_script(include_str!("../inject/event.js"))
281
293
  .initialization_script(include_str!("../inject/style.js"))
282
294
  .initialization_script(include_str!("../inject/theme_refresh.js"))
@@ -391,7 +403,9 @@ fn build_window(
391
403
  }
392
404
 
393
405
  if let Some(features) = new_window_features {
394
- // macOS popup webviews must reuse the opener webview configuration.
406
+ // Reuse only opener-provided position/size on macOS; sharing the opener
407
+ // WKWebViewConfiguration triggers duplicate WKScriptMessageHandler
408
+ // registrations on macOS 26+ and crashes the app (issue #1194).
395
409
  #[cfg(target_os = "macos")]
396
410
  {
397
411
  if let Some(position) = features.position() {
@@ -402,9 +416,7 @@ fn build_window(
402
416
  window_builder = window_builder.inner_size(size.width, size.height);
403
417
  }
404
418
 
405
- window_builder = window_builder
406
- .with_webview_configuration(features.opener().target_configuration.clone())
407
- .focused(true);
419
+ window_builder = window_builder.focused(true);
408
420
  }
409
421
 
410
422
  #[cfg(not(target_os = "macos"))]
@@ -417,3 +429,37 @@ fn build_window(
417
429
 
418
430
  window_builder.build()
419
431
  }
432
+
433
+ #[cfg(all(test, target_os = "windows"))]
434
+ mod proxy_arg_tests {
435
+ use super::*;
436
+
437
+ fn parse(url: &str) -> Url {
438
+ Url::from_str(url).unwrap()
439
+ }
440
+
441
+ #[test]
442
+ fn http_url_with_explicit_port() {
443
+ let arg = build_proxy_browser_arg(&parse("http://127.0.0.1:7890")).unwrap();
444
+ assert_eq!(arg, "--proxy-server=http://127.0.0.1:7890");
445
+ }
446
+
447
+ #[test]
448
+ fn http_url_uses_default_port_when_missing() {
449
+ let arg = build_proxy_browser_arg(&parse("http://proxy.local")).unwrap();
450
+ assert_eq!(arg, "--proxy-server=http://proxy.local:80");
451
+ }
452
+
453
+ #[test]
454
+ fn socks5_url_uses_default_port_when_missing() {
455
+ let arg = build_proxy_browser_arg(&parse("socks5://proxy.local")).unwrap();
456
+ assert_eq!(arg, "--proxy-server=socks5://proxy.local:1080");
457
+ }
458
+
459
+ #[test]
460
+ fn https_scheme_is_not_supported_yet() {
461
+ // https proxies fall back to platform proxy_url; we only emit a CLI arg
462
+ // for http/socks5 today.
463
+ assert!(build_proxy_browser_arg(&parse("https://proxy.local:8443")).is_none());
464
+ }
465
+ }
@@ -1,28 +1,10 @@
1
- document.addEventListener("DOMContentLoaded", () => {
2
- // Toast
3
- function pakeToast(msg) {
4
- const m = document.createElement("div");
5
- m.innerHTML = msg;
6
- m.style.cssText =
7
- "max-width:60%;min-width: 80px;padding:0 12px;height: 32px;color: rgb(255, 255, 255);line-height: 32px;text-align: center;border-radius: 8px;position: fixed; bottom:24px;right: 28px;z-index: 999999;background: rgba(0, 0, 0,.8);font-size: 13px;";
8
- document.body.appendChild(m);
9
- setTimeout(function () {
10
- const d = 0.5;
11
- m.style.transition =
12
- "transform " + d + "s ease-in, opacity " + d + "s ease-in";
13
- m.style.opacity = "0";
14
- setTimeout(function () {
15
- document.body.removeChild(m);
16
- }, d * 1000);
17
- }, 3000);
18
- }
19
-
20
- window.pakeToast = pakeToast;
21
- });
22
-
23
- // Polyfill for HTML5 Fullscreen API in Tauri webview
24
- // This bridges the HTML5 Fullscreen API to Tauri's native window fullscreen
25
- // Works for all video sites (YouTube, Vimeo, Bilibili, etc.)
1
+ // Polyfill for HTML5 Fullscreen API in Tauri webview.
2
+ // Bridges the standard requestFullscreen / exitFullscreen DOM API to Tauri's
3
+ // native window fullscreen so video sites (YouTube, Vimeo, Bilibili, etc.) can
4
+ // go true fullscreen on their player buttons.
5
+ //
6
+ // Split out from component.js so a future CLI flag (or custom.js override)
7
+ // can short-circuit the polyfill for apps that don't need video fullscreen.
26
8
  (function () {
27
9
  if (window.__PAKE_FULLSCREEN_POLYFILL__) return;
28
10
  window.__PAKE_FULLSCREEN_POLYFILL__ = true;
@@ -42,7 +24,6 @@ document.addEventListener("DOMContentLoaded", () => {
42
24
  let wasInBody = false;
43
25
  let monitorId = null;
44
26
 
45
- // Inject fullscreen styles
46
27
  if (!document.getElementById("pake-fullscreen-style")) {
47
28
  const styleEl = document.createElement("style");
48
29
  styleEl.id = "pake-fullscreen-style";
@@ -93,7 +74,6 @@ document.addEventListener("DOMContentLoaded", () => {
93
74
  monitorId = null;
94
75
  }
95
76
 
96
- // Find the actual video element
97
77
  function findMediaElement() {
98
78
  const videos = document.querySelectorAll("video");
99
79
  if (videos.length > 0) {
@@ -112,11 +92,9 @@ document.addEventListener("DOMContentLoaded", () => {
112
92
  return null;
113
93
  }
114
94
 
115
- // Enter fullscreen
116
95
  function enterFullscreen(element) {
117
96
  fullscreenElement = element;
118
97
 
119
- // If html/body element, find the video instead
120
98
  let targetElement = element;
121
99
  if (element === document.documentElement || element === document.body) {
122
100
  const mediaElement = findMediaElement();
@@ -130,7 +108,6 @@ document.addEventListener("DOMContentLoaded", () => {
130
108
  actualFullscreenElement = element;
131
109
  }
132
110
 
133
- // Save original state
134
111
  originalStyles = {
135
112
  position: targetElement.style.position,
136
113
  top: targetElement.style.top,
@@ -152,7 +129,6 @@ document.addEventListener("DOMContentLoaded", () => {
152
129
  originalNextSibling = targetElement.nextSibling;
153
130
  }
154
131
 
155
- // Apply fullscreen
156
132
  targetElement.classList.add("pake-fullscreen-element");
157
133
  document.body.classList.add("pake-fullscreen-active");
158
134
 
@@ -160,7 +136,6 @@ document.addEventListener("DOMContentLoaded", () => {
160
136
  document.body.appendChild(targetElement);
161
137
  }
162
138
 
163
- // Fullscreen window
164
139
  appWindow.setFullscreen(true).then(() => {
165
140
  startFullscreenMonitor();
166
141
  const event = new Event("fullscreenchange", { bubbles: true });
@@ -177,7 +152,6 @@ document.addEventListener("DOMContentLoaded", () => {
177
152
  return Promise.resolve();
178
153
  }
179
154
 
180
- // Exit fullscreen
181
155
  function exitFullscreen() {
182
156
  if (!fullscreenElement) {
183
157
  return Promise.resolve();
@@ -188,7 +162,6 @@ document.addEventListener("DOMContentLoaded", () => {
188
162
  const exitingElement = fullscreenElement;
189
163
  const targetElement = actualFullscreenElement;
190
164
 
191
- // Restore styles and position
192
165
  targetElement.classList.remove("pake-fullscreen-element");
193
166
  document.body.classList.remove("pake-fullscreen-active");
194
167
 
@@ -209,7 +182,6 @@ document.addEventListener("DOMContentLoaded", () => {
209
182
  }
210
183
  }
211
184
 
212
- // Reset state
213
185
  fullscreenElement = null;
214
186
  actualFullscreenElement = null;
215
187
  originalStyles = null;
@@ -217,7 +189,6 @@ document.addEventListener("DOMContentLoaded", () => {
217
189
  originalNextSibling = null;
218
190
  wasInBody = false;
219
191
 
220
- // Exit window fullscreen
221
192
  return appWindow.setFullscreen(false).then(() => {
222
193
  const event = new Event("fullscreenchange", { bubbles: true });
223
194
  document.dispatchEvent(event);
@@ -231,7 +202,6 @@ document.addEventListener("DOMContentLoaded", () => {
231
202
  });
232
203
  }
233
204
 
234
- // Override fullscreenEnabled
235
205
  Object.defineProperty(document, "fullscreenEnabled", {
236
206
  get: () => true,
237
207
  configurable: true,
@@ -241,7 +211,6 @@ document.addEventListener("DOMContentLoaded", () => {
241
211
  configurable: true,
242
212
  });
243
213
 
244
- // Override fullscreenElement
245
214
  Object.defineProperty(document, "fullscreenElement", {
246
215
  get: () => fullscreenElement,
247
216
  configurable: true,
@@ -255,7 +224,6 @@ document.addEventListener("DOMContentLoaded", () => {
255
224
  configurable: true,
256
225
  });
257
226
 
258
- // Override requestFullscreen
259
227
  Element.prototype.requestFullscreen = function () {
260
228
  return enterFullscreen(this);
261
229
  };
@@ -266,12 +234,10 @@ document.addEventListener("DOMContentLoaded", () => {
266
234
  return enterFullscreen(this);
267
235
  };
268
236
 
269
- // Override exitFullscreen
270
237
  document.exitFullscreen = exitFullscreen;
271
238
  document.webkitExitFullscreen = exitFullscreen;
272
239
  document.webkitCancelFullScreen = exitFullscreen;
273
240
 
274
- // Handle Escape key
275
241
  document.addEventListener(
276
242
  "keydown",
277
243
  (e) => {
@@ -0,0 +1,22 @@
1
+ // Lightweight in-page toast used by Rust `show_toast` (download status, etc).
2
+ // Kept tiny and always loaded so the Rust side can rely on `window.pakeToast`.
3
+ document.addEventListener("DOMContentLoaded", () => {
4
+ function pakeToast(msg) {
5
+ const m = document.createElement("div");
6
+ m.innerHTML = msg;
7
+ m.style.cssText =
8
+ "max-width:60%;min-width: 80px;padding:0 12px;height: 32px;color: rgb(255, 255, 255);line-height: 32px;text-align: center;border-radius: 8px;position: fixed; bottom:24px;right: 28px;z-index: 999999;background: rgba(0, 0, 0,.8);font-size: 13px;";
9
+ document.body.appendChild(m);
10
+ setTimeout(function () {
11
+ const d = 0.5;
12
+ m.style.transition =
13
+ "transform " + d + "s ease-in, opacity " + d + "s ease-in";
14
+ m.style.opacity = "0";
15
+ setTimeout(function () {
16
+ document.body.removeChild(m);
17
+ }, d * 1000);
18
+ }, 3000);
19
+ }
20
+
21
+ window.pakeToast = pakeToast;
22
+ });
@@ -103,7 +103,7 @@ pub fn run_app() {
103
103
  }
104
104
  // --- Menu Construction End ---
105
105
 
106
- let window = set_window(app.app_handle(), &pake_config, &tauri_config);
106
+ let window = set_window(app.app_handle(), &pake_config, &tauri_config)?;
107
107
  set_system_tray(
108
108
  app.app_handle(),
109
109
  show_system_tray,
@@ -174,7 +174,10 @@ pub fn run_app() {
174
174
  }
175
175
  })
176
176
  .build(tauri::generate_context!())
177
- .expect("error while building tauri application")
177
+ .unwrap_or_else(|error| {
178
+ eprintln!("[Pake] Fatal error while building Tauri application: {error}");
179
+ std::process::exit(1);
180
+ })
178
181
  .run(|_app, _event| {
179
182
  // Handle macOS dock icon click to reopen hidden window
180
183
  #[cfg(target_os = "macos")]
@@ -23,25 +23,35 @@ pub fn get_pake_config() -> (PakeConfig, Config) {
23
23
  (pake_config, tauri_config)
24
24
  }
25
25
 
26
- pub fn get_data_dir(app: &AppHandle, package_name: String) -> PathBuf {
27
- {
28
- let data_dir = app
29
- .path()
30
- .config_dir()
31
- .expect("Failed to get data dirname")
32
- .join(package_name);
33
-
34
- if !data_dir.exists() {
35
- std::fs::create_dir(&data_dir)
36
- .unwrap_or_else(|_| panic!("Can't create dir {}", data_dir.display()));
37
- }
38
- data_dir
26
+ pub fn get_data_dir(app: &AppHandle, package_name: String) -> std::io::Result<PathBuf> {
27
+ let data_dir = app
28
+ .path()
29
+ .config_dir()
30
+ .map_err(|err| {
31
+ std::io::Error::new(
32
+ std::io::ErrorKind::NotFound,
33
+ format!("Failed to resolve config dir: {err}"),
34
+ )
35
+ })?
36
+ .join(package_name);
37
+
38
+ if !data_dir.exists() {
39
+ std::fs::create_dir_all(&data_dir).map_err(|err| {
40
+ std::io::Error::new(
41
+ err.kind(),
42
+ format!("Can't create dir {}: {err}", data_dir.display()),
43
+ )
44
+ })?;
39
45
  }
46
+
47
+ Ok(data_dir)
40
48
  }
41
49
 
42
50
  pub fn show_toast(window: &WebviewWindow, message: &str) {
43
51
  let script = format!(r#"pakeToast("{message}");"#);
44
- window.eval(&script).unwrap();
52
+ if let Err(error) = window.eval(&script) {
53
+ eprintln!("[Pake] Failed to show toast: {error}");
54
+ }
45
55
  }
46
56
 
47
57
  pub enum MessageType {
@@ -101,10 +111,16 @@ pub fn get_download_message_with_lang(
101
111
  .to_string()
102
112
  }
103
113
 
104
- // Check if the file exists, if it exists, add a number to file name
114
+ /// Check if the file exists. If it does, append `-N` to the stem until a free
115
+ /// path is found.
116
+ ///
117
+ /// Robustness notes:
118
+ /// - Files without an extension are handled (we keep them extensionless).
119
+ /// - If the numeric suffix would overflow `u32::MAX` we fall back to the
120
+ /// original file_path so the caller never enters an infinite loop on
121
+ /// pathologically large filenames (regression guard for #1183).
105
122
  pub fn check_file_or_append(file_path: &str) -> String {
106
123
  let mut new_path = PathBuf::from(file_path);
107
- let mut counter = 0;
108
124
 
109
125
  while new_path.exists() {
110
126
  let file_stem = new_path
@@ -116,16 +132,24 @@ pub fn check_file_or_append(file_path: &str) -> String {
116
132
  .map(|e| e.to_string_lossy().to_string());
117
133
  let parent_dir = new_path.parent().unwrap_or(Path::new(""));
118
134
 
119
- let new_file_stem = match file_stem.rfind('-') {
120
- Some(index) if file_stem[index + 1..].parse::<u32>().is_ok() => {
135
+ let parsed_suffix = file_stem.rfind('-').and_then(|index| {
136
+ file_stem[index + 1..]
137
+ .parse::<u32>()
138
+ .ok()
139
+ .map(|n| (index, n))
140
+ });
141
+
142
+ let new_file_stem = match parsed_suffix {
143
+ Some((index, current)) => {
144
+ let Some(next) = current.checked_add(1) else {
145
+ // u32::MAX collisions are a sign of something pathological;
146
+ // bail with the original path instead of looping forever.
147
+ return file_path.to_string();
148
+ };
121
149
  let base_name = &file_stem[..index];
122
- counter = file_stem[index + 1..].parse::<u32>().unwrap() + 1;
123
- format!("{base_name}-{counter}")
124
- }
125
- _ => {
126
- counter += 1;
127
- format!("{file_stem}-{counter}")
150
+ format!("{base_name}-{next}")
128
151
  }
152
+ None => format!("{file_stem}-1"),
129
153
  };
130
154
 
131
155
  new_path = match &extension {
@@ -136,3 +160,86 @@ pub fn check_file_or_append(file_path: &str) -> String {
136
160
 
137
161
  new_path.to_string_lossy().into_owned()
138
162
  }
163
+
164
+ #[cfg(test)]
165
+ mod tests {
166
+ use super::*;
167
+ use std::env;
168
+ use std::fs;
169
+ use std::path::PathBuf;
170
+
171
+ fn temp_path(name: &str) -> PathBuf {
172
+ let mut dir = env::temp_dir();
173
+ dir.push(format!(
174
+ "pake-util-test-{}-{}",
175
+ std::process::id(),
176
+ std::time::SystemTime::now()
177
+ .duration_since(std::time::UNIX_EPOCH)
178
+ .map(|d| d.as_nanos())
179
+ .unwrap_or(0)
180
+ ));
181
+ fs::create_dir_all(&dir).unwrap();
182
+ dir.push(name);
183
+ dir
184
+ }
185
+
186
+ #[test]
187
+ fn check_file_or_append_returns_input_when_missing() {
188
+ let path = temp_path("ghost.txt");
189
+ let resolved = check_file_or_append(path.to_str().unwrap());
190
+ assert_eq!(resolved, path.to_string_lossy());
191
+ let _ = fs::remove_dir_all(path.parent().unwrap());
192
+ }
193
+
194
+ #[test]
195
+ fn check_file_or_append_increments_suffix() {
196
+ let path = temp_path("dup.txt");
197
+ fs::write(&path, b"existing").unwrap();
198
+ let resolved = check_file_or_append(path.to_str().unwrap());
199
+ assert!(resolved.ends_with("dup-1.txt"), "got {resolved}");
200
+ let _ = fs::remove_dir_all(path.parent().unwrap());
201
+ }
202
+
203
+ #[test]
204
+ fn check_file_or_append_handles_files_without_extension() {
205
+ let path = temp_path("README");
206
+ fs::write(&path, b"existing").unwrap();
207
+ let resolved = check_file_or_append(path.to_str().unwrap());
208
+ assert!(resolved.ends_with("README-1"), "got {resolved}");
209
+ let _ = fs::remove_dir_all(path.parent().unwrap());
210
+ }
211
+
212
+ #[test]
213
+ fn check_file_or_append_does_not_panic_on_huge_suffix() {
214
+ let path = temp_path(&format!("huge-{}.txt", u32::MAX));
215
+ fs::write(&path, b"existing").unwrap();
216
+ let resolved = check_file_or_append(path.to_str().unwrap());
217
+ assert!(resolved.contains("huge-"));
218
+ let _ = fs::remove_dir_all(path.parent().unwrap());
219
+ }
220
+
221
+ #[test]
222
+ fn download_message_falls_back_to_english_for_unknown_locale() {
223
+ let msg = get_download_message_with_lang(MessageType::Start, Some("fr-FR".to_string()));
224
+ assert_eq!(msg, "Start downloading~");
225
+ }
226
+
227
+ #[test]
228
+ fn download_message_picks_chinese_for_zh_locales() {
229
+ for tag in ["zh", "zh-CN", "zh-TW", "en-CN", "en-HK"] {
230
+ let msg = get_download_message_with_lang(MessageType::Success, Some(tag.to_string()));
231
+ assert_eq!(
232
+ msg, "下载成功,已保存到下载目录~",
233
+ "expected Chinese for {tag}"
234
+ );
235
+ }
236
+ }
237
+
238
+ #[test]
239
+ fn download_message_failure_localized() {
240
+ let en = get_download_message_with_lang(MessageType::Failure, Some("en".into()));
241
+ let zh = get_download_message_with_lang(MessageType::Failure, Some("zh".into()));
242
+ assert!(en.contains("Download failed"));
243
+ assert!(zh.contains("下载失败"));
244
+ }
245
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "productName": "Weekly",
3
3
  "identifier": "com.pake.weekly",
4
- "version": "3.11.4",
4
+ "version": "3.11.5",
5
5
  "app": {
6
6
  "withGlobalTauri": true,
7
7
  "trayIcon": {