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/README.md +3 -3
- package/dist/cli.js +372 -238
- package/package.json +3 -3
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/src/app/setup.rs +39 -34
- package/src-tauri/src/app/window.rs +63 -17
- package/src-tauri/src/inject/{component.js → fullscreen.js} +7 -41
- package/src-tauri/src/inject/toast.js +22 -0
- package/src-tauri/src/lib.rs +8 -2
- package/src-tauri/src/util.rs +144 -29
- package/src-tauri/tauri.conf.json +1 -1
- package/dist/test-local.html +0 -1
- package/src-tauri/.cargo/config.toml +0 -10
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pake-cli",
|
|
3
|
-
"version": "3.11.
|
|
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": "
|
|
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",
|
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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(
|
|
54
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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/
|
|
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
|
-
//
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
});
|
package/src-tauri/src/lib.rs
CHANGED
|
@@ -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
|
-
.
|
|
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")]
|
package/src-tauri/src/util.rs
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
115
|
-
Some(index
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|