pake-cli 3.16.1 → 3.16.3
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 +77 -19
- 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 +12 -1
- package/src-tauri/src/inject/frame_links.js +2 -1
- package/src-tauri/src/inject/link_policy.js +7 -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.3";
|
|
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,
|
|
@@ -540,15 +538,8 @@ async function acquireBuildCache(targetDirectory) {
|
|
|
540
538
|
async function enterBuildWorkspace() {
|
|
541
539
|
const previousTarget = process.env.CARGO_TARGET_DIR;
|
|
542
540
|
const targetDirectory = path.resolve(packageDirectory, previousTarget || 'src-tauri/target');
|
|
543
|
-
const
|
|
544
|
-
let
|
|
545
|
-
try {
|
|
546
|
-
directory = await createBuildWorkspace(packageDirectory);
|
|
547
|
-
}
|
|
548
|
-
catch (error) {
|
|
549
|
-
await release();
|
|
550
|
-
throw error;
|
|
551
|
-
}
|
|
541
|
+
const directory = await createBuildWorkspace(packageDirectory);
|
|
542
|
+
let release;
|
|
552
543
|
setBuildDirectory(directory);
|
|
553
544
|
process.env.CARGO_TARGET_DIR = targetDirectory;
|
|
554
545
|
const leave = async () => {
|
|
@@ -569,7 +560,7 @@ async function enterBuildWorkspace() {
|
|
|
569
560
|
}
|
|
570
561
|
finally {
|
|
571
562
|
try {
|
|
572
|
-
await release();
|
|
563
|
+
await release?.();
|
|
573
564
|
}
|
|
574
565
|
catch (error) {
|
|
575
566
|
logger.warn(`Could not release the compilation cache lock: ${String(error)}`);
|
|
@@ -580,7 +571,11 @@ async function enterBuildWorkspace() {
|
|
|
580
571
|
await leave();
|
|
581
572
|
throwIfBuildCancelled();
|
|
582
573
|
}
|
|
583
|
-
return leave
|
|
574
|
+
return Object.assign(leave, {
|
|
575
|
+
async lockCache() {
|
|
576
|
+
release = await acquireBuildCache(targetDirectory);
|
|
577
|
+
},
|
|
578
|
+
});
|
|
584
579
|
}
|
|
585
580
|
|
|
586
581
|
async function terminateBuildTree(pid) {
|
|
@@ -1213,6 +1208,7 @@ async function injectCustomCode(options, tauriConf) {
|
|
|
1213
1208
|
await fsExtra.writeFile(injectFilePath, '');
|
|
1214
1209
|
}
|
|
1215
1210
|
tauriConf.pake.proxy_url = proxyUrl || '';
|
|
1211
|
+
tauriConf.pake.download_dir = options.downloadDir || '';
|
|
1216
1212
|
tauriConf.pake.basic_auth = basicAuth;
|
|
1217
1213
|
tauriConf.pake.multi_instance = multiInstance;
|
|
1218
1214
|
tauriConf.pake.multi_window = multiWindow;
|
|
@@ -1664,9 +1660,14 @@ class BaseBuilder {
|
|
|
1664
1660
|
await shellExec({ executable: packageManager, args });
|
|
1665
1661
|
}
|
|
1666
1662
|
async buildAndCopy(url, target, logSuccess = true) {
|
|
1667
|
-
|
|
1663
|
+
await this.prepareBuild(url);
|
|
1664
|
+
await this.runBuildCommand(this.getBuildCommand(await detectPackageManager()), target);
|
|
1665
|
+
await this.copyBuildArtifacts(target, logSuccess);
|
|
1666
|
+
}
|
|
1667
|
+
async prepareBuild(url) {
|
|
1668
1668
|
await mergeConfig(url, this.options, structuredClone(tauriConfig));
|
|
1669
|
-
|
|
1669
|
+
}
|
|
1670
|
+
async runBuildCommand(buildCommand, target) {
|
|
1670
1671
|
// Build app
|
|
1671
1672
|
const buildSpinner = getSpinner('Building app...');
|
|
1672
1673
|
buildSpinner.stop();
|
|
@@ -1685,7 +1686,6 @@ class BaseBuilder {
|
|
|
1685
1686
|
if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) {
|
|
1686
1687
|
logger.warn('⚠ AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.');
|
|
1687
1688
|
}
|
|
1688
|
-
const buildCommand = this.getBuildCommand(packageManager);
|
|
1689
1689
|
const buildTimeout = getBuildTimeout();
|
|
1690
1690
|
try {
|
|
1691
1691
|
await shellExec(buildCommand, buildTimeout, resolveExecEnv());
|
|
@@ -1712,6 +1712,9 @@ class BaseBuilder {
|
|
|
1712
1712
|
throw retryError;
|
|
1713
1713
|
}
|
|
1714
1714
|
}
|
|
1715
|
+
}
|
|
1716
|
+
async copyBuildArtifacts(target, logSuccess = true) {
|
|
1717
|
+
const { name = 'pake-app' } = this.options;
|
|
1715
1718
|
// With --no-bundle there is no installer to copy; surface the raw
|
|
1716
1719
|
// executable the build produced instead.
|
|
1717
1720
|
if (this.options.bundle === false) {
|
|
@@ -2084,6 +2087,7 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
2084
2087
|
constructor(options) {
|
|
2085
2088
|
super(options);
|
|
2086
2089
|
this.currentBuildType = '';
|
|
2090
|
+
this.compiled = false;
|
|
2087
2091
|
const target = options.targets || 'deb';
|
|
2088
2092
|
if (target.includes('-arm64')) {
|
|
2089
2093
|
this.buildFormat = target.replace('-arm64', '');
|
|
@@ -2119,6 +2123,8 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
2119
2123
|
return `${name}_${version}_${arch}`;
|
|
2120
2124
|
}
|
|
2121
2125
|
async build(url) {
|
|
2126
|
+
this.compiled = false;
|
|
2127
|
+
this.currentBuildType = '';
|
|
2122
2128
|
// --no-bundle: build the executable once with no per-format packaging loop.
|
|
2123
2129
|
if (this.options.bundle === false) {
|
|
2124
2130
|
await this.buildAndCopy(url, 'deb');
|
|
@@ -2129,6 +2135,13 @@ class LinuxBuilder extends BaseBuilder {
|
|
|
2129
2135
|
throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`);
|
|
2130
2136
|
}
|
|
2131
2137
|
const useTemporaryDebForZst = needsTemporaryDebForZst(targets);
|
|
2138
|
+
if (targets.length > 1) {
|
|
2139
|
+
await this.prepareBuild(url);
|
|
2140
|
+
const command = this.getBuildCommand(await detectPackageManager());
|
|
2141
|
+
command.args.push('--no-bundle');
|
|
2142
|
+
await this.runBuildCommand(command, 'compile');
|
|
2143
|
+
this.compiled = true;
|
|
2144
|
+
}
|
|
2132
2145
|
// With a single explicit target, fail fast. With multiple targets (the
|
|
2133
2146
|
// distro-aware default, or an explicit comma list) keep building the rest
|
|
2134
2147
|
// when one fails, so a usable installer is still produced, e.g. AppImage
|
|
@@ -2294,7 +2307,24 @@ post_remove() {
|
|
|
2294
2307
|
// Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time.
|
|
2295
2308
|
async buildAndCopy(url, target, logSuccess = true) {
|
|
2296
2309
|
this.currentBuildType = target;
|
|
2297
|
-
|
|
2310
|
+
if (!this.compiled) {
|
|
2311
|
+
await super.buildAndCopy(url, target, logSuccess);
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
const packageManager = await detectPackageManager();
|
|
2315
|
+
const args = ['run', 'tauri'];
|
|
2316
|
+
if (packageManager === 'npm')
|
|
2317
|
+
args.push('--');
|
|
2318
|
+
args.push('bundle', '--config', path.join('src-tauri', '.pake', 'tauri.conf.json'));
|
|
2319
|
+
args.push('--bundles', target, '--features', this.getBuildFeatures().join(','));
|
|
2320
|
+
if (this.options.debug)
|
|
2321
|
+
args.push('--debug');
|
|
2322
|
+
if (this.options.debug || target === 'appimage' || process.env.PAKE_VERBOSE)
|
|
2323
|
+
args.push('--verbose');
|
|
2324
|
+
if (this.buildArch === 'arm64')
|
|
2325
|
+
args.push('--target', this.getTauriTarget('arm64', 'linux'));
|
|
2326
|
+
await this.runBuildCommand({ executable: packageManager, args }, target);
|
|
2327
|
+
await this.copyBuildArtifacts(target, logSuccess);
|
|
2298
2328
|
}
|
|
2299
2329
|
getBuildCommand(packageManager = 'pnpm') {
|
|
2300
2330
|
const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json');
|
|
@@ -3370,6 +3400,7 @@ const DEFAULT_PAKE_OPTIONS = {
|
|
|
3370
3400
|
useLocalFile: false,
|
|
3371
3401
|
systemTrayIcon: '',
|
|
3372
3402
|
proxyUrl: '',
|
|
3403
|
+
downloadDir: '',
|
|
3373
3404
|
basicAuth: false,
|
|
3374
3405
|
debug: false,
|
|
3375
3406
|
json: false,
|
|
@@ -3434,6 +3465,28 @@ function validateUrlInput(url) {
|
|
|
3434
3465
|
}
|
|
3435
3466
|
return url;
|
|
3436
3467
|
}
|
|
3468
|
+
function validateDownloadDirInput(value) {
|
|
3469
|
+
if (value === '')
|
|
3470
|
+
return value;
|
|
3471
|
+
const homeRelative = value.startsWith('~/') ? value.slice(2) : null;
|
|
3472
|
+
const invalidHomePath = homeRelative !== null &&
|
|
3473
|
+
(path.isAbsolute(homeRelative) ||
|
|
3474
|
+
(process.platform === 'win32' &&
|
|
3475
|
+
/^(?:[a-zA-Z]:|[\\/])/.test(homeRelative)));
|
|
3476
|
+
if (invalidHomePath ||
|
|
3477
|
+
value.includes('\0') ||
|
|
3478
|
+
!(path.isAbsolute(value) || value === '~' || value.startsWith('~/')) ||
|
|
3479
|
+
(process.platform === 'win32' &&
|
|
3480
|
+
value !== '~' &&
|
|
3481
|
+
!value.startsWith('~/') &&
|
|
3482
|
+
!/^(?:[a-zA-Z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/.test(value))) {
|
|
3483
|
+
throw new PakeError('Invalid download directory.', {
|
|
3484
|
+
code: 'INVALID_INPUT',
|
|
3485
|
+
hint: 'Use an absolute path or a quoted ~/path; relative paths are not supported.',
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
return value;
|
|
3489
|
+
}
|
|
3437
3490
|
|
|
3438
3491
|
function getCliProgram() {
|
|
3439
3492
|
const { green, yellow } = chalk;
|
|
@@ -3470,6 +3523,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with
|
|
|
3470
3523
|
// If previous values exist (from multiple --inject options), merge them
|
|
3471
3524
|
return previous ? [...previous, ...files] : files;
|
|
3472
3525
|
}, DEFAULT_PAKE_OPTIONS.inject)
|
|
3526
|
+
.option('--download-dir <path>', 'App download directory (absolute path or ~/path; default: system Downloads)', DEFAULT_PAKE_OPTIONS.downloadDir)
|
|
3473
3527
|
.option('--debug', 'Debug build and more output', DEFAULT_PAKE_OPTIONS.debug)
|
|
3474
3528
|
.option('--json', 'Machine-readable output: logs to stderr, one JSON result on stdout', DEFAULT_PAKE_OPTIONS.json)
|
|
3475
3529
|
.option('--config <path>', 'Load options from a JSON config file (fields mirror CLI options, see schema/pake.schema.json)')
|
|
@@ -3804,6 +3858,7 @@ program.action(async (urlArg, options) => {
|
|
|
3804
3858
|
}
|
|
3805
3859
|
}
|
|
3806
3860
|
}
|
|
3861
|
+
validateDownloadDirInput(options.downloadDir);
|
|
3807
3862
|
if (!url) {
|
|
3808
3863
|
if (jsonMode) {
|
|
3809
3864
|
throw new PakeError('No URL or local path to package.', {
|
|
@@ -3823,7 +3878,8 @@ program.action(async (urlArg, options) => {
|
|
|
3823
3878
|
}
|
|
3824
3879
|
endCancellation = beginBuildCancellation();
|
|
3825
3880
|
phase = 'prepare';
|
|
3826
|
-
|
|
3881
|
+
const workspace = await enterBuildWorkspace();
|
|
3882
|
+
leaveWorkspace = workspace;
|
|
3827
3883
|
phase = 'input';
|
|
3828
3884
|
const appOptions = await handleOptions(options, url);
|
|
3829
3885
|
throwIfBuildCancelled();
|
|
@@ -3833,6 +3889,8 @@ program.action(async (urlArg, options) => {
|
|
|
3833
3889
|
await builder.prepare();
|
|
3834
3890
|
throwIfBuildCancelled();
|
|
3835
3891
|
phase = 'build';
|
|
3892
|
+
await workspace.lockCache();
|
|
3893
|
+
throwIfBuildCancelled();
|
|
3836
3894
|
await builder.build(url);
|
|
3837
3895
|
throwIfBuildCancelled();
|
|
3838
3896
|
await leaveWorkspace();
|
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
|
});
|
|
@@ -859,7 +864,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
|
|
859
864
|
// Rewrite the window.open function.
|
|
860
865
|
const originalWindowOpen = window.open;
|
|
861
866
|
window.open = function (url, name, specs) {
|
|
867
|
+
url = normalizePopupUrl(url);
|
|
862
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
|
+
}
|
|
863
874
|
if (normalizedUrl.startsWith("#")) {
|
|
864
875
|
window.location.href = new URL(normalizedUrl, window.location.href).href;
|
|
865
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);
|
|
@@ -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",
|
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");
|