pake-cli 3.11.3 → 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.
package/package.json CHANGED
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "name": "pake-cli",
3
- "version": "3.11.3",
3
+ "version": "3.11.5",
4
4
  "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"
7
7
  },
8
8
  "packageManager": "pnpm@10.26.2",
9
9
  "bin": {
10
- "pake": "./dist/cli.js"
10
+ "pake": "dist/cli.js"
11
11
  },
12
12
  "repository": {
13
13
  "type": "git",
14
- "url": "https://github.com/tw93/pake.git"
14
+ "url": "git+https://github.com/tw93/pake.git"
15
15
  },
16
16
  "author": {
17
17
  "name": "Tw93",
@@ -2564,7 +2564,7 @@ dependencies = [
2564
2564
 
2565
2565
  [[package]]
2566
2566
  name = "pake"
2567
- version = "3.11.3"
2567
+ version = "3.11.5"
2568
2568
  dependencies = [
2569
2569
  "serde",
2570
2570
  "serde_json",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "pake"
3
- version = "3.11.3"
3
+ version = "3.11.5"
4
4
  description = "🤱🏻 Turn any webpage into a desktop app with Rust."
5
5
  authors = ["Tw93"]
6
6
  license = "MIT"
@@ -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
+ });
@@ -27,6 +27,9 @@ pub fn run_app() {
27
27
  if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() {
28
28
  std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
29
29
  }
30
+ if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() {
31
+ std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1");
32
+ }
30
33
  }
31
34
 
32
35
  let (pake_config, tauri_config) = get_pake_config();
@@ -100,7 +103,7 @@ pub fn run_app() {
100
103
  }
101
104
  // --- Menu Construction End ---
102
105
 
103
- let window = set_window(app.app_handle(), &pake_config, &tauri_config);
106
+ let window = set_window(app.app_handle(), &pake_config, &tauri_config)?;
104
107
  set_system_tray(
105
108
  app.app_handle(),
106
109
  show_system_tray,
@@ -171,7 +174,10 @@ pub fn run_app() {
171
174
  }
172
175
  })
173
176
  .build(tauri::generate_context!())
174
- .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
+ })
175
181
  .run(|_app, _event| {
176
182
  // Handle macOS dock icon click to reopen hidden window
177
183
  #[cfg(target_os = "macos")]
@@ -1,6 +1,6 @@
1
1
  use crate::app::config::PakeConfig;
2
2
  use std::env;
3
- use std::path::PathBuf;
3
+ use std::path::{Path, PathBuf};
4
4
  use tauri::{AppHandle, Config, Manager, WebviewWindow};
5
5
 
6
6
  pub fn get_pake_config() -> (PakeConfig, Config) {
@@ -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,30 +111,135 @@ 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
- let file_stem = new_path.file_stem().unwrap().to_string_lossy().to_string();
111
- let extension = new_path.extension().unwrap().to_string_lossy().to_string();
112
- let parent_dir = new_path.parent().unwrap();
126
+ let file_stem = new_path
127
+ .file_stem()
128
+ .map(|s| s.to_string_lossy().to_string())
129
+ .unwrap_or_default();
130
+ let extension = new_path
131
+ .extension()
132
+ .map(|e| e.to_string_lossy().to_string());
133
+ let parent_dir = new_path.parent().unwrap_or(Path::new(""));
134
+
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
+ });
113
141
 
114
- let new_file_stem = match file_stem.rfind('-') {
115
- Some(index) if file_stem[index + 1..].parse::<u32>().is_ok() => {
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
+ };
116
149
  let base_name = &file_stem[..index];
117
- counter = file_stem[index + 1..].parse::<u32>().unwrap() + 1;
118
- format!("{base_name}-{counter}")
119
- }
120
- _ => {
121
- counter += 1;
122
- format!("{file_stem}-{counter}")
150
+ format!("{base_name}-{next}")
123
151
  }
152
+ None => format!("{file_stem}-1"),
124
153
  };
125
154
 
126
- new_path = parent_dir.join(format!("{new_file_stem}.{extension}"));
155
+ new_path = match &extension {
156
+ Some(ext) => parent_dir.join(format!("{new_file_stem}.{ext}")),
157
+ None => parent_dir.join(new_file_stem),
158
+ };
127
159
  }
128
160
 
129
161
  new_path.to_string_lossy().into_owned()
130
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
+ }