pake-cli 3.16.0 → 3.16.2
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 +1 -1
- package/dist/cli.js +27 -3
- package/llms.txt +0 -1
- package/package.json +1 -2
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/pake.json +1 -0
- package/src-tauri/src/app/config.rs +2 -0
- package/src-tauri/src/app/invoke.rs +28 -10
- package/src-tauri/src/app/window.rs +61 -10
- package/src-tauri/src/inject/auth.js +16 -25
- package/src-tauri/src/inject/event.js +18 -2
- package/src-tauri/src/inject/frame_links.js +7 -2
- package/src-tauri/src/inject/link_policy.js +30 -0
- package/src-tauri/src/util.rs +106 -2
- package/src-tauri/tauri.conf.json +1 -1
package/README.md
CHANGED
|
@@ -192,7 +192,7 @@ pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/week
|
|
|
192
192
|
|
|
193
193
|
First-time packaging requires environment setup and may be slower, subsequent builds are fast. For complete parameter documentation, see [CLI Usage Guide](docs/cli-usage.md). Don't want to use CLI? Try [GitHub Actions Online Building](docs/github-actions-usage.md).
|
|
194
194
|
|
|
195
|
-
Using Pake from a script or AI agent? Pass `--json` for machine-readable results, describe apps declaratively with `--config app.json` ([schema](schema/pake.schema.json)), and package local build output directly with `pake ./dist --name MyTool`. See [llms.txt](llms.txt) for the full agent contract
|
|
195
|
+
Using Pake from a script or AI agent? Pass `--json` for machine-readable results, describe apps declaratively with `--config app.json` ([schema](schema/pake.schema.json)), and package local build output directly with `pake ./dist --name MyTool`. See [llms.txt](llms.txt) for the full agent contract. Claude Code users can install the official skill with `/plugin marketplace add tw93/Pake` and `/plugin install pake@pake`.
|
|
196
196
|
|
|
197
197
|
Copy this to your AI agent to get started:
|
|
198
198
|
|
package/dist/cli.js
CHANGED
|
@@ -19,9 +19,8 @@ import * as psl from 'psl';
|
|
|
19
19
|
import { InvalidArgumentError, program as program$1, Option } from 'commander';
|
|
20
20
|
|
|
21
21
|
var name = "pake-cli";
|
|
22
|
-
var version = "3.16.
|
|
22
|
+
var version = "3.16.2";
|
|
23
23
|
var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。";
|
|
24
|
-
var homepage = "https://faberon.io/projects/pake";
|
|
25
24
|
var engines = {
|
|
26
25
|
node: ">=20.9.0"
|
|
27
26
|
};
|
|
@@ -122,7 +121,6 @@ var packageJson = {
|
|
|
122
121
|
name: name,
|
|
123
122
|
version: version,
|
|
124
123
|
description: description,
|
|
125
|
-
homepage: homepage,
|
|
126
124
|
engines: engines,
|
|
127
125
|
packageManager: packageManager,
|
|
128
126
|
bin: bin,
|
|
@@ -1213,6 +1211,7 @@ async function injectCustomCode(options, tauriConf) {
|
|
|
1213
1211
|
await fsExtra.writeFile(injectFilePath, '');
|
|
1214
1212
|
}
|
|
1215
1213
|
tauriConf.pake.proxy_url = proxyUrl || '';
|
|
1214
|
+
tauriConf.pake.download_dir = options.downloadDir || '';
|
|
1216
1215
|
tauriConf.pake.basic_auth = basicAuth;
|
|
1217
1216
|
tauriConf.pake.multi_instance = multiInstance;
|
|
1218
1217
|
tauriConf.pake.multi_window = multiWindow;
|
|
@@ -3370,6 +3369,7 @@ const DEFAULT_PAKE_OPTIONS = {
|
|
|
3370
3369
|
useLocalFile: false,
|
|
3371
3370
|
systemTrayIcon: '',
|
|
3372
3371
|
proxyUrl: '',
|
|
3372
|
+
downloadDir: '',
|
|
3373
3373
|
basicAuth: false,
|
|
3374
3374
|
debug: false,
|
|
3375
3375
|
json: false,
|
|
@@ -3434,6 +3434,28 @@ function validateUrlInput(url) {
|
|
|
3434
3434
|
}
|
|
3435
3435
|
return url;
|
|
3436
3436
|
}
|
|
3437
|
+
function validateDownloadDirInput(value) {
|
|
3438
|
+
if (value === '')
|
|
3439
|
+
return value;
|
|
3440
|
+
const homeRelative = value.startsWith('~/') ? value.slice(2) : null;
|
|
3441
|
+
const invalidHomePath = homeRelative !== null &&
|
|
3442
|
+
(path.isAbsolute(homeRelative) ||
|
|
3443
|
+
(process.platform === 'win32' &&
|
|
3444
|
+
/^(?:[a-zA-Z]:|[\\/])/.test(homeRelative)));
|
|
3445
|
+
if (invalidHomePath ||
|
|
3446
|
+
value.includes('\0') ||
|
|
3447
|
+
!(path.isAbsolute(value) || value === '~' || value.startsWith('~/')) ||
|
|
3448
|
+
(process.platform === 'win32' &&
|
|
3449
|
+
value !== '~' &&
|
|
3450
|
+
!value.startsWith('~/') &&
|
|
3451
|
+
!/^(?:[a-zA-Z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(value))) {
|
|
3452
|
+
throw new PakeError('Invalid download directory.', {
|
|
3453
|
+
code: 'INVALID_INPUT',
|
|
3454
|
+
hint: 'Use an absolute path or a quoted ~/path; relative paths are not supported.',
|
|
3455
|
+
});
|
|
3456
|
+
}
|
|
3457
|
+
return value;
|
|
3458
|
+
}
|
|
3437
3459
|
|
|
3438
3460
|
function getCliProgram() {
|
|
3439
3461
|
const { green, yellow } = chalk;
|
|
@@ -3470,6 +3492,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
3470
3492
|
// If previous values exist (from multiple --inject options), merge them
|
|
3471
3493
|
return previous ? [...previous, ...files] : files;
|
|
3472
3494
|
}, DEFAULT_PAKE_OPTIONS.inject)
|
|
3495
|
+
.option('--download-dir <path>', 'App download directory (absolute path or ~/path; default: system Downloads)', DEFAULT_PAKE_OPTIONS.downloadDir)
|
|
3473
3496
|
.option('--debug', 'Debug build and more output', DEFAULT_PAKE_OPTIONS.debug)
|
|
3474
3497
|
.option('--json', 'Machine-readable output: logs to stderr, one JSON result on stdout', DEFAULT_PAKE_OPTIONS.json)
|
|
3475
3498
|
.option('--config <path>', 'Load options from a JSON config file (fields mirror CLI options, see schema/pake.schema.json)')
|
|
@@ -3804,6 +3827,7 @@ program.action(async (urlArg, options) => {
|
|
|
3804
3827
|
}
|
|
3805
3828
|
}
|
|
3806
3829
|
}
|
|
3830
|
+
validateDownloadDirInput(options.downloadDir);
|
|
3807
3831
|
if (!url) {
|
|
3808
3832
|
if (jsonMode) {
|
|
3809
3833
|
throw new PakeError('No URL or local path to package.', {
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
> Pake turns any webpage or local web build into a lightweight desktop app, typically under 10 MB on disk, for macOS, Windows, and Linux. It uses Tauri (Rust) and the system WebView, and one CLI command packages a URL, a local HTML file, or a static directory into a native installer.
|
|
4
4
|
|
|
5
|
-
Official product page: https://faberon.io/projects/pake
|
|
6
5
|
Source code: https://github.com/tw93/Pake
|
|
7
6
|
npm package: https://www.npmjs.com/package/pake-cli
|
|
8
7
|
Author: Tw93, https://tw93.fun/
|
package/package.json
CHANGED
package/src-tauri/Cargo.lock
CHANGED
package/src-tauri/Cargo.toml
CHANGED
package/src-tauri/pake.json
CHANGED
|
@@ -83,6 +83,8 @@ pub struct PakeConfig {
|
|
|
83
83
|
pub system_tray: FunctionON,
|
|
84
84
|
pub system_tray_path: String,
|
|
85
85
|
pub proxy_url: String,
|
|
86
|
+
#[serde(default)]
|
|
87
|
+
pub download_dir: String,
|
|
86
88
|
/// Prompt for HTTP Basic credentials at runtime on macOS. WKWebView does
|
|
87
89
|
/// not provide its own 401 login dialog, while Windows and Linux WebViews
|
|
88
90
|
/// handle this flow natively.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use crate::app::navigation::{history_step, reload_window};
|
|
2
2
|
use crate::util::{
|
|
3
|
-
check_file_or_append,
|
|
4
|
-
MessageType,
|
|
3
|
+
check_file_or_append, get_download_dir, get_download_message_with_lang,
|
|
4
|
+
sanitize_download_filename, show_toast, MessageType,
|
|
5
5
|
};
|
|
6
6
|
use std::fs::File;
|
|
7
7
|
use std::io::Write;
|
|
@@ -113,10 +113,12 @@ pub async fn download_file(
|
|
|
113
113
|
&get_download_message_with_lang(MessageType::Start, params.language.clone()),
|
|
114
114
|
);
|
|
115
115
|
|
|
116
|
-
let download_dir = app
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
let download_dir = get_download_dir(&app).inspect_err(|_| {
|
|
117
|
+
show_toast(
|
|
118
|
+
&window,
|
|
119
|
+
&get_download_message_with_lang(MessageType::DirectoryFailure, params.language.clone()),
|
|
120
|
+
);
|
|
121
|
+
})?;
|
|
120
122
|
|
|
121
123
|
let output_path = download_dir.join(sanitize_download_filename(¶ms.filename));
|
|
122
124
|
|
|
@@ -149,16 +151,32 @@ pub async fn download_file(
|
|
|
149
151
|
return Err(format!("Download failed with HTTP status {}", res.status()));
|
|
150
152
|
}
|
|
151
153
|
|
|
152
|
-
let mut file =
|
|
153
|
-
|
|
154
|
+
let mut file = File::create(&file_path).map_err(|e| {
|
|
155
|
+
show_toast(
|
|
156
|
+
&window,
|
|
157
|
+
&get_download_message_with_lang(
|
|
158
|
+
MessageType::DirectoryFailure,
|
|
159
|
+
params.language.clone(),
|
|
160
|
+
),
|
|
161
|
+
);
|
|
162
|
+
format!("Failed to create file: {e}")
|
|
163
|
+
})?;
|
|
154
164
|
|
|
155
165
|
while let Some(chunk) = res
|
|
156
166
|
.chunk()
|
|
157
167
|
.await
|
|
158
168
|
.map_err(|e| format!("Failed to get chunk: {}", e))?
|
|
159
169
|
{
|
|
160
|
-
file.write_all(&chunk)
|
|
161
|
-
|
|
170
|
+
file.write_all(&chunk).map_err(|e| {
|
|
171
|
+
show_toast(
|
|
172
|
+
&window,
|
|
173
|
+
&get_download_message_with_lang(
|
|
174
|
+
MessageType::DirectoryFailure,
|
|
175
|
+
params.language.clone(),
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
format!("Failed to write chunk: {e}")
|
|
179
|
+
})?;
|
|
162
180
|
}
|
|
163
181
|
|
|
164
182
|
show_toast(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use crate::app::config::PakeConfig;
|
|
2
2
|
use crate::util::{
|
|
3
|
-
check_file_or_append, get_data_dir,
|
|
4
|
-
show_toast, MessageType,
|
|
3
|
+
check_file_or_append, get_data_dir, get_download_dir, get_download_message_with_lang,
|
|
4
|
+
sanitize_download_filename, show_toast, MessageType,
|
|
5
5
|
};
|
|
6
6
|
#[cfg(target_os = "macos")]
|
|
7
7
|
use dispatch::Queue;
|
|
@@ -179,6 +179,31 @@ struct WindowBuildOptions<'a> {
|
|
|
179
179
|
new_window_features: Option<NewWindowFeatures>,
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
fn is_blank_popup_url(url: &Url) -> bool {
|
|
183
|
+
url.scheme() == "about" && url.path() == "blank"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#[cfg(test)]
|
|
187
|
+
mod popup_tests {
|
|
188
|
+
use super::is_blank_popup_url;
|
|
189
|
+
use tauri::Url;
|
|
190
|
+
|
|
191
|
+
#[test]
|
|
192
|
+
fn only_blank_documents_get_the_default_popup_exception() {
|
|
193
|
+
for input in ["about:blank", "about:blank#download", "about:blank?pending"] {
|
|
194
|
+
assert!(is_blank_popup_url(&Url::parse(input).unwrap()));
|
|
195
|
+
}
|
|
196
|
+
for input in [
|
|
197
|
+
"https://example.com/",
|
|
198
|
+
"about:srcdoc",
|
|
199
|
+
"about:blankness",
|
|
200
|
+
"file:///tmp/file",
|
|
201
|
+
] {
|
|
202
|
+
assert!(!is_blank_popup_url(&Url::parse(input).unwrap()));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
182
207
|
fn open_requested_window(
|
|
183
208
|
app: &AppHandle,
|
|
184
209
|
config: &PakeConfig,
|
|
@@ -472,11 +497,17 @@ fn build_window(
|
|
|
472
497
|
window_builder = window_builder.disable_drag_drop_handler();
|
|
473
498
|
}
|
|
474
499
|
|
|
475
|
-
|
|
500
|
+
{
|
|
501
|
+
let allow_new_window = window_config.new_window;
|
|
476
502
|
let app_handle = app.clone();
|
|
477
503
|
let popup_config = config.clone();
|
|
478
504
|
let popup_tauri_config = tauri_config.clone();
|
|
479
505
|
window_builder = window_builder.on_new_window(move |target_url, features| {
|
|
506
|
+
// Even without --new-window, two-stage popups need a real webview
|
|
507
|
+
// with our download delegate, never a proxy for the main window.
|
|
508
|
+
if !allow_new_window && !is_blank_popup_url(&target_url) {
|
|
509
|
+
return NewWindowResponse::Deny;
|
|
510
|
+
}
|
|
480
511
|
match open_requested_window(
|
|
481
512
|
&app_handle,
|
|
482
513
|
&popup_config,
|
|
@@ -494,9 +525,8 @@ fn build_window(
|
|
|
494
525
|
}
|
|
495
526
|
|
|
496
527
|
// Add initialization scripts. Order matters: pakeConfig must land before
|
|
497
|
-
// any script that reads it
|
|
498
|
-
//
|
|
499
|
-
// calls show_toast().
|
|
528
|
+
// any script that reads it, and toast must register `window.pakeToast`
|
|
529
|
+
// before Rust code calls show_toast().
|
|
500
530
|
window_builder = window_builder
|
|
501
531
|
.initialization_script_for_all_frames(&config_script)
|
|
502
532
|
.initialization_script_for_all_frames(include_str!("../inject/link_policy.js"))
|
|
@@ -513,9 +543,17 @@ fn build_window(
|
|
|
513
543
|
window_builder = window_builder.initialization_script(include_str!("../inject/find.js"));
|
|
514
544
|
}
|
|
515
545
|
|
|
546
|
+
window_builder = window_builder.initialization_script(include_str!("../inject/toast.js"));
|
|
547
|
+
|
|
548
|
+
// WebView2's native Fullscreen API already drives Tauri's window fullscreen.
|
|
549
|
+
// Keep its top-layer layout and player controls instead of overriding the API.
|
|
550
|
+
#[cfg(not(target_os = "windows"))]
|
|
551
|
+
{
|
|
552
|
+
window_builder =
|
|
553
|
+
window_builder.initialization_script(include_str!("../inject/fullscreen.js"));
|
|
554
|
+
}
|
|
555
|
+
|
|
516
556
|
window_builder = window_builder
|
|
517
|
-
.initialization_script(include_str!("../inject/toast.js"))
|
|
518
|
-
.initialization_script(include_str!("../inject/fullscreen.js"))
|
|
519
557
|
.initialization_script(include_str!("../inject/event.js"))
|
|
520
558
|
.initialization_script(include_str!("../inject/style.js"))
|
|
521
559
|
.initialization_script(include_str!("../inject/theme_refresh.js"))
|
|
@@ -652,7 +690,7 @@ fn build_window(
|
|
|
652
690
|
}
|
|
653
691
|
|
|
654
692
|
// Capture webview-initiated downloads (blob:, data:, Content-Disposition,
|
|
655
|
-
// etc.) and write them to the
|
|
693
|
+
// etc.) and write them to the configured download folder. This is essential for
|
|
656
694
|
// sites with a strict Content-Security-Policy (e.g. Gemini): their
|
|
657
695
|
// `connect-src` blocks Tauri's IPC origin, so downloads cannot be routed
|
|
658
696
|
// through the JS bridge, and downloads triggered from a sandboxed iframe
|
|
@@ -662,7 +700,7 @@ fn build_window(
|
|
|
662
700
|
let download_handle = app.clone();
|
|
663
701
|
window_builder = window_builder.on_download(move |webview, event| match event {
|
|
664
702
|
DownloadEvent::Requested { url, destination } => {
|
|
665
|
-
match download_handle
|
|
703
|
+
match get_download_dir(&download_handle) {
|
|
666
704
|
Ok(download_dir) => {
|
|
667
705
|
let filename = destination
|
|
668
706
|
.file_name()
|
|
@@ -679,10 +717,23 @@ fn build_window(
|
|
|
679
717
|
let target = download_dir.join(sanitize_download_filename(&filename));
|
|
680
718
|
if let Some(path_str) = target.to_str() {
|
|
681
719
|
*destination = PathBuf::from(check_file_or_append(path_str));
|
|
720
|
+
} else {
|
|
721
|
+
eprintln!("[Pake] Download destination is not valid UTF-8");
|
|
722
|
+
return false;
|
|
682
723
|
}
|
|
683
724
|
}
|
|
684
725
|
Err(error) => {
|
|
685
726
|
eprintln!("[Pake] Failed to resolve download dir: {error}");
|
|
727
|
+
if let Some(window) = download_handle.get_webview_window(webview.label()) {
|
|
728
|
+
show_toast(
|
|
729
|
+
&window,
|
|
730
|
+
&get_download_message_with_lang(
|
|
731
|
+
MessageType::DirectoryFailure,
|
|
732
|
+
None,
|
|
733
|
+
),
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
return false;
|
|
686
737
|
}
|
|
687
738
|
}
|
|
688
739
|
true
|
|
@@ -6,26 +6,20 @@ function matchesAuthUrl(url, baseUrl = window.location.href) {
|
|
|
6
6
|
const urlObj = new URL(url, baseUrl);
|
|
7
7
|
const hostname = urlObj.hostname.toLowerCase();
|
|
8
8
|
const pathname = urlObj.pathname.toLowerCase();
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/appleid\.apple\.com/,
|
|
20
|
-
/\/oauth\//,
|
|
21
|
-
/\/auth\//,
|
|
22
|
-
/\/authorize/,
|
|
23
|
-
/\/login\/oauth/,
|
|
24
|
-
/\/signin/,
|
|
25
|
-
/\/login/,
|
|
26
|
-
/servicelogin/,
|
|
27
|
-
/\/o\/oauth2/,
|
|
9
|
+
// Host patterns never inspect user-controlled query strings or fragments.
|
|
10
|
+
const oauthHostPatterns = [
|
|
11
|
+
/^accounts\.google\.(?:com|[a-z]{2}|com\.[a-z]{2}|co\.[a-z]{2})$/,
|
|
12
|
+
/^login\.microsoftonline\.com$/,
|
|
13
|
+
/^appleid\.apple\.com$/,
|
|
14
|
+
];
|
|
15
|
+
// Match complete path segments, including nested identity-provider routes.
|
|
16
|
+
// /login-tips is content; /tenant/login remains an authentication endpoint.
|
|
17
|
+
const oauthPathPatterns = [
|
|
18
|
+
/\/(?:oauth2?|auth|authorize|signin|login|servicelogin)(?:\/|$)/,
|
|
28
19
|
];
|
|
20
|
+
const providerEndpoint =
|
|
21
|
+
/^(?:www\.)?facebook\.com$/.test(hostname) &&
|
|
22
|
+
/^\/(?:[^/]+\/)?dialog(?:\/|$)/.test(pathname);
|
|
29
23
|
|
|
30
24
|
// Enterprise SSO. Match identity providers on the host, and SAML/SSO/ADFS on
|
|
31
25
|
// the pathname with endpoint-shaped patterns only, so ordinary pages such as
|
|
@@ -39,12 +33,9 @@ function matchesAuthUrl(url, baseUrl = window.location.href) {
|
|
|
39
33
|
];
|
|
40
34
|
|
|
41
35
|
const isMatch =
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
pattern.test(pathname) ||
|
|
46
|
-
pattern.test(fullUrl),
|
|
47
|
-
) ||
|
|
36
|
+
oauthHostPatterns.some((pattern) => pattern.test(hostname)) ||
|
|
37
|
+
oauthPathPatterns.some((pattern) => pattern.test(pathname)) ||
|
|
38
|
+
providerEndpoint ||
|
|
48
39
|
enterpriseHostPatterns.some((pattern) => pattern.test(hostname)) ||
|
|
49
40
|
enterprisePathPatterns.some((pattern) => pattern.test(pathname));
|
|
50
41
|
|
|
@@ -101,7 +101,12 @@ function handleWebShortcut(event) {
|
|
|
101
101
|
function toggleNativeFullscreen(appWindow) {
|
|
102
102
|
appWindow
|
|
103
103
|
.isFullscreen()
|
|
104
|
-
.then((fullscreen) =>
|
|
104
|
+
.then((fullscreen) => {
|
|
105
|
+
if (document.fullscreenElement && document.exitFullscreen) {
|
|
106
|
+
return document.exitFullscreen();
|
|
107
|
+
}
|
|
108
|
+
return appWindow.setFullscreen(!fullscreen);
|
|
109
|
+
})
|
|
105
110
|
.catch((error) => {
|
|
106
111
|
console.warn("[Pake] Failed to toggle native fullscreen:", error);
|
|
107
112
|
});
|
|
@@ -736,6 +741,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
736
741
|
|
|
737
742
|
const target = anchorElement.target;
|
|
738
743
|
const hrefUrl = new URL(anchorElement.href);
|
|
744
|
+
if (["mailto:", "tel:"].includes(hrefUrl.protocol)) return;
|
|
739
745
|
const absoluteUrl = hrefUrl.href;
|
|
740
746
|
let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl);
|
|
741
747
|
|
|
@@ -848,13 +854,23 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
848
854
|
}
|
|
849
855
|
};
|
|
850
856
|
|
|
851
|
-
|
|
857
|
+
window.addEventListener("click", (event) =>
|
|
858
|
+
handleProtocolLinkClick(event, handleExternalLink),
|
|
859
|
+
);
|
|
860
|
+
|
|
861
|
+
// Capture web links before site popup handlers route them into the app.
|
|
852
862
|
document.addEventListener("click", detectAnchorElementClick, true);
|
|
853
863
|
|
|
854
864
|
// Rewrite the window.open function.
|
|
855
865
|
const originalWindowOpen = window.open;
|
|
856
866
|
window.open = function (url, name, specs) {
|
|
867
|
+
url = normalizePopupUrl(url);
|
|
857
868
|
const normalizedUrl = normalizeAnchorHref(url);
|
|
869
|
+
// A two-stage popup needs its own WindowProxy. Returning the main window
|
|
870
|
+
// makes a later popup.location assignment navigate away from the app.
|
|
871
|
+
if (/^about:blank(?:[?#]|$)/i.test(normalizedUrl)) {
|
|
872
|
+
return originalWindowOpen.call(window, url, name, specs);
|
|
873
|
+
}
|
|
858
874
|
if (normalizedUrl.startsWith("#")) {
|
|
859
875
|
window.location.href = new URL(normalizedUrl, window.location.href).href;
|
|
860
876
|
return window;
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
(function () {
|
|
5
5
|
if (window === window.top) return;
|
|
6
6
|
const config = window.pakeConfig || {};
|
|
7
|
-
if (config.force_internal_navigation === true) return;
|
|
8
7
|
const isInternalUrl = createInternalUrlMatcher(config.internal_url_regex);
|
|
9
8
|
const originalOpen = window.open;
|
|
10
9
|
|
|
11
10
|
function externalDestination(rawUrl, name) {
|
|
11
|
+
if (config.force_internal_navigation === true) return null;
|
|
12
12
|
if (
|
|
13
13
|
typeof rawUrl !== "string" ||
|
|
14
14
|
!rawUrl.trim() ||
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
window.open = function (url, name, specs) {
|
|
36
|
+
url = normalizePopupUrl(url);
|
|
36
37
|
// Named targets can navigate an existing frame and must retain its proxy.
|
|
37
38
|
if (name && String(name).toLowerCase() !== "_blank") {
|
|
38
39
|
return originalOpen.call(window, url, name, specs);
|
|
@@ -43,6 +44,10 @@
|
|
|
43
44
|
return null;
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
window.addEventListener("click", (event) =>
|
|
48
|
+
handleProtocolLinkClick(event, forward),
|
|
49
|
+
);
|
|
50
|
+
|
|
46
51
|
document.addEventListener(
|
|
47
52
|
"click",
|
|
48
53
|
(event) => {
|
|
@@ -52,7 +57,7 @@
|
|
|
52
57
|
if (anchor.target && !["_blank", "_new", "_self"].includes(anchor.target))
|
|
53
58
|
return;
|
|
54
59
|
const external = externalDestination(anchor.getAttribute("href"), "");
|
|
55
|
-
if (!external) return;
|
|
60
|
+
if (!external || /^(mailto|tel):/.test(external)) return;
|
|
56
61
|
event.preventDefault();
|
|
57
62
|
event.stopImmediatePropagation();
|
|
58
63
|
forward(external);
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
// Shared by the main page and the lightweight subframe bridge.
|
|
2
|
+
// Tauri rejects WebKit's empty popup URL before calling our native handler.
|
|
3
|
+
function normalizePopupUrl(url) {
|
|
4
|
+
return url === undefined || (typeof url === "string" && !url.trim())
|
|
5
|
+
? "about:blank"
|
|
6
|
+
: url;
|
|
7
|
+
}
|
|
8
|
+
|
|
2
9
|
// This list intentionally preserves Pake's existing domain routing policy.
|
|
3
10
|
const MULTI_PART_PUBLIC_SUFFIXES = [
|
|
4
11
|
"co.uk",
|
|
@@ -58,3 +65,26 @@ function createInternalUrlMatcher(pattern) {
|
|
|
58
65
|
}
|
|
59
66
|
};
|
|
60
67
|
}
|
|
68
|
+
|
|
69
|
+
// Protocol links may represent a web app's compose/contact menu. Let target
|
|
70
|
+
// and document handlers cancel them before falling back to the system app.
|
|
71
|
+
function handleProtocolLinkClick(event, openExternal) {
|
|
72
|
+
if (event.defaultPrevented) return;
|
|
73
|
+
const anchor = event.target?.closest?.("a[href]");
|
|
74
|
+
if (!anchor || anchor.hasAttribute("download")) return;
|
|
75
|
+
if (anchor.target && !["_blank", "_new", "_self"].includes(anchor.target))
|
|
76
|
+
return;
|
|
77
|
+
if (typeof anchor.href !== "string" || !/^(mailto|tel):/i.test(anchor.href))
|
|
78
|
+
return;
|
|
79
|
+
const url = new URL(anchor.href);
|
|
80
|
+
if (window.pakeConfig?.force_internal_navigation) return;
|
|
81
|
+
if (
|
|
82
|
+
createInternalUrlMatcher(window.pakeConfig?.internal_url_regex)(
|
|
83
|
+
url.href,
|
|
84
|
+
window.pakeConfig?.url || window.location.href,
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return;
|
|
88
|
+
event.preventDefault();
|
|
89
|
+
openExternal(url.href);
|
|
90
|
+
}
|
package/src-tauri/src/util.rs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use crate::app::config::PakeConfig;
|
|
2
|
+
use crate::app::window::MultiWindowState;
|
|
2
3
|
use std::env;
|
|
3
4
|
use std::path::{Path, PathBuf};
|
|
4
5
|
use tauri::{AppHandle, Config, Manager, WebviewWindow};
|
|
@@ -47,6 +48,55 @@ pub fn get_data_dir(app: &AppHandle, package_name: String) -> std::io::Result<Pa
|
|
|
47
48
|
Ok(data_dir)
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
/// Both native and IPC downloads use the trusted, packaged configuration.
|
|
52
|
+
pub fn get_download_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
|
53
|
+
let state = app
|
|
54
|
+
.try_state::<MultiWindowState>()
|
|
55
|
+
.ok_or("Missing app download configuration")?;
|
|
56
|
+
let configured = &state.pake_config.download_dir;
|
|
57
|
+
let directory = if configured.is_empty() {
|
|
58
|
+
app.path().download_dir().map_err(|e| e.to_string())?
|
|
59
|
+
} else {
|
|
60
|
+
let home = if configured == "~" || configured.starts_with("~/") {
|
|
61
|
+
Some(app.path().home_dir().map_err(|e| e.to_string())?)
|
|
62
|
+
} else {
|
|
63
|
+
None
|
|
64
|
+
};
|
|
65
|
+
expand_download_dir(configured, home.as_deref())?
|
|
66
|
+
};
|
|
67
|
+
std::fs::create_dir_all(&directory).map_err(|e| {
|
|
68
|
+
format!(
|
|
69
|
+
"Cannot create download directory {}: {e}",
|
|
70
|
+
directory.display()
|
|
71
|
+
)
|
|
72
|
+
})?;
|
|
73
|
+
Ok(directory)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
fn expand_download_dir(configured: &str, home: Option<&Path>) -> Result<PathBuf, String> {
|
|
77
|
+
let directory = if configured == "~" || configured.starts_with("~/") {
|
|
78
|
+
let home = home.ok_or("Cannot resolve the app user's home directory")?;
|
|
79
|
+
let relative = Path::new(configured.strip_prefix("~/").unwrap_or(""));
|
|
80
|
+
if relative.has_root()
|
|
81
|
+
|| matches!(
|
|
82
|
+
relative.components().next(),
|
|
83
|
+
Some(std::path::Component::Prefix(_))
|
|
84
|
+
)
|
|
85
|
+
{
|
|
86
|
+
return Err(
|
|
87
|
+
"Download directory after ~/ must be relative to the home directory".into(),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
home.join(relative)
|
|
91
|
+
} else {
|
|
92
|
+
PathBuf::from(configured)
|
|
93
|
+
};
|
|
94
|
+
if !directory.is_absolute() || configured.contains('\0') {
|
|
95
|
+
return Err("Download directory must be an absolute path or ~/path".into());
|
|
96
|
+
}
|
|
97
|
+
Ok(directory)
|
|
98
|
+
}
|
|
99
|
+
|
|
50
100
|
pub fn show_toast(window: &WebviewWindow, message: &str) {
|
|
51
101
|
let script = format!(r#"pakeToast("{message}");"#);
|
|
52
102
|
if let Err(error) = window.eval(&script) {
|
|
@@ -58,6 +108,7 @@ pub enum MessageType {
|
|
|
58
108
|
Start,
|
|
59
109
|
Success,
|
|
60
110
|
Failure,
|
|
111
|
+
DirectoryFailure,
|
|
61
112
|
}
|
|
62
113
|
|
|
63
114
|
pub fn get_download_message_with_lang(
|
|
@@ -70,8 +121,8 @@ pub fn get_download_message_with_lang(
|
|
|
70
121
|
let default_success_message = "Download successful, saved to download directory~";
|
|
71
122
|
let chinese_success_message = "下载成功,已保存到下载目录~";
|
|
72
123
|
|
|
73
|
-
let default_failure_message = "Download failed
|
|
74
|
-
let chinese_failure_message = "
|
|
124
|
+
let default_failure_message = "Download failed~";
|
|
125
|
+
let chinese_failure_message = "下载失败~";
|
|
75
126
|
|
|
76
127
|
let is_chinese = language
|
|
77
128
|
.as_ref()
|
|
@@ -100,12 +151,14 @@ pub fn get_download_message_with_lang(
|
|
|
100
151
|
MessageType::Start => chinese_start_message,
|
|
101
152
|
MessageType::Success => chinese_success_message,
|
|
102
153
|
MessageType::Failure => chinese_failure_message,
|
|
154
|
+
MessageType::DirectoryFailure => "无法保存到下载目录~",
|
|
103
155
|
}
|
|
104
156
|
} else {
|
|
105
157
|
match message_type {
|
|
106
158
|
MessageType::Start => default_start_message,
|
|
107
159
|
MessageType::Success => default_success_message,
|
|
108
160
|
MessageType::Failure => default_failure_message,
|
|
161
|
+
MessageType::DirectoryFailure => "Cannot save to the download directory~",
|
|
109
162
|
}
|
|
110
163
|
}
|
|
111
164
|
.to_string()
|
|
@@ -204,6 +257,57 @@ mod tests {
|
|
|
204
257
|
dir
|
|
205
258
|
}
|
|
206
259
|
|
|
260
|
+
#[test]
|
|
261
|
+
fn expand_download_dir_preserves_absolute_paths_without_home() {
|
|
262
|
+
let absolute = temp_path("My Downloads");
|
|
263
|
+
assert_eq!(
|
|
264
|
+
expand_download_dir(absolute.to_str().unwrap(), None).unwrap(),
|
|
265
|
+
absolute
|
|
266
|
+
);
|
|
267
|
+
fs::remove_dir_all(absolute.parent().unwrap()).unwrap();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[test]
|
|
271
|
+
fn expand_download_dir_uses_runtime_home() {
|
|
272
|
+
let home = temp_path("home");
|
|
273
|
+
assert_eq!(
|
|
274
|
+
expand_download_dir("~/Documents/My App", Some(&home)).unwrap(),
|
|
275
|
+
home.join("Documents/My App")
|
|
276
|
+
);
|
|
277
|
+
assert_eq!(expand_download_dir("~", Some(&home)).unwrap(), home);
|
|
278
|
+
assert!(expand_download_dir("~/Downloads", None).is_err());
|
|
279
|
+
fs::remove_dir_all(home.parent().unwrap()).unwrap();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#[test]
|
|
283
|
+
fn expand_download_dir_rejects_home_replacement() {
|
|
284
|
+
let home = temp_path("home");
|
|
285
|
+
assert!(expand_download_dir("~//tmp", Some(&home)).is_err());
|
|
286
|
+
#[cfg(target_os = "windows")]
|
|
287
|
+
for value in ["~/C:\\other", "~/C:other", "~/\\other"] {
|
|
288
|
+
assert!(
|
|
289
|
+
expand_download_dir(value, Some(&home)).is_err(),
|
|
290
|
+
"accepted {value}"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
fs::remove_dir_all(home.parent().unwrap()).unwrap();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#[test]
|
|
297
|
+
fn expand_download_dir_rejects_relative_and_nul_paths() {
|
|
298
|
+
for value in [
|
|
299
|
+
"downloads",
|
|
300
|
+
"./downloads",
|
|
301
|
+
"~alice/downloads",
|
|
302
|
+
"~/bad\0path",
|
|
303
|
+
] {
|
|
304
|
+
assert!(
|
|
305
|
+
expand_download_dir(value, None).is_err(),
|
|
306
|
+
"accepted {value}"
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
207
311
|
#[test]
|
|
208
312
|
fn check_file_or_append_returns_input_when_missing() {
|
|
209
313
|
let path = temp_path("ghost.txt");
|