diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000000..a651400682 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,54 @@ +--- +name: code-review +description: Pake project adapter for Waza check/code-review. Use for TypeScript CLI, Rust/Tauri, release artifact, and CI review. +version: 1.2.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Pake Code Review Adapter + +Use Waza `/check` for the generic review method. This adapter adds Pake-specific commands, hard stops, and artifact rules. + +## Pake-Specific Hard Stops + +- [ ] Changes under `bin/` rebuild and commit `dist/cli.js` with `pnpm run cli:build`. +- [ ] Changes to package metadata embedded by Rollup (`package.json` name/version/repository/bin/scripts/exports) rebuild and commit `dist/cli.js`. +- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, and `src-tauri/tauri.conf.json` in sync. +- [ ] npm release workflow changes preserve Trusted Publishing: `.github/workflows/npm-publish.yml`, `id-token: write`, canonical `git+https://github.com/tw93/Pake.git`, and `scripts/check-release-version.mjs`. +- [ ] Release/status changes keep npm registry, GitHub Release/assets, workflow run state, and issue closeout as separate truth surfaces. +- [ ] `workflow_dispatch` release logic does not infer the release tag from `headBranch`, run title, or compare UI; use an explicit tag/ref and verify the package `gitHead`. +- [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. +- [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. +- [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. +- [ ] New helper in `bin/utils/` or `bin/helpers/` has a matching `tests/unit/.test.ts`. +- [ ] Binary parsers have a round-trip test, not only builder assertions. +- [ ] Linux WebKit/AppImage runtime flag changes keep the default conservative, add or update tests for the decision logic, and update `docs/faq*.md` when users need a fallback command. +- [ ] macOS `--new-window` or auth URL changes include targeted tests for popup/auth routing in `src-tauri/src/inject/event.js`. + +## Quick Review Commands + +```bash +# Get PR diff +gh pr diff + +# Format check +pnpm run format:check + +# Run unit tests (fast, sub-second) +npx vitest run + +# Full suite without the slow real build +pnpm test -- --no-build + +# Build CLI and catch TypeScript errors +pnpm run cli:build +``` + +## Review Output Format + +Follow Waza `/check`: findings first, ordered by severity, with tight file/line references. Keep summaries brief. diff --git a/.agents/skills/github-ops/SKILL.md b/.agents/skills/github-ops/SKILL.md new file mode 100644 index 0000000000..168cb655d7 --- /dev/null +++ b/.agents/skills/github-ops/SKILL.md @@ -0,0 +1,107 @@ +--- +name: github-ops +description: GitHub issue, PR, and release operations via gh CLI. Not for code review or release builds. +version: 1.1.0 +allowed-tools: + - Bash + - Read +--- + +# GitHub Operations Skill + +Use this skill when working with GitHub issues, PRs, and releases for Pake. + +## Golden Rule + +**ALWAYS use `gh` CLI** for GitHub operations. Never use the web UI or make assumptions about state — always query first. + +## Issue Operations + +```bash +# View a specific issue +gh issue view 123 + +# List open issues +gh issue list --state open + +# List issues with a label +gh issue list --label bug + +# Add a comment (only with explicit user request) +gh issue comment 123 --body "..." + +# Close an issue +gh issue close 123 +``` + +## PR Operations + +```bash +# List open PRs +gh pr list + +# View a PR +gh pr view 456 + +# Check PR status and CI checks +gh pr checks 456 + +# View PR diff +gh pr diff 456 + +# Read inline review comments on a PR +gh api repos/tw93/Pake/pulls/456/comments + +# Merge a PR (only with explicit user request) +gh pr merge 456 --squash + +# Create a PR +gh pr create --title "..." --body "..." +``` + +## Release Operations + +```bash +# List releases +gh release list + +# View a specific release +gh release view V3.10.0 + +# Check CI runs for a tag +gh run list --workflow=release.yml +gh run list --workflow=npm-publish.yml + +# Watch a running CI job +gh run watch + +# View CI run logs +gh run view --log + +# Verify npm registry state after publish +npm view pake-cli version +npm view pake-cli@ dist.tarball +``` + +## CI / Workflow Operations + +```bash +# List recent workflow runs +gh run list + +# Filter by workflow +gh run list --workflow=release.yml +gh run list --workflow=quality-and-test.yml + +# Re-run failed jobs +gh run rerun --failed-only +``` + +## Safety Rules + +1. **ALWAYS** draft the reply first and show it to the user for approval before calling any write operation (`gh issue comment`, `gh pr comment`, `gh pr merge`, `gh issue close`, `gh release create`, etc.). Approval of one draft does not extend to future comments. +2. **NEVER** merge, close, or modify without explicit user request. +3. **ALWAYS** query current state before taking action — never assume. +4. Before replying to an issue or PR, read the body to confirm the author's language; match their language in the reply. This applies to the author, not to arbitrary thread commenters. +5. Before replying that a fix is released, verify the public artifact first: `npm view pake-cli version` for CLI releases or `gh release view ` for app releases. +6. Before closing an issue after release, confirm the target with `gh issue view --json number,title,state,author,url` and include the concrete version or upgrade command in the comment. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000000..2dc7b6dee1 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,96 @@ +--- +name: release +description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. +version: 1.1.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Release Skill + +Use this skill when preparing or executing a Pake release. + +## Version Files + +Four files must be updated in sync — never update one without the others: + +- `package.json` → `"version"` +- `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/Cargo.lock` → `version` for package `pake` +- `src-tauri/tauri.conf.json` → `"version"` + +## Release Checklist + +### Pre-Release + +1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) +2. [ ] Confirm the version is not already on npm: `npm view pake-cli@X.Y.Z version` should return 404 before publishing +3. [ ] Update all four version files above +4. [ ] Run `pnpm run format` — must pass cleanly +5. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +6. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +7. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +8. [ ] No uncommitted changes: `git status` +9. [ ] Commit version bump with message: `chore: bump version to VX.X.X` + +### Tagging (triggers CI) + +```bash +git tag -a VX.X.X -m "Release VX.X.X" +git push origin VX.X.X +``` + +Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. + +### Post-Tag Verification + +1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` +2. [ ] Watch CI status: `gh run watch` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X --json tagName,url,assets` +4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` +5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +6. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` +7. [ ] Verify latest now resolves to the release: `npm view pake-cli version` +8. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` + +npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. + +Keep release surfaces separate in the final status: + +- npm registry: the authority for `pake-cli` installability and CLI/npm issue closeout. +- GitHub Release/assets: the authority for app installers and popular-app artifact availability. +- Quality workflow: the authority for post-push CI health, but it can continue after npm has already shipped. +- Source/tag: the authority for what code was intended to ship. + +Do not collapse these into "released" without naming which surface was verified. If GitHub Release assets are visible while `gh run list` still reports the release workflow as queued or in progress, trust `gh release view` for asset state and report the workflow state separately. + +## Trusted Publishing Notes + +- The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. +- npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. +- If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. `npm view pake-cli version` alone is not enough because `latest` can point at a different commit than the fix under review. +- A `workflow_dispatch` run may execute on `main`; do not treat `headBranch`, run title, or compare UI as the release tag. Check the pushed tag and published package `gitHead`. +- If CI creates `chore: update contributors [skip ci]` after the tag is pushed, fast-forward local `main` after the release. Do not retag just to include generated contributor art. + +## Build Commands (local only) + +```bash +# Current platform +pnpm build + +# macOS universal binary +pnpm build:mac +``` + +Cross-platform builds (Windows/Linux) are handled by CI, not locally. + +## Safety Rules + +1. **NEVER** auto-commit or auto-push without explicit user request +2. **NEVER** tag before all checks pass +3. **ALWAYS** verify the four version files are in sync before tagging diff --git a/.agents/skills/use-pake/SKILL.md b/.agents/skills/use-pake/SKILL.md new file mode 100644 index 0000000000..9a77e52124 --- /dev/null +++ b/.agents/skills/use-pake/SKILL.md @@ -0,0 +1,187 @@ +--- +name: use-pake +description: "Package any website into a lightweight desktop app using Pake (Tauri/Rust). Use when the user wants to: wrap a URL as a native app, build a desktop app from a website, use Pake CLI to package a page, set up proxy for a packaged app, customize app icons or bundle IDs, or mentions 'pake', 'tauri package', 'website to app', 'wrap site'. Also trigger when the user asks about Pake CLI options, proxy configuration for packaged apps, or icon handling." +version: 1.0.0 +allowed-tools: + - Bash + - Read +--- + +# Pake - Website to Desktop App + +Pake wraps any webpage into a native desktop app via Tauri (Rust + system WebView). Output is ~5MB (vs Electron's ~150MB). Run the commands below from the root of this Pake repository. + +## Quick Start + +```bash +# Build the CLI once (produces dist/cli.js); skip if dist/ already exists +pnpm run cli:build + +node dist/cli.js "" --name [options] +``` + +## Workflow + +### 1. Gather Requirements + +Before running the build, confirm these with the user: + +| Parameter | Why it matters | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| URL | The target website | +| Name | Becomes the `.app` / `.exe` name and productName | +| Bundle ID (`--identifier`) | Defaults to `com.pake.a{md5hash}` — set explicitly for clean installs (e.g. `com.pake.youtube`) | +| Proxy (`--proxy-url`) | Baked into the binary at build time; not runtime-configurable. Format: `http://host:port` or `socks5://host:port` | +| Icon | Auto-fetched from website if omitted; can be a local path or URL | + +### 2. Handle Icons + +Pake auto-fetches icons from multiple services (logo.dev, brandfetch, clearbit, Google favicons, direct favicon.ico). However: + +**The icon download does NOT use `--proxy-url`.** That flag only configures the packaged app's WebView proxy. If the icon URL requires a proxy to reach (e.g. Google/GitHub assets from China), download it manually first: + +```bash +curl -x http://: -o /tmp/icon.png "" --connect-timeout 15 -s +``` + +Then pass the local file: + +```bash +node dist/cli.js "" --icon /tmp/icon.png ... +``` + +**Icon tips:** + +- Prefer 256x256 or larger PNGs; SVG also works +- Pake auto-converts to platform format: `.icns` (macOS), `.ico` (Windows), `.png` (Linux) +- macOS icons get a squircle mask automatically +- If an old icon exists in `src-tauri/icons/.icns`, Pake reuses it — rename/remove it to force re-fetch + +### 3. Build + +```bash +node dist/cli.js "" \ + --name \ + --identifier \ + --proxy-url "http://host:port" \ + --icon /path/to/icon.png +``` + +Build takes ~40s with cache, ~2min cold. Output: `.dmg` in the project root. + +### 4. Verify + +After build, confirm: + +- DMG path printed in output +- Icon correctness (user should open and check) +- Bundle ID via: `mdls -name kMDItemCFBundleIdentifier .app` + +## CLI Options Reference + +### Common Options + +| Option | Default | Description | +| ------------------------ | ------------------ | --------------------------------- | +| `--name ` | — | App name | +| `--icon ` | auto-fetch | Icon path (local file or URL) | +| `--identifier ` | `com.pake.a{hash}` | Bundle ID / app identifier | +| `--proxy-url ` | — | WebView proxy (http/https/socks5) | +| `--width ` | 1200 | Window width | +| `--height ` | 780 | Window height | +| `--app-version ` | 1.0.0 | App version | + +### Window Behavior + +| Option | Default | Description | +| ------------------ | ------- | -------------------- | +| `--fullscreen` | false | Start fullscreen | +| `--maximize` | false | Start maximized | +| `--always-on-top` | false | Pin window on top | +| `--hide-title-bar` | false | Hide macOS title bar | + +### Advanced + +| Option | Default | Description | +| ----------------------------- | ------- | -------------------------------------------- | +| `--inject ` | — | Inject CSS/JS files (comma-separated paths) | +| `--user-agent ` | — | Custom user agent | +| `--debug` | false | Enable devtools and verbose logging | +| `--multi-arch` | false | Build for both Intel and Apple Silicon | +| `--multi-instance` | false | Allow multiple app instances | +| `--multi-window` | false | Multiple windows in one instance | +| `--new-window` | false | Allow popup windows (needed for OAuth flows) | +| `--safe-domain ` | none | Keep trusted SSO/workspace domains in-app | +| `--incognito` | false | Private browsing mode | +| `--dark-mode` | false | Force macOS dark mode | +| `--zoom ` | 100 | Initial zoom level (50-200) | +| `--wasm` | false | Enable WebAssembly | +| `--enable-drag-drop` | false | Drag & drop support | +| `--camera` | false | Camera permission (macOS) | +| `--microphone` | false | Microphone permission (macOS) | +| `--ignore-certificate-errors` | false | Ignore TLS errors | +| `--targets ` | auto | Build target format | +| `--use-local-file` | false | Package local HTML file | + +## Platform Notes + +### Proxy Support + +| Platform | Status | Notes | +| -------- | --------- | -------------------------------------------------------------------- | +| Windows | Full | Via `--proxy-server` browser arg | +| Linux | Full | Via `--proxy-server` browser arg | +| macOS | macOS 14+ | Uses Tauri native `macos-proxy` feature; auto-detected at build time | + +### Chrome Extensions + +Not supported. Pake uses system WebView (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux), not a full Chrome browser. Use `--inject` to add custom JS/CSS as an alternative. + +### Linux Wayland Input Issues + +If an AppImage opens but buttons cannot be clicked or keyboard input does not reach the page on a pure Wayland compositor, especially niri, first rebuild with the latest `pake-cli`. Then try the native WebKit path: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./YourApp.AppImage +``` + +If that produces a blank window on the same system, re-enable the conservative WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./YourApp.AppImage +``` + +Do not diagnose this from GTK, appindicator, or GStreamer warnings alone; those can be optional runtime warnings unrelated to the input failure. + +## Common Patterns + +### Website behind proxy (icon also needs proxy) + +```bash +# Step 1: Download icon via proxy +curl -x http://127.0.0.1:7890 -o /tmp/icon.png "" -s + +# Step 2: Build with local icon +node dist/cli.js "https://example.com" \ + --name MyApp \ + --identifier com.pake.myapp \ + --proxy-url "http://127.0.0.1:7890" \ + --icon /tmp/icon.png +``` + +### Force icon re-fetch + +```bash +# Remove cached icon, then build without --icon +mv src-tauri/icons/.icns src-tauri/icons/.icns.bak +node dist/cli.js "https://example.com" --name MyApp +``` + +### OAuth-dependent site + +```bash +node dist/cli.js "https://accounts.google.com" \ + --name GoogleApp \ + --new-window \ + --ignore-certificate-errors +``` diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md new file mode 100644 index 0000000000..873bb54b04 --- /dev/null +++ b/.claude/rules/rust.md @@ -0,0 +1,40 @@ +# Pake Rust + Tauri Rules + +> Pake-specific Rust + Tauri rules. Standard Rust hygiene is assumed: `?` over `unwrap()`, `cargo clippy` clean, `cargo fmt` before commit. + +## Pake-Specific + +### Error handling + +- No `panic!` / `.unwrap()` / `.expect()` on user-reachable paths: CLI options, config loading, event handlers, IPC commands. Use `?` and surface clear messages. +- Silent `catch {}` in TS or `let _ = ...` in Rust must surface the real error through `logger.warn` at minimum. +- `shellExec` runs subprocesses with `stdio: 'inherit'`, so their output (linuxdeploy, cargo, npm) never reaches `error.message`; only the failed command line does. Do NOT classify a build failure by grepping `error.message`; you would be matching the command, not the diagnostics. Drive failure guidance off a structured fact the caller holds (e.g. `target === 'appimage'`). Owners: `bin/utils/shell.ts` + `bin/builders/BaseBuilder.ts`. + +### IPC + +- `#[tauri::command]` handlers validate every input from the renderer. The webview is untrusted. +- Long work in handlers goes through `tauri::async_runtime::spawn`. Don't block the IPC thread. +- Don't broaden the allowlist (filesystem, shell, http) past the exact paths and commands needed. + +### Config types + +- No `tauriConf: any` or other untyped config bags. Use `PakeTauriConfig`. +- Window options live in `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`. Adding an option means touching all four plus `docs/cli-usage*.md`. Forgetting any is a regression. + +### Network mirrors + +- CN mirror switching is **explicit opt-in** via `PAKE_USE_CN_MIRROR=1`. Do not reintroduce automatic CN-domain detection. +- Behavior owners: `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts`. Keep docs and tests aligned. + +### dist/cli.js + +- `dist/cli.js` is a tracked build artifact (declared in `package.json` `files`). +- Any change under `bin/` must rebuild with `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change. + +### Platform sensitivity + +- WebKit compositing on Linux/Wayland is platform-sensitive. Don't change defaults without testing on the affected platform or documenting the risk. +- Linux WebKit runtime flags live in `src-tauri/src/lib.rs`. Keep the default conservative; compositor-specific exceptions need unit tests for the decision function and FAQ guidance for users. +- AppImage logs often contain optional GTK, appindicator, or GStreamer warnings. Do not treat those warnings as the root cause unless the user-visible symptom and target path confirm it. +- `--incognito` trades persistence for clean private sessions; be deliberate around login / cookies / local storage / embedded-WebView detection. +- Google OAuth and other embedded-WebView restrictions may still apply even with `--new-window` / `--multi-window`. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000000..02f9ad3b91 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,131 @@ +--- +name: release +description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. +version: 1.1.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Release Skill + +Use this skill when preparing or executing a Pake release. + +## Version Files + +Four files must be updated in sync — never update one without the others: + +- `package.json` → `"version"` +- `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/Cargo.lock` → `version` for package `pake` +- `src-tauri/tauri.conf.json` → `"version"` + +## Release Checklist + +### Pre-Release + +1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) +2. [ ] Confirm the version is not already on npm: `npm view pake-cli@X.Y.Z version` should return 404 before publishing +3. [ ] Update all four version files above +4. [ ] Run `pnpm run format` — must pass cleanly +5. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +6. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +7. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +8. [ ] No uncommitted changes: `git status` +9. [ ] Commit version bump with message: `chore: bump version to VX.X.X` + +### Tagging (triggers CI) + +```bash +git tag -a VX.X.X -m "Release VX.X.X" +git push origin VX.X.X +``` + +Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. + +### Post-Tag Verification + +1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` +2. [ ] Watch CI status: `gh run watch` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X --json tagName,url,assets` +4. [ ] Fill the GitHub Release title and body from the template in **GitHub Release Notes** below. CI's `create-release` step only makes a bare placeholder (title = `VX.X.X`, empty body); do not leave it bare. +5. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` +6. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +7. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` +8. [ ] Verify latest now resolves to the release: `npm view pake-cli version` +9. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` + +npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. + +Keep release surfaces separate in the final status: + +- npm registry: the authority for `pake-cli` installability and CLI/npm issue closeout. +- GitHub Release/assets: the authority for app installers and popular-app artifact availability. +- Quality workflow: the authority for post-push CI health, but it can continue after npm has already shipped. +- Source/tag: the authority for what code was intended to ship. + +Do not collapse these into "released" without naming which surface was verified. If GitHub Release assets are visible while `gh run list` still reports the release workflow as queued or in progress, trust `gh release view` for asset state and report the workflow state separately. + +## GitHub Release Notes + +CI only creates a bare placeholder release. Every published release must be edited to match the house format, or it looks broken next to the others. Two failure modes to avoid: a bare version title with no codename, and a body missing the logo header / star line / repo footer (see `V3.11.10` and `V3.12.0`, both fixed after the fact). + +### Title format + +`V ` — version, then a single English codename word, optionally with one emoji. Examples: `V3.11.8 Polish`, `V3.11.10 Bedrock`, `V3.12.0 Gateway`, `V3.11.0 Evolve 👻`. The codename is the maintainer's call; pick one that fits the release theme. Even patch releases get a codename. + +### Body template + +Fill in the version, the two changelog lists (English + 中文, same items in the same order, numbered), the thanks line (credit the reporters/PR authors behind the release), and keep the logo header and repo footer verbatim: + +```markdown +
+Pake Logo +

Pake VX.Y.Z

+

Turn any webpage into a desktop app with one command.

+
+ +### Changelog + +1. ... + +### 更新日志 + +1. ... + +Special thanks to @user for the reports and PRs behind this release. If Pake helps you, please consider giving it a star and recommending it to your friends. + +> https://github.com/tw93/Pake +``` + +Apply with a notes file to avoid shell escaping: `gh release edit VX.Y.Z --title "VX.Y.Z Codename" --notes-file notes.md`. Source changelog items from the real commit range (`git log VPREV..VX.Y.Z`), keep them user-facing, and drop pure CI/refactor/docs noise. + +## Trusted Publishing Notes + +- The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. +- npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. +- If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. `npm view pake-cli version` alone is not enough because `latest` can point at a different commit than the fix under review. +- A `workflow_dispatch` run may execute on `main`; do not treat `headBranch`, run title, or compare UI as the release tag. Check the pushed tag and published package `gitHead`. +- If CI creates `chore: update contributors [skip ci]` after the tag is pushed, fast-forward local `main` after the release. Do not retag just to include generated contributor art. + +## Build Commands (local only) + +```bash +# Current platform +pnpm build + +# macOS universal binary +pnpm build:mac +``` + +Cross-platform builds (Windows/Linux) are handled by CI, not locally. + +## Safety Rules + +1. **NEVER** auto-commit or auto-push without explicit user request +2. **NEVER** tag before all checks pass +3. **ALWAYS** verify the four version files are in sync before tagging diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e7dad43930..b626d367e8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ["tw93"] -custom: ["https://miaoyan.app/cats.html?name=Pake"] +custom: ["https://cats.tw93.fun?name=Pake"] diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000000..9661b4fce6 --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,83 @@ +name: Publish npm Package + +on: + push: + tags: + - "V*" + # Manual npm-only publish for CLI hotfixes that do not need the full + # V* tag release (popular apps, Docker). Version files must already be + # bumped on main; the check step then validates against package.json. + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish pake-cli + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Check manual publish branch + if: github.event_name == 'workflow_dispatch' + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Manual npm publish must run from main, got ${GITHUB_REF}." + exit 1 + fi + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: "10.26.2" + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check release version + run: node scripts/check-release-version.mjs "${{ github.ref_type == 'tag' && github.ref_name || '' }}" + + - name: Check formatting + run: pnpm run format:check + + - name: Run unit tests + run: npx vitest run + + - name: Build CLI + run: pnpm run cli:build + + - name: Check package contents + run: npm pack --dry-run --ignore-scripts + + - name: Publish to npm + run: npm publish + + - name: Verify published version + run: | + VERSION="$(node -p "require('./package.json').version")" + for attempt in {1..12}; do + PUBLISHED="$(npm view "pake-cli@${VERSION}" version --registry=https://registry.npmjs.org 2>/dev/null || true)" + if [ "$PUBLISHED" = "$VERSION" ]; then + echo "Published pake-cli@${VERSION}" + exit 0 + fi + echo "Waiting for npm registry to expose pake-cli@${VERSION} (attempt ${attempt}/12)" + sleep 10 + done + echo "pake-cli@${VERSION} was not visible in npm registry after publish" + exit 1 diff --git a/.github/workflows/pake-cli.yaml b/.github/workflows/pake-cli.yaml index e8caab6bd1..41bd9981ef 100644 --- a/.github/workflows/pake-cli.yaml +++ b/.github/workflows/pake-cli.yaml @@ -33,6 +33,16 @@ on: description: "Window height (px)" required: false default: "780" + min_width: + description: "Minimum window width (px)" + required: false + min_height: + description: "Minimum window height (px)" + required: false + app_version: + description: "App version" + required: false + default: "1.0.0" fullscreen: description: "Start in fullscreen mode" required: false @@ -49,7 +59,7 @@ on: type: boolean default: false targets: - description: "Package formats (comma-separated: deb,appimage,rpm)" + description: "Package formats (comma-separated: deb,appimage,rpm,zst)" required: false default: "deb" @@ -112,6 +122,18 @@ jobs: ARGS+=("--height" "${{ inputs.height }}") fi + if [ -n "${{ inputs.min_width }}" ]; then + ARGS+=("--min-width" "${{ inputs.min_width }}") + fi + + if [ -n "${{ inputs.min_height }}" ]; then + ARGS+=("--min-height" "${{ inputs.min_height }}") + fi + + if [ -n "${{ inputs.app_version }}" ]; then + ARGS+=("--app-version" "${{ inputs.app_version }}") + fi + if [ "${{ inputs.fullscreen }}" == "true" ]; then ARGS+=("--fullscreen") fi @@ -150,6 +172,18 @@ jobs: $args += "--height", "${{ inputs.height }}" } + if ("${{ inputs.min_width }}" -ne "") { + $args += "--min-width", "${{ inputs.min_width }}" + } + + if ("${{ inputs.min_height }}" -ne "") { + $args += "--min-height", "${{ inputs.min_height }}" + } + + if ("${{ inputs.app_version }}" -ne "") { + $args += "--app-version", "${{ inputs.app_version }}" + } + if ("${{ inputs.fullscreen }}" -eq "true") { $args += "--fullscreen" } @@ -189,6 +223,24 @@ jobs: retention-days: 3 if-no-files-found: ignore + - name: Upload RPM (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.name }}-Linux-rpm + path: ${{ inputs.name }}.rpm + retention-days: 3 + if-no-files-found: ignore + + - name: Upload ZST (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.name }}-Linux-zst + path: ${{ inputs.name }}-*.pkg.tar.zst + retention-days: 3 + if-no-files-found: ignore + - name: Upload MSI (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v6 diff --git a/.github/workflows/quality-and-test.yml b/.github/workflows/quality-and-test.yml index 6800dedb9c..b537e4d017 100644 --- a/.github/workflows/quality-and-test.yml +++ b/.github/workflows/quality-and-test.yml @@ -79,18 +79,10 @@ jobs: - uses: rui314/setup-mold@v1 - - name: Cache cargo-hack - uses: actions/cache@v5 - id: cargo-hack-cache - with: - path: ~/.cargo/bin/cargo-hack - key: ${{ runner.os }}-cargo-hack-${{ hashFiles('~/.cargo/bin/cargo-hack') }} - restore-keys: | - ${{ runner.os }}-cargo-hack- - - name: Install cargo-hack - if: steps.cargo-hack-cache.outputs.cache-hit != 'true' - run: cargo install cargo-hack --force + uses: taiki-e/install-action@v2 + with: + tool: cargo-hack - name: Check Rust formatting run: cargo fmt --all -- --color=always --check @@ -98,8 +90,11 @@ jobs: - name: Run Clippy lints run: cargo hack --feature-powerset --exclude-features cli-build --no-dev-deps clippy # cspell:disable-line - validation: - name: CLI & Build Validation (${{ matrix.os }}) + # Fast lane: runs on every PR and push. Skips the heavy real Tauri build + # (kept under tests/index.js) but still covers Vitest + integration suites + # so feedback stays under ~5 minutes per OS instead of 20+. + validation-fast: + name: CLI Fast Validation (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -117,14 +112,15 @@ jobs: - name: Build CLI run: pnpm run cli:build - - name: Run CI Test Suite - run: pnpm test - timeout-minutes: 30 + - name: Run Fast Test Suite + shell: bash + run: PAKE_CREATE_APP=1 node tests/index.js --no-build + timeout-minutes: 15 env: CI: true NODE_ENV: test - - name: Test CLI Integration + - name: Test CLI Integration (smoke) shell: bash run: | echo "Testing CLI integration..." @@ -134,10 +130,36 @@ jobs: timeout 30s PAKE_CREATE_APP=1 node dist/cli.js https://weekly.tw93.fun --name "CITest" --debug --iterative-build || true fi + # Full lane: only runs after merge to main (push event) or manual dispatch. + # Keeps the real Tauri build coverage off the PR critical path. + validation-full: + name: Full Tauri Build (${{ matrix.os }}) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-env + with: + mode: build + + - name: Run Full Test Suite (with real build) + run: pnpm test + timeout-minutes: 30 + env: + CI: true + NODE_ENV: test + summary: name: Quality Summary runs-on: ubuntu-latest - needs: [auto-format, rust-quality, validation] + needs: [auto-format, rust-quality, validation-fast, validation-full] if: always() steps: - name: Generate Summary @@ -149,5 +171,6 @@ jobs: echo "|-------|--------|" echo "| Auto Formatting | ${{ needs.auto-format.result == 'success' && 'PASSED' || needs.auto-format.result == 'skipped' && 'SKIPPED' || 'FAILED' }} |" echo "| Rust Quality | ${{ needs.rust-quality.result == 'success' && 'PASSED' || 'FAILED' }} |" - echo "| CLI & Build Validation | ${{ needs.validation.result == 'success' && 'PASSED' || 'FAILED' }} |" + echo "| CLI Fast Validation | ${{ needs.validation-fast.result == 'success' && 'PASSED' || 'FAILED' }} |" + echo "| Full Tauri Build | ${{ needs.validation-full.result == 'success' && 'PASSED' || needs.validation-full.result == 'skipped' && 'SKIPPED (PR fast lane)' || 'FAILED' }} |" } >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 259d9b6074..910f26db1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,9 @@ jobs: url: ${{ matrix.config.url }} icon: ${{ matrix.config.icon }} new_window: ${{ matrix.config.new_window || false }} + incognito: ${{ matrix.config.incognito || false }} + width: ${{ matrix.config.width || 1200 }} + height: ${{ matrix.config.height || 780 }} # Publish Docker image (runs in parallel with app builds) publish-docker: diff --git a/.github/workflows/single-app.yaml b/.github/workflows/single-app.yaml index 80ffd0c4b3..a3b0c5e6a6 100644 --- a/.github/workflows/single-app.yaml +++ b/.github/workflows/single-app.yaml @@ -30,6 +30,18 @@ on: description: "Allow sites to open new windows" required: false default: false + incognito: + description: "Enable incognito mode" + required: false + default: false + width: + description: "Window width" + required: false + default: 1200 + height: + description: "Window height" + required: false + default: 780 workflow_call: inputs: name: @@ -61,6 +73,21 @@ on: type: boolean required: false default: false + incognito: + description: "Enable incognito mode" + type: boolean + required: false + default: false + width: + description: "Window width" + type: number + required: false + default: 1200 + height: + description: "Window height" + type: number + required: false + default: 780 secrets: PAKE_SIGNING_IDENTITY: required: false @@ -187,6 +214,13 @@ jobs: ARGS+=("--new-window") fi + if [ "${{ inputs.incognito }}" = "true" ]; then + ARGS+=("--incognito") + fi + + ARGS+=("--width" "${{ inputs.width }}") + ARGS+=("--height" "${{ inputs.height }}") + # Build once with multiple targets (faster than separate builds) node dist/cli.js "${ARGS[@]}" --targets deb,appimage @@ -234,6 +268,13 @@ jobs: ARGS+=("--new-window") fi + if [ "${{ inputs.incognito }}" = "true" ]; then + ARGS+=("--incognito") + fi + + ARGS+=("--width" "${{ inputs.width }}") + ARGS+=("--height" "${{ inputs.height }}") + node dist/cli.js "${ARGS[@]}" --targets universal --multi-arch mkdir -p output/macos @@ -303,6 +344,13 @@ jobs: $args += "--new-window" } + if ("${{ inputs.incognito }}" -eq "true") { + $args += "--incognito" + } + + $args += "--width", "${{ inputs.width }}" + $args += "--height", "${{ inputs.height }}" + $args += "--targets", "x64" node dist/cli.js $args diff --git a/.gitignore b/.gitignore index d02ef8b60a..518540e934 100644 --- a/.gitignore +++ b/.gitignore @@ -20,14 +20,14 @@ *.suo *.sw? *.tmp -# AI assistant docs (do not commit) -# AI Assistant files +# Local AI assistant overrides (shared docs AGENTS.md / CLAUDE.md / .claude are tracked) +CLAUDE.local.md +AGENTS.override.md +.claude/settings.local.json +.agents/settings.local.json # Editor directories and files # Logs -.claude/ AGENT.md -AGENTS.md -CLAUDE.md dist dist-ssr journal/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..4485c356ee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,222 @@ +# AGENTS.md - Pake Project Knowledge Base + +> Project-specific Rust + Tauri rules: `.claude/rules/rust.md`. Release runbook: `.claude/skills/release/SKILL.md` (run `/release`). + +## Project Identity + +**Pake** - Turn any webpage into a lightweight desktop app with one command. + +- **Purpose**: Package any website into a ~5MB desktop app (20x smaller than Electron) +- **Stack**: Tauri v2 (Rust) + TypeScript CLI +- **Platforms**: macOS, Windows, Linux +- **Mechanism**: Uses system webview (WebKit on macOS/Linux, WebView2 on Windows) + +## Repository Structure + +``` +Pake/ +├── bin/ # CLI source code (TypeScript) +│ └── cli.ts # Main CLI entry (Commander.js) +├── src-tauri/ # Tauri Rust application +│ ├── src/ # Rust source code +│ ├── src/app/ # window creation, setup, menu, config, and invokes +│ ├── src/inject/ # injected JS/CSS behavior +│ ├── Cargo.toml # Rust dependencies and version +│ ├── tauri.conf.json # Tauri configuration and version +│ └── .cargo/ # Cargo configuration (gitignored) +├── dist/ # Compiled CLI output +├── docs/ # Documentation +│ ├── cli-usage.md # CLI parameters +│ ├── advanced-usage.md # Customization guide +│ └── faq.md # Troubleshooting +├── scripts/ # Utility scripts +├── tests/ # Unit, integration, and release-flow tests +├── .github/workflows/ # quality/test and release automation +├── default_app_list.json # Popular apps config for release builds +├── package.json # Node.js dependencies and version +└── rollup.config.js # CLI build configuration +``` + +## Development Commands + +| Command | Purpose | +| ------------------------------------ | --------------------------------------------------------------- | +| `pnpm install` | Install dependencies | +| `pnpm run dev` | Tauri development mode | +| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | +| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | +| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | +| `pnpm run build` | Build for current platform | +| `pnpm run build:mac` | macOS universal binary | +| `pnpm run format` | Format code (prettier + cargo fmt) | +| `npx vitest run` | Unit and integration tests only (sub-second) | +| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | +| `pnpm test` | Full suite including release workflow | + +Keep shared project facts in this file so Codex, Claude Code, and other agents use the same source of truth. `CLAUDE.md` is a symlink to this file, so edit `AGENTS.md` only. Local-only overrides (`CLAUDE.local.md`, `AGENTS.override.md`, `.claude/settings.local.json`) stay ignored. + +## Code Conventions + +- No Chinese comments in any source (Rust / TypeScript / any file). Comments and identifiers in English; follow the existing language of surrounding prose. + +## Task Intake And Investigation + +Prefer requests with: + +- `Goal`: exact bug, feature, refactor, or review target +- `Scope`: files, directories, or subsystem boundaries to inspect first +- `Repro`: command, input, fixture, or failing test +- `Expected`: expected behavior +- `Actual`: current behavior, error text, or regression note +- `Constraints`: what must not change +- `Verify`: minimum command or test that proves the result + +When task scope is incomplete, inspect in this order: + +1. CLI entry and option parsing under `bin/cli.ts`, `bin/options/`, and `bin/helpers/` +2. Target TypeScript module under `bin/` +3. Tauri runtime or packaging files under `src-tauri/src/` and `src-tauri/tauri*.conf.json` +4. Narrow tests under `tests/unit/` or `tests/integration/` +5. Release workflow files under `.github/workflows/` only for CI or release issues +6. Docs only if behavior, ownership, or expected usage is still unclear + +Execution rules: + +- Start with the smallest plausible file set +- Prefer targeted search (`rg `) over repository-wide scans +- Ignore generated or output-heavy areas unless the task directly targets them, especially `dist/`, `node_modules/`, `src-tauri/target/`, `.app/`, `src-tauri/icons/`, and `src-tauri/png/`. Exception: `dist/cli.js` is the shipped CLI build artifact (see `package.json` `files`); when you change anything under `bin/`, rebuild it via `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change +- If a task touches release status, issue closeout, npm delivery, or GitHub assets, verify live surfaces separately: source commit/tag, workflow run, npm registry, GitHub Release/assets, and issue state. Do not let one passing surface imply another +- Keep changes local to one subsystem when possible +- Run the narrowest relevant verification first, expand only if needed +- If key context is missing, make one reasonable assumption and proceed + +## Current Risk Areas + +- CLI options are user-facing and must stay synchronized across `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`, generated `dist/cli.js`, and `docs/cli-usage*.md`. +- Recent window/runtime options include `--incognito`, `--new-window`, `--min-width`, `--min-height`, `--maximize`, multi-window behavior, notification click handling, and Linux/Wayland WebKit compositing defaults. +- `--incognito` intentionally trades persistence for clean private sessions; be careful around login, cookies, local storage, and WeChat-style WebView detection. +- `--new-window` and `--multi-window` do not bypass every provider policy. Google OAuth and similar embedded-WebView restrictions may still require a normal browser or native client. +- macOS auth-popup behavior is fragile. Auth/sign-in URLs that trigger WebKit `SOAuthorization` popup creation should stay in the current window when that path can abort the app; changes in `src-tauri/src/inject/event.js` need targeted tests. +- Notification flows cross injected JS, Tauri invokes, capabilities, and native notification plugins. Verify the Rust capability and JS caller together. +- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Runtime flag decisions live in `src-tauri/src/lib.rs`; keep the default conservative, cover compositor exceptions with unit tests, and document user-facing fallbacks in `docs/faq*.md`. +- Linux AppImage reports often include harmless GTK, appindicator, or GStreamer warnings. Separate optional runtime warnings from the actual symptom before changing code; input/click failures on pure Wayland compositors are not the same class as blank-window failures. +- Release state can be split. npm Trusted Publishing can succeed before the popular-app release workflow finishes, and GitHub Release assets can exist while a workflow run still shows queued or in progress. Report each surface explicitly. + +## Platform-Specific Development + +### macOS + +- Universal builds via `--multi-arch` (Intel + Apple Silicon). +- Icons: `.icns`. +- Title bar can be customized via Tauri window options. + +### Windows + +- Requires Visual Studio Build Tools to compile. +- Icons: `.ico`. +- MSI installer supported via Tauri bundler. + +### Linux + +- Multiple package formats: `.deb`, `.AppImage`, `.rpm`. +- Runtime depends on `libwebkit2gtk` and its companion libraries. +- Icons: `.png`. +- WebKit compositing is platform-sensitive on Wayland; see Current Risk Areas before changing defaults. + +## Branch Strategy + +- `main` - Only branch. All development and releases happen here directly. + +## Version Management + +Four files must be updated in sync for every release: + +| File | Field | +| --------------------------- | ---------------------------- | +| `package.json` | `"version"` | +| `src-tauri/Cargo.toml` | `version` under `[package]` | +| `src-tauri/Cargo.lock` | `version` for package `pake` | +| `src-tauri/tauri.conf.json` | `"version"` | + +Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. + +## Release Workflow (CI) + +Pushing a `V*` tag triggers `.github/workflows/release.yml`: + +1. **release-apps** - reads `default_app_list.json` for app list +2. **create-release** - creates the GitHub Release placeholder +3. **build-cli** - builds and uploads the `dist/` CLI artifact +4. **build-popular-apps** - builds all apps in parallel across macOS/Windows/Linux +5. **publish-docker** - builds and pushes Docker image to GHCR + +The workflow can also be triggered manually via `workflow_dispatch` with options to build popular apps or publish Docker independently. + +Pushing the same `V*` tag also triggers `.github/workflows/npm-publish.yml`, which publishes `pake-cli` to npm through Trusted Publishing. Configure the npm package's Trusted Publisher as GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, with no environment. Local `npm publish` is only a fallback when CI or npm registry state blocks the trusted path. + +Before treating an npm release as shipped, verify both `gh workflow list --all | grep "Publish npm Package"` and `npm view pake-cli@X.Y.Z version`. Prefer `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` so the published package can be tied back to the intended commit. Do not reply to or close GitHub issues as released until the public registry returns the expected version. + +For release follow-through, keep these boundaries explicit: + +- `workflow_dispatch` runs on a branch unless a tag ref or input is supplied. Do not infer a release tag from the branch name, run title, or compare UI. +- For CLI/npm issue closeout, the npm registry is the decisive public surface. GitHub app release assets and quality workflows should still be reported, but they are separate surfaces. +- For app-release claims, inspect the GitHub Release directly with `gh release view --json assets` and check asset count/state instead of trusting source state or workflow names alone. +- If CI pushes an automatic `chore: update contributors [skip ci]` commit after release, fast-forward local `main`; do not move an already pushed release tag to include it. + +`.github/workflows/quality-and-test.yml` runs auto-format on push, Rust quality checks, and CLI/build validation across Linux, Windows, and macOS. + +### Network Mirror Behavior + +Pake uses official npm and Rust sources by default. CN mirrors are explicit opt-in only: + +- Set `PAKE_USE_CN_MIRROR=1` only when the user or CI environment intentionally wants npmmirror/rsProxy. +- Do not reintroduce automatic China-domain mirror switching. +- If an install fails against a CN mirror, retry the same install command to separate network availability from a product regression. +- `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts` own this behavior; keep docs and tests aligned when changing it. + +## CLI Usage Example + +```bash +# Install CLI +pnpm install -g pake-cli + +# Basic usage +pake https://github.com --name GitHub + +# Advanced usage +pake https://weekly.tw93.fun --name Weekly --width 1200 --height 800 +``` + +## Troubleshooting + +See `docs/faq.md` for common issues and solutions. + +### macOS SDK / Compile Errors + +If compilation errors occur (e.g. on macOS beta), create `src-tauri/.cargo/config.toml`: + +```toml +[env] +MACOSX_DEPLOYMENT_TARGET = "15.0" +SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" +``` + +This file is already in `.gitignore`. + +### `dist/cli.js` out of sync with `bin/` + +Symptom: tests or release builds use stale CLI behavior after a `bin/` edit. Fix with `pnpm run cli:build` and commit the regenerated `dist/cli.js`. + +### First Tauri build is slow + +The first `cargo build` on a fresh clone takes 10+ minutes as Cargo compiles every Tauri dependency from source. Subsequent builds reuse the `src-tauri/target/` cache. This is expected, not a bug. + +## Documentation Guidelines + +- **Main README**: keep only common, frequently-used parameters to avoid clutter. +- **CLI Documentation** (`docs/cli-usage.md` and locale variants): include **all** CLI parameters with detailed usage examples. +- **Rare or advanced parameters**: should have full documentation in `docs/cli-usage*.md` but minimal or no mention in the main README. Examples: `--title`, `--incognito`, `--system-tray-icon`, `--multi-window`, `--min-width`, `--min-height`. +- **Key configuration files**: + - `pake.json` - default app configuration. + - `src-tauri/tauri.conf.json` - shared Tauri settings. + - `src-tauri/tauri.{macos,windows,linux}.conf.json` - per-platform overrides. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index e4065f9bfa..888224d6ea 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -8,7 +8,7 @@ - + tw93 @@ -19,7 +19,7 @@ - + Tlntin @@ -30,7 +30,7 @@ - + jeasonnow @@ -85,7 +85,7 @@ - + liby @@ -96,19 +96,19 @@ - + essesoul - + - - - AielloChan + + + artrixdotdev @@ -118,33 +118,66 @@ - + YangguangZhou + + + + + + + + AielloChan + + + - + m1911star - + - + - - - GXiang314 + + + mgaldamez - + + + + + + + + + Ghraven + + + + + + + + + + + gxiang314 + + + @@ -155,51 +188,62 @@ Pake-Actions - + - + exposir - + - + lkieryan - + - + g1eny0ung - + - + xinyii - + + + + + + + + + xantorres + + + @@ -210,106 +254,117 @@ Tianj0o - + - + QingZ11 - + - + vaddisrinivas - + - + mattbajorek - + - + kittizz - + - + eltociear - + - + GoodbyeNJN - + - + - - - princemaple + + + AllDaGearNoIdea - + + + + + + + + + 2nthony + + + - + RoyRao2333 - + - + sebastianbreguel - + @@ -320,29 +375,40 @@ youxi798 - + - + fulldecent - + + + + + + + + + a5677746shdh + + + - + beautifulrem - + @@ -353,7 +419,7 @@ bocanhcam - + @@ -364,29 +430,29 @@ dbraendle - + - + - - - geekvest + + + ekishion - + - + - - - houhoz + + + geekvest - + @@ -397,29 +463,29 @@ lakca - + - + liudonghua123 - + - + liusishan - + @@ -430,18 +496,18 @@ piaoyidage - + - + enihsyou - + @@ -452,158 +518,180 @@ hetz - + - + - - - pgoslatara + + + ACGNnsj - + - + - - - Milo123459 + + + kidylee - + - + - - - Jason6987 + + + Bortlesboat - + - + - - - JohannLai + + + nekomeowww - + - + - - - droid-Q + + + kuishou68 - + - + - - - ImgBotApp + + + turkyden - + - + - - - Fechin + + + kud - + - + fvn-elmy - + - + - - - turkyden + + + Fechin - + - + - - - kuishou68 + + + ImgBotApp - + - + - - - nekomeowww + + + droid-Q - + - + - - - kidylee + + + JohannLai - + - + - - - ACGNnsj + + + Jason6987 - + - + - - - 2nthony + + + Milo123459 + + + + + + + + + + + pgoslatara + + + + + + + + + + + princemaple \ No newline at end of file diff --git a/LICENSE b/LICENSE index b52b582ca3..f288702d2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2024 Tw93 - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/LICENSE-EXCEPTION b/LICENSE-EXCEPTION new file mode 100644 index 0000000000..bc68a88c5a --- /dev/null +++ b/LICENSE-EXCEPTION @@ -0,0 +1,20 @@ +Pake Output Exception + +This is an exception to the GNU General Public License version 3 (GPLv3), +under which Pake is licensed (see the LICENSE file). + +As an additional permission under section 7 of the GPLv3: + +When you use Pake, or a build of Pake produced by the standard Pake build +and packaging process, to generate a target application ("Pake Output"), +the portions of Pake incorporated into that Pake Output do not by +themselves cause the Pake Output as a whole to be or become subject to the +GPLv3. You may distribute such Pake Output under license terms of your own +choosing, including proprietary terms. + +This permission does NOT apply to Pake itself or to any modified version of +Pake's own source code: anyone who distributes Pake, or a work derived from +Pake's source code beyond the configuration and packaging performed by the +standard build process, remains fully bound by the GPLv3. + +Copyright (c) 2024 Tw93 and the Pake contributors. diff --git a/README.md b/README.md index af2500ff88..1b214ea66e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@
twitter - + telegram GitHub downloads @@ -19,7 +19,7 @@ ## Features -- 🎐 **Lightweight**: Nearly 20 times smaller than Electron packages, typically around 5M +- 🎐 **Lightweight**: Installer is nearly 20 times smaller than Electron packages, typically under 10M on disk - 🚀 **Fast**: Built with Rust Tauri, much faster than traditional JS frameworks with lower memory usage - ⚡ **Easy to use**: One-command packaging via CLI or online building, no complex configuration needed - 📦 **Feature-rich**: Supports shortcuts, immersive windows, drag & drop, style customization, ad removal @@ -177,7 +177,7 @@ First-time packaging requires environment setup and may be slower, subsequent bu ## Development -Requires Rust `>=1.85` and Node `>=22`. For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead. +Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=18` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead. ```bash # Install dependencies @@ -202,8 +202,16 @@ Pake's development can not be without these Hackers. They contributed a lot of c ## Support -- If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. -- Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. -- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. +- The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid Mac cleanup app. +- If Pake helped you, give it a star, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux), or open an issue or PR. +- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. - +
+These lovely people already did 🐱 +
+ +
+ +## License + +Pake is open source under GPL-3.0, see [LICENSE](./LICENSE) and [Pake Output Exception](./LICENSE-EXCEPTION); apps you build with Pake are entirely yours to use and distribute. If you fork Pake into your own product, to avoid confusion please give it a different name and credit Pake as the source. diff --git a/README_CN.md b/README_CN.md index 921d11a525..ec97872b4c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -7,7 +7,7 @@
twitter - + telegram GitHub downloads @@ -19,7 +19,7 @@ ## 特征 -- 🎐 **体积小巧**:相比 Electron 应用小近 20 倍,通常只有 5M 左右 +- 🎐 **体积小巧**:安装包相比 Electron 应用小近 20 倍,通常小于 10M - 🚀 **性能优异**:基于 Rust Tauri,比传统 JS 框架更快,内存占用更少 - ⚡ **使用简单**:命令行一键打包,或在线构建,无需复杂配置 - 📦 **功能丰富**:支持快捷键透传、沉浸式窗口、拖拽、样式定制、去广告 @@ -178,7 +178,7 @@ pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/week ## 定制开发 -需要 Rust `>=1.85` 和 Node `>=22`,详细安装指南参考 [Tauri 文档](https://tauri.app/start/prerequisites/)。不熟悉开发环境建议直接使用命令行工具。 +需要 Rust `>=1.85` 和 Node `>=22`(推荐 LTS,较旧的 `>=18` 也可使用),详细安装指南参考 [Tauri 文档](https://tauri.app/start/prerequisites/)。不熟悉开发环境建议直接使用命令行工具。 ```bash # 安装依赖 @@ -203,9 +203,18 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 支持 - - -1. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 +1. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),是对我最直接的支持。 2. 如果你喜欢 Pake,可以在 Github Star,更欢迎 [推荐](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20一键打包网页生成轻量桌面应用,比%20Electron%20小%2020%20倍,支持%20macOS%20Windows%20Linux) 给志同道合的朋友使用。 -3. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) 聊天群。 +3. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+9f9gf4ZrFSQ2OWVl) 聊天群。 4. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 +5. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 + +
+这些可爱的朋友已经喂过了 🐱 +
+ +
+ +## 开源协议 + +Pake 使用 GPL-3.0 协议开源,详见 [LICENSE](./LICENSE) 和 [Pake Output Exception](./LICENSE-EXCEPTION),用 Pake 打包生成的应用所有权完全归你,可以自由使用和分发;假如你想基于 fork 重新做一个 Pake 产品,为了避免误解,辛苦换一个名字,并注明来源。 diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000000..0a6cc231b3 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,14 @@ +# Trademark Policy + +"Pake" and the Pake logo are trademarks of the Pake project (Tw93). The code +license covers the code, not the brand. Open source licenses grant copyright, +not trademark. + +We want users to trust that something called "Pake" really is this project. So +if you publish a fork or a derived product, please: + +- Use your own name and icon, not "Pake" or the Pake logo. +- Don't imply your product is endorsed by or affiliated with Pake. +- Don't use the Pake name to market a paid or competing product. + +Permission requests: open an issue at . diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index 1a48ce2264..130eb7af2b 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -14,94 +14,49 @@ import { import { npmDirectory } from '@/utils/dir'; import { getSpinner } from '@/utils/info'; import { shellExec } from '@/utils/shell'; -import { isChinaDomain } from '@/utils/ip'; +import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; import { IS_MAC } from '@/utils/platform'; import logger from '@/options/logger'; +import { + configureCargoRegistry, + detectPackageManager, + getBuildEnvironment, + getBuildTimeout, + getInstallCommand, + getInstallTimeout, +} from './env'; +// Appended to the error when a Linux AppImage build fails for good. linuxdeploy's +// diagnostics stream to the terminal (stdio: 'inherit') and never reach +// error.message, so we cannot name the exact cause. We only reach here after +// NO_STRIP=1 has been applied and still failed, so strip is shown as ruled out. +const APPIMAGE_BAR = '━'.repeat(56); +const APPIMAGE_FAILURE_GUIDANCE = + `\n\n${APPIMAGE_BAR}\n` + + 'Linux AppImage Build Failed\n' + + `${APPIMAGE_BAR}\n\n` + + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + + ' the build still failed, so strip is likely not the cause.\n' + + ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + + " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' then: sudo gdk-pixbuf-query-loaders --update-cache\n' + + ' (Arch refreshes the cache automatically via a pacman hook)\n' + + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + + 'Still stuck? Build a DEB instead: pake --targets deb\n' + + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + APPIMAGE_BAR; export default abstract class BaseBuilder { protected options: PakeAppOptions; - private static packageManagerCache: string | null = null; protected constructor(options: PakeAppOptions) { this.options = options; } - private getBuildEnvironment() { - if (!IS_MAC) { - return undefined; - } - - const currentPath = process.env.PATH || ''; - const systemToolsPath = '/usr/bin'; - const buildPath = currentPath.startsWith(`${systemToolsPath}:`) - ? currentPath - : `${systemToolsPath}:${currentPath}`; - - return { - CFLAGS: '-fno-modules', - CXXFLAGS: '-fno-modules', - MACOSX_DEPLOYMENT_TARGET: '14.0', - PATH: buildPath, - }; - } - - private getInstallTimeout(): number { - // Windows needs more time due to native compilation and antivirus scanning - return process.platform === 'win32' ? 900000 : 600000; - } - - private getBuildTimeout(): number { - return 900000; - } - - private async detectPackageManager(): Promise { - if (BaseBuilder.packageManagerCache) { - return BaseBuilder.packageManagerCache; - } - - const { execa } = await import('execa'); - - try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ Using pnpm for package management.'); - BaseBuilder.packageManagerCache = 'pnpm'; - return 'pnpm'; - } catch { - try { - await execa('npm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ pnpm not available, using npm for package management.'); - BaseBuilder.packageManagerCache = 'npm'; - return 'npm'; - } catch { - throw new Error( - 'Neither pnpm nor npm is available. Please install a package manager.', - ); - } - } - } - - private async copyFileWithSamePathGuard( - sourcePath: string, - destinationPath: string, - ): Promise { - if (path.resolve(sourcePath) === path.resolve(destinationPath)) { - return; - } - - try { - await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('Source and destination must not be the same') - ) { - return; - } - - throw error; - } - } - async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); const tauriTargetPath = path.join(tauriSrcPath, 'target'); @@ -129,20 +84,13 @@ export default abstract class BaseBuilder { } } - const isChina = await isChinaDomain('www.npmjs.com'); const spinner = getSpinner('Installing package...'); - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - await fsExtra.ensureDir(rustProjectDir); - - // Detect available package manager - const packageManager = await this.detectPackageManager(); - const registryOption = ' --registry=https://registry.npmmirror.com'; - const peerDepsOption = - packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + const useCnMirror = isCnMirrorEnabled(); + await configureCargoRegistry(tauriSrcPath, useCnMirror); - const timeout = this.getInstallTimeout(); - const buildEnv = this.getBuildEnvironment(); + const packageManager = await detectPackageManager(); + const timeout = getInstallTimeout(); + const buildEnv = getBuildEnvironment(); // Show helpful message for first-time users if (!tauriTargetPathExists) { @@ -153,64 +101,26 @@ export default abstract class BaseBuilder { ); } - let usedMirror = isChina; + if (useCnMirror) { + logger.info( + `✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`, + ); + } try { - if (isChina) { - logger.info( - `✺ Located in China, using ${packageManager}/rsProxy CN mirror.`, - ); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - } else { - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - } + await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, { + ...buildEnv, + CI: 'true', + }); spinner.succeed(chalk.green('Package installed!')); - } catch (error: unknown) { - // If installation times out and we haven't tried the mirror yet, retry with mirror - if ( - error instanceof Error && - error.message.includes('timed out') && - !usedMirror - ) { - spinner.fail( - chalk.yellow('Installation timed out, retrying with CN mirror...'), - ); + } catch (error) { + spinner.fail(chalk.red('Installation failed')); + if (!useCnMirror) { logger.info( - '✺ Retrying installation with CN mirror for better speed...', + `✺ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`, ); - - const retrySpinner = getSpinner('Retrying installation...'); - usedMirror = true; - - try { - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - retrySpinner.succeed( - chalk.green('Package installed with CN mirror!'), - ); - } catch (retryError) { - retrySpinner.fail(chalk.red('Installation failed')); - throw retryError; - } - } else { - spinner.fail(chalk.red('Installation failed')); - throw error; } + throw error; } if (!tauriTargetPathExists) { @@ -228,7 +138,7 @@ export default abstract class BaseBuilder { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); const configPath = path.join( npmDirectory, 'src-tauri', @@ -246,12 +156,11 @@ export default abstract class BaseBuilder { await shellExec(command); } - async buildAndCopy(url: string, target: string) { + async buildAndCopy(url: string, target: string, logSuccess = true) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); - // Detect available package manager - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); // Build app const buildSpinner = getSpinner('Building app...'); @@ -261,7 +170,7 @@ export default abstract class BaseBuilder { // Show static message to keep the status visible logger.warn('✸ Building app...'); - const baseEnv = this.getBuildEnvironment(); + const baseEnv = getBuildEnvironment(); let buildEnv: Record = { ...(baseEnv ?? {}), ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}), @@ -270,44 +179,46 @@ export default abstract class BaseBuilder { const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined; - // Warn users about potential AppImage build failures on modern Linux systems. - // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't - // recognize the .relr.dyn section introduced in glibc 2.38+. - if (process.platform === 'linux' && target === 'appimage') { - if (!buildEnv.NO_STRIP) { - logger.warn( - '⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+', - ); - logger.warn( - '⚠ If build fails, retry with: NO_STRIP=1 pake --targets appimage', - ); - } + const isLinuxAppImage = + process.platform === 'linux' && target === 'appimage'; + + // AppImage builds can fail at the linuxdeploy strip step on glibc 2.38+. + // A real failure now prints full guidance, so only hint in debug mode. + if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) { + logger.warn( + '⚠ AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.', + ); } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; - const buildTimeout = this.getBuildTimeout(); + const buildTimeout = getBuildTimeout(); try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (error) { - const shouldRetryWithoutStrip = - process.platform === 'linux' && - target === 'appimage' && - !buildEnv.NO_STRIP && - this.isLinuxDeployStripError(error); + if (!isLinuxAppImage) { + throw error; + } - if (shouldRetryWithoutStrip) { - logger.warn( - '⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.', - ); - buildEnv = { - ...buildEnv, - NO_STRIP: '1', - }; - await shellExec(buildCommand, buildTimeout, resolveExecEnv()); - } else { + // linuxdeploy's diagnostics stream to the terminal (stdio: 'inherit') and + // never reach error.message, so we cannot classify the cause. strip is the + // most common AppImage failure, so retry once with NO_STRIP=1; if that + // (or an already-NO_STRIP run) still fails, surface all known causes. + if (buildEnv.NO_STRIP) { + (error as Error).message += APPIMAGE_FAILURE_GUIDANCE; throw error; } + + logger.warn( + '⚠ AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).', + ); + buildEnv = { ...buildEnv, NO_STRIP: '1' }; + try { + await shellExec(buildCommand, buildTimeout, resolveExecEnv()); + } catch (retryError) { + (retryError as Error).message += APPIMAGE_FAILURE_GUIDANCE; + throw retryError; + } } // Copy app @@ -323,8 +234,10 @@ export default abstract class BaseBuilder { } await fsExtra.remove(appPath); - logger.success('✔ Build success!'); - logger.success('✔ App installer located in', distPath); + if (logSuccess) { + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', distPath); + } // Log binary location if preserved if (this.options.keepBinary) { @@ -372,21 +285,6 @@ export default abstract class BaseBuilder { abstract getFileName(): string; - private isLinuxDeployStripError(error: unknown): boolean { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - return ( - message.includes('linuxdeploy') || - message.includes('failed to run linuxdeploy') || - message.includes('strip:') || - message.includes('unable to recognise the format of the input file') || - message.includes('appimage tool failed') || - message.includes('strip tool') - ); - } - protected static readonly ARCH_MAPPINGS: Record< string, Record @@ -506,9 +404,19 @@ export default abstract class BaseBuilder { } } + protected getCargoTargetDir(): string { + return process.env.CARGO_TARGET_DIR || path.join('src-tauri', 'target'); + } + + protected resolveBuildPath(npmDirectory: string, buildPath: string): string { + return path.isAbsolute(buildPath) + ? buildPath + : path.join(npmDirectory, buildPath); + } + protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; - return `src-tauri/target/${basePath}/bundle/`; + return path.join(this.getCargoTargetDir(), basePath, 'bundle'); } protected getBuildAppPath( @@ -520,8 +428,7 @@ export default abstract class BaseBuilder { const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); return path.join( - npmDirectory, - this.getBasePath(), + this.resolveBuildPath(npmDirectory, this.getBasePath()), bundleDir, `${fileName}.${fileType}`, ); @@ -561,14 +468,17 @@ export default abstract class BaseBuilder { // Handle cross-platform builds if (this.options.multiArch || this.hasArchSpecificTarget()) { return path.join( - npmDirectory, - this.getArchSpecificPath(), + this.resolveBuildPath(npmDirectory, this.getArchSpecificPath()), basePath, binaryName, ); } - return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName); + return path.join( + this.resolveBuildPath(npmDirectory, this.getCargoTargetDir()), + basePath, + binaryName, + ); } /** @@ -605,6 +515,6 @@ export default abstract class BaseBuilder { * Get architecture-specific path for binary */ protected getArchSpecificPath(): string { - return 'src-tauri/target'; // Override in subclasses if needed + return this.getCargoTargetDir(); // Override in subclasses if needed } } diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 41d13f0451..b38b0111f7 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -1,7 +1,16 @@ import path from 'path'; +import fsExtra from 'fs-extra'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; +import { shellExec } from '@/utils/shell'; +import { generateLinuxPackageName } from '@/utils/name'; +import { + LINUX_TARGET_TYPES, + filterLinuxTargets, + needsTemporaryDebForZst, +} from '@/utils/targets'; +import logger from '@/options/logger'; export default class LinuxBuilder extends BaseBuilder { private buildFormat: string; @@ -55,23 +64,195 @@ export default class LinuxBuilder extends BaseBuilder { } async build(url: string) { - const targetTypes = ['deb', 'appimage', 'rpm']; - const requestedTargets = this.options.targets - .split(',') - .map((t: string) => t.trim()); - - for (const target of targetTypes) { - if (requestedTargets.includes(target)) { - this.currentBuildType = target; - await this.buildAndCopy(url, target); + const targets = filterLinuxTargets(this.options.targets); + if (targets.length === 0) { + throw new Error( + `No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`, + ); + } + const useTemporaryDebForZst = needsTemporaryDebForZst(targets); + + // With a single explicit target, fail fast. With multiple targets (the + // distro-aware default, or an explicit comma list) keep building the rest + // when one fails, so a usable installer is still produced, e.g. AppImage + // survives a .deb bundler abort on RPM-based distros. + const isolateFailures = targets.length > 1; + const failed: string[] = []; + let firstError: Error | null = null; + + for (const target of targets) { + this.currentBuildType = target; + try { + if (target === 'zst') { + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); + } else { + await this.buildAndCopy(url, target); + } + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (!isolateFailures) { + throw err; + } + if (!firstError) { + firstError = err; + } + failed.push(target); + logger.warn( + `✼ Failed to build "${target}" target: ${err.message.split('\n')[0]}`, + ); + } + } + + // Every requested target failed: surface the first real error. + if (firstError && failed.length === targets.length) { + throw firstError; + } + + if (failed.length > 0) { + logger.warn( + `✼ Skipped failed Linux targets: ${failed.join(', ')}. Other formats built successfully.`, + ); + } + } + + private async ensureArchPackagingTools() { + const requiredTools = [ + { tool: 'ar', pacmanPackage: 'binutils' }, + { tool: 'bsdtar', pacmanPackage: 'libarchive' }, + ]; + for (const { tool, pacmanPackage } of requiredTools) { + try { + await shellExec(`command -v ${tool} >/dev/null 2>&1`); + } catch { + throw new Error( + `Building a zst package requires "${tool}". Install it first, e.g. "sudo pacman -S ${pacmanPackage}".`, + ); + } + } + } + + private async createArchPackageFromDeb({ + removeSourceDeb, + }: { + removeSourceDeb: boolean; + }) { + const { name = 'pake-app' } = this.options; + const packageName = generateLinuxPackageName(name); + const version = tauriConfig.version; + const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; + const debPath = path.resolve(`${name}.deb`); + const packagePath = path.resolve( + `${name}-${version}-1-${arch}.pkg.tar.zst`, + ); + const workDir = path.resolve('.pake-arch-package'); + const dataDir = path.join(workDir, 'data'); + const controlDir = path.join(workDir, 'control'); + + await this.ensureArchPackagingTools(); + await fsExtra.remove(workDir); + await fsExtra.ensureDir(dataDir); + await fsExtra.ensureDir(controlDir); + + try { + await shellExec(`cd "${controlDir}" && ar x "${debPath}"`); + const dataArchive = (await fsExtra.readdir(controlDir)).find((file) => + file.startsWith('data.tar'), + ); + if (!dataArchive) { + throw new Error(`Could not find data.tar payload in ${debPath}`); + } + + await shellExec( + `tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`, + ); + // Drop the desktop entry auto-generated by the Tauri deb bundler; + // the payload already ships Pake's own com.pake..desktop. + await fsExtra.remove( + path.join( + dataDir, + 'usr', + 'share', + 'applications', + `${packageName}.desktop`, + ), + ); + + const installedSize = await this.getDirectorySize(dataDir); + const pkgInfo = `pkgname = ${packageName} +pkgbase = ${packageName} +pkgver = ${version}-1 +pkgdesc = ${name} Pake app +url = https://github.com/tw93/Pake +builddate = ${Math.floor(Date.now() / 1000)} +packager = Pake +size = ${installedSize} +arch = ${arch} +license = custom +depend = cairo +depend = desktop-file-utils +depend = gdk-pixbuf2 +depend = glib2 +depend = gtk3 +depend = hicolor-icon-theme +depend = libsoup3 +depend = pango +depend = webkit2gtk-4.1 +`; + await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); + await fsExtra.writeFile( + path.join(dataDir, '.INSTALL'), + `post_install() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} + +post_upgrade() { + post_install +} + +post_remove() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} +`, + ); + await shellExec( + `bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`, + ); + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', packagePath); + } finally { + if (removeSourceDeb) { + await fsExtra.remove(debPath); } + await fsExtra.remove(workDir); } } + private async getDirectorySize(directory: string): Promise { + let size = 0; + for (const entry of await fsExtra.readdir(directory, { + withFileTypes: true, + })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + size += await this.getDirectorySize(entryPath); + } else if (entry.isFile()) { + size += (await fsExtra.stat(entryPath)).size; + } + } + return size; + } + // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. - async buildAndCopy(url: string, target: string) { + async buildAndCopy(url: string, target: string, logSuccess = true) { this.currentBuildType = target; - await super.buildAndCopy(url, target); + await super.buildAndCopy(url, target, logSuccess); } protected getBuildCommand(packageManager: string = 'pnpm'): string { @@ -112,7 +293,12 @@ export default class LinuxBuilder extends BaseBuilder { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Linux`, + ); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } return super.getBasePath(); @@ -132,7 +318,12 @@ export default class LinuxBuilder extends BaseBuilder { protected getArchSpecificPath(): string { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Linux`, + ); + } + return path.join(this.getCargoTargetDir(), target); } return super.getArchSpecificPath(); } diff --git a/bin/builders/MacBuilder.ts b/bin/builders/MacBuilder.ts index ebf061d469..1fbf776ae3 100644 --- a/bin/builders/MacBuilder.ts +++ b/bin/builders/MacBuilder.ts @@ -15,7 +15,9 @@ export default class MacBuilder extends BaseBuilder { ? options.targets : 'auto'; + // `app` is a valid macOS bundle target (see merge.ts); honour it explicitly. if ( + options.targets === 'app' || options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1' @@ -76,7 +78,11 @@ export default class MacBuilder extends BaseBuilder { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}/${basePath}/bundle`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } protected hasArchSpecificTarget(): boolean { @@ -86,6 +92,9 @@ export default class MacBuilder extends BaseBuilder { protected getArchSpecificPath(): string { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target); } } diff --git a/bin/builders/WinBuilder.ts b/bin/builders/WinBuilder.ts index 4a3ffc809e..7b1f85e00d 100644 --- a/bin/builders/WinBuilder.ts +++ b/bin/builders/WinBuilder.ts @@ -2,6 +2,7 @@ import path from 'path'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; +import { generateIdentifierSafeName } from '@/utils/name'; export default class WinBuilder extends BaseBuilder { private buildFormat: string = 'msi'; @@ -39,7 +40,12 @@ export default class WinBuilder extends BaseBuilder { protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Windows`, + ); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } protected hasArchSpecificTarget(): boolean { @@ -48,6 +54,19 @@ export default class WinBuilder extends BaseBuilder { protected getArchSpecificPath(): string { const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Windows`, + ); + } + return path.join(this.getCargoTargetDir(), target); + } + + protected getRawBinaryPath(appName: string): string { + return `${appName}.exe`; + } + + protected getBinaryName(appName: string): string { + return `pake-${generateIdentifierSafeName(appName)}.exe`; } } diff --git a/bin/builders/env.ts b/bin/builders/env.ts new file mode 100644 index 0000000000..8b5b578069 --- /dev/null +++ b/bin/builders/env.ts @@ -0,0 +1,206 @@ +import path from 'path'; +import fsExtra from 'fs-extra'; + +import { CN_MIRROR_ENV } from '@/utils/mirror'; +import { IS_MAC } from '@/utils/platform'; +import { npmDirectory } from '@/utils/dir'; +import logger from '@/options/logger'; +import packageJson from '../../package.json'; + +/** + * Returns build environment variables overrides for macOS, where Rust crates + * sometimes need explicit C/C++ flags and a deterministic SDK target. Other + * platforms inherit `process.env` unchanged. + */ +export function getBuildEnvironment(): Record | undefined { + if (!IS_MAC) { + return undefined; + } + + const currentPath = process.env.PATH || ''; + const systemToolsPath = '/usr/bin'; + const buildPath = currentPath.startsWith(`${systemToolsPath}:`) + ? currentPath + : `${systemToolsPath}:${currentPath}`; + + return { + CFLAGS: '-fno-modules', + CXXFLAGS: '-fno-modules', + MACOSX_DEPLOYMENT_TARGET: '14.0', + PATH: buildPath, + }; +} + +/** + * Windows needs more time due to native compilation and antivirus scanning. + */ +export function getInstallTimeout(): number { + return process.platform === 'win32' ? 900_000 : 600_000; +} + +export function getBuildTimeout(): number { + return 900_000; +} + +let packageManagerCache: 'pnpm' | 'npm' | null = null; + +function parseMajorVersion(version: string): number | null { + const match = version.match(/^v?(\d+)/); + return match ? Number(match[1]) : null; +} + +function getPinnedPnpmMajorVersion(): number | null { + const packageManager = packageJson.packageManager; + const match = packageManager?.match(/^pnpm@(\d+)/); + return match ? Number(match[1]) : null; +} + +async function detectNpm( + execa: typeof import('execa').execa, +): Promise { + try { + await execa('npm', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +/** Resets the cached package manager. Exported for tests. */ +export function _resetPackageManagerCache(): void { + packageManagerCache = null; +} + +/** + * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. + * Cached after the first successful detection so tests can call repeatedly. + */ +export async function detectPackageManager(): Promise<'pnpm' | 'npm'> { + if (packageManagerCache) { + return packageManagerCache; + } + + const { execa } = await import('execa'); + let pnpmVersion: string; + + try { + const { stdout } = await execa('pnpm', ['--version']); + pnpmVersion = stdout.trim(); + } catch { + if (await detectNpm(execa)) { + logger.info('✺ pnpm not available, using npm for package management.'); + packageManagerCache = 'npm'; + return 'npm'; + } + + throw new Error( + 'Neither pnpm nor npm is available. Please install a package manager.', + ); + } + + const normalizedPnpmVersion = pnpmVersion.startsWith('v') + ? pnpmVersion + : `v${pnpmVersion}`; + const pnpmMajor = parseMajorVersion(pnpmVersion); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + + if ( + pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor + ) { + if (!(await detectNpm(execa))) { + throw new Error( + `Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}. Install npm so Pake can fall back, or use pnpm ${pinnedPnpmMajor}.x to match the project pin.`, + ); + } + + logger.warn( + `✼ Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}; using npm for package management instead.`, + ); + packageManagerCache = 'npm'; + return 'npm'; + } + + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; +} + +export function getInstallCommand( + packageManager: string, + useCnMirror: boolean, +): string { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; +} + +async function copyFileWithSamePathGuard( + sourcePath: string, + destinationPath: string, +): Promise { + if (path.resolve(sourcePath) === path.resolve(destinationPath)) { + return; + } + try { + await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Source and destination must not be the same') + ) { + return; + } + throw error; + } +} + +function isGeneratedCnMirrorConfig( + projectConfig: string, + cnMirrorConfig: string, +): boolean { + return projectConfig.trim() === cnMirrorConfig.trim(); +} + +/** + * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in + * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config + * (or warns about a manual one) when they opt out. + */ +export async function configureCargoRegistry( + tauriSrcPath: string, + useCnMirror: boolean, +): Promise { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await copyFileWithSamePathGuard(projectCnConf, projectConf); + return; + } + + if (!(await fsExtra.pathExists(projectConf))) { + return; + } + + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + + if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + + if (projectConfig.includes('rsproxy.cn')) { + logger.warn( + `✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`, + ); + } +} diff --git a/bin/cli.ts b/bin/cli.ts index 69f4238169..8fe6738c28 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -1,9 +1,11 @@ import log from 'loglevel'; +import chalk from 'chalk'; import updateNotifier from 'update-notifier'; import packageJson from '../package.json'; import BuilderProvider from './builders/BuilderProvider'; import handleInputOptions from './options/index'; import { getCliProgram } from './helpers/cli-program'; +import { isPakeError } from './utils/error'; import { PakeCliOptions } from './types'; const program = getCliProgram(); @@ -15,26 +17,47 @@ async function checkUpdateTips() { } program.action(async (url: string, options: PakeCliOptions) => { - await checkUpdateTips(); - - if (!url) { - program.help({ - error: false, - }); - return; + try { + await checkUpdateTips(); + + if (!url) { + program.help({ + error: false, + }); + return; + } + + log.setDefaultLevel('info'); + log.setLevel('info'); + if (options.debug) { + log.setLevel('debug'); + } + + const appOptions = await handleInputOptions(options, url); + + const builder = BuilderProvider.create(appOptions); + await builder.prepare(); + await builder.build(url); + } catch (error) { + if (isPakeError(error)) { + console.error(chalk.red(error.message)); + } else if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + if (options?.debug && error.stack) { + console.error(chalk.gray(error.stack)); + } + } else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); + } + process.exit(1); } +}); - log.setDefaultLevel('info'); - log.setLevel('info'); - if (options.debug) { - log.setLevel('debug'); +program.parseAsync().catch((error: unknown) => { + if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + } else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); } - - const appOptions = await handleInputOptions(options, url); - - const builder = BuilderProvider.create(appOptions); - await builder.prepare(); - await builder.build(url); + process.exit(1); }); - -program.parse(); diff --git a/bin/defaults.ts b/bin/defaults.ts index 6fb41c02d9..a9539d9a8a 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -1,4 +1,5 @@ import { PakeCliOptions } from './types.js'; +import { getDefaultLinuxTargets } from './utils/platform.js'; export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { icon: '', @@ -19,7 +20,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return getDefaultLinuxTargets(); case 'darwin': return 'dmg'; case 'win32': @@ -44,6 +45,8 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + safeDomain: '', + enableFind: false, iterativeBuild: false, zoom: 100, minWidth: 0, @@ -54,11 +57,3 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { camera: false, microphone: false, }; - -// Just for cli development -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: 'https://weekly.tw93.fun/en', - name: 'Weekly', - hideTitleBar: true, -}; diff --git a/bin/dev.ts b/bin/dev.ts index 4e4f1b113a..02ec81b34a 100644 --- a/bin/dev.ts +++ b/bin/dev.ts @@ -7,6 +7,13 @@ import { getCliProgram } from './helpers/cli-program'; const program = getCliProgram(); program.action(async (url: string, options: PakeCliOptions) => { + if (!url) { + program.help({ + error: false, + }); + return; + } + log.setDefaultLevel('debug'); const appOptions = await handleInputOptions(options, url); diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index e5a748fdbb..5e00f1f702 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -16,6 +16,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with return program .addHelpText('beforeAll', logo) .usage(`[url] [options]`) + .helpOption('-h, --help', 'Show all CLI options') .showHelpAfterError() .argument('[url]', 'The web URL you want to package', validateUrlInput) .option('--name ', 'Application name') @@ -102,7 +103,10 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .hideHelp(), ) .addOption( - new Option('--dark-mode', 'Force Mac app to use dark mode') + new Option( + '--dark-mode', + 'Force app to use dark mode (supports macOS, Windows, and Linux)', + ) .default(DEFAULT.darkMode) .hideHelp(), ) @@ -183,16 +187,26 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option( '--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers', - ) - .default(DEFAULT.forceInternalNavigation) - .hideHelp(), + ).default(DEFAULT.forceInternalNavigation), ) .addOption( new Option( '--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal', + ).default(DEFAULT.internalUrlRegex), + ) + .addOption( + new Option( + '--safe-domain ', + 'Comma-separated domains kept inside the app (e.g. SSO/workspace callbacks)', + ).default(DEFAULT.safeDomain), + ) + .addOption( + new Option( + '--enable-find', + 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts', ) - .default(DEFAULT.internalUrlRegex) + .default(DEFAULT.enableFind) .hideHelp(), ) .addOption( @@ -204,8 +218,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option('--zoom ', 'Initial page zoom level (50-200)') .default(DEFAULT.zoom) .argParser((value) => { - const zoom = parseInt(value); - if (isNaN(zoom) || zoom < 50 || zoom > 200) { + const zoom = Number(value); + if (!Number.isFinite(zoom) || zoom < 50 || zoom > 200) { throw new Error('--zoom must be a number between 50 and 200'); } return zoom; @@ -244,9 +258,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option( '--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)', - ) - .default(DEFAULT.newWindow) - .hideHelp(), + ).default(DEFAULT.newWindow), ) .addOption( new Option( @@ -269,14 +281,19 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .version(packageJson.version, '-v, --version') .configureHelp({ sortSubcommands: true, + visibleOptions: (command) => { + const options = [...command.options]; + const helpOption = (command as unknown as { _helpOption?: Option }) + ._helpOption; + if (helpOption) { + options.push(helpOption); + } + return options; + }, optionTerm: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.flags; }, optionDescription: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.description; }, }); diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index fa6503786f..10f587504e 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -17,6 +17,49 @@ import { WindowConfig, } from '@/types'; import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; +import { LINUX_TARGET_TYPES } from '@/utils/targets'; + +/** + * Pure transform from CLI options to the window-config slice that gets + * merged into pake.json. Exposed for snapshot testing so option drift + * (e.g. a new flag added in cli-program.ts but forgotten here) is caught. + * + * Keep this function side-effect free. + */ +export function buildWindowConfigOverrides( + options: PakeAppOptions, + platform: SupportedPlatform = asSupportedPlatform(process.platform), +): Partial { + const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + const platformHideTitleBar = + platform === 'darwin' ? options.hideTitleBar : false; + return { + width: options.width, + height: options.height, + fullscreen: options.fullscreen, + maximize: options.maximize, + resizable: options.resizable ?? true, + hide_title_bar: platformHideTitleBar, + activation_shortcut: options.activationShortcut, + always_on_top: options.alwaysOnTop, + dark_mode: options.darkMode, + disabled_web_shortcuts: options.disabledWebShortcuts, + hide_on_close: platformHideOnClose, + incognito: options.incognito, + title: options.title, + enable_wasm: options.wasm, + enable_drag_drop: options.enableDragDrop, + start_to_tray: options.startToTray && options.showSystemTray, + force_internal_navigation: options.forceInternalNavigation, + internal_url_regex: options.internalUrlRegex, + enable_find: options.enableFind, + zoom: options.zoom, + min_width: options.minWidth, + min_height: options.minHeight, + ignore_certificate_errors: options.ignoreCertificateErrors, + new_window: options.newWindow, + }; +} type PlatformIconInfo = { fileExt: string; @@ -148,19 +191,16 @@ Terminal=false }; const validTargets = [ - 'deb', - 'appimage', - 'rpm', - 'deb-arm64', - 'appimage-arm64', - 'rpm-arm64', + ...LINUX_TARGET_TYPES, + ...LINUX_TARGET_TYPES.map((target) => `${target}-arm64`), ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { - tauriConf.bundle.targets = [baseTarget]; + // zst is repacked from the deb payload, so Tauri itself bundles a deb. + tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { logger.warn( `✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`, @@ -388,68 +428,25 @@ export async function mergeConfig( await copyTemplateConfigs(); const { - width, - height, - fullscreen, - maximize, - hideTitleBar, - alwaysOnTop, appVersion, - darkMode, - disabledWebShortcuts, - activationShortcut, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', - resizable = true, installerLanguage, - hideOnClose, - incognito, - title, wasm, - enableDragDrop, - startToTray, - forceInternalNavigation, - internalUrlRegex, - zoom, - minWidth, - minHeight, - ignoreCertificateErrors, - newWindow, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); - const platformHideOnClose = hideOnClose ?? platform === 'darwin'; - - const tauriConfWindowOptions: Partial = { - width, - height, - fullscreen, - maximize, - resizable, - hide_title_bar: hideTitleBar, - activation_shortcut: activationShortcut, - always_on_top: alwaysOnTop, - dark_mode: darkMode, - disabled_web_shortcuts: disabledWebShortcuts, - hide_on_close: platformHideOnClose, - incognito, - title, - enable_wasm: wasm, - enable_drag_drop: enableDragDrop, - start_to_tray: startToTray && showSystemTray, - force_internal_navigation: forceInternalNavigation, - internal_url_regex: internalUrlRegex, - zoom, - min_width: minWidth, - min_height: minHeight, - ignore_certificate_errors: ignoreCertificateErrors, - new_window: newWindow, - }; + if (options.hideTitleBar && platform !== 'darwin') { + logger.warn( + '✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.', + ); + } + const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; diff --git a/bin/helpers/rust.ts b/bin/helpers/rust.ts index 946408c87e..4a61f76ae2 100644 --- a/bin/helpers/rust.ts +++ b/bin/helpers/rust.ts @@ -5,9 +5,9 @@ import chalk from 'chalk'; import { execaSync } from 'execa'; import { getSpinner } from '@/utils/info'; +import { isCnMirrorEnabled } from '@/utils/mirror'; import { IS_WIN } from '@/utils/platform'; import { shellExec } from '@/utils/shell'; -import { isChinaDomain } from '@/utils/ip'; function normalizePathForComparison(targetPath: string) { const normalized = path.normalize(targetPath); @@ -68,19 +68,16 @@ export function ensureRustEnv() { } export async function installRust() { - const isActions = process.env.GITHUB_ACTIONS; - const isInChina = await isChinaDomain('sh.rustup.rs'); - const rustInstallScriptForMac = - isInChina && !isActions - ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' - : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; + const rustInstallScriptForUnix = isCnMirrorEnabled() + ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' + : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { await shellExec( - IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, + IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined, ); diff --git a/bin/options/icon.ts b/bin/options/icon.ts index a068fb87b3..ca0c29664d 100644 --- a/bin/options/icon.ts +++ b/bin/options/icon.ts @@ -17,7 +17,12 @@ import { } from '@/utils/icon-source'; import { generateLinuxPackageName, getSafeAppName } from '@/utils/name'; import { PakeAppOptions } from '@/types'; -import { writeIcoWithPreferredSize, buildIcoFromPngBuffers } from '@/utils/ico'; +import { + ensureMultiResolutionIco, + writeIcoWithPreferredSize, + buildIcoFromPngBuffers, + WIN_STANDARD_ICO_SIZES, +} from '@/utils/ico'; type PlatformIconConfig = { format: string; @@ -43,7 +48,7 @@ const ICON_CONFIG = { } as const; const PLATFORM_CONFIG: Record<'win' | 'linux' | 'macos', PlatformIconConfig> = { - win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] }, + win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] }, linux: { format: '.png', size: 512 }, macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] }, }; @@ -89,14 +94,23 @@ async function copyWindowsIconIfNeeded( try { const finalIconPath = generateIconPath(appName); await fsExtra.ensureDir(path.dirname(finalIconPath)); - // Reorder ICO to prioritize 256px icons for better Windows display - const reordered = await writeIcoWithPreferredSize( + // Re-render ICO so every Windows standard size is present and prefer the + // 256px frame as the leading entry; falls back to plain reordering if the + // ICO is non-decodable, then to a raw copy. (Issue #1190) + const upgraded = await ensureMultiResolutionIco( convertedPath, finalIconPath, 256, ); - if (!reordered) { - await fsExtra.copy(convertedPath, finalIconPath); + if (!upgraded) { + const reordered = await writeIcoWithPreferredSize( + convertedPath, + finalIconPath, + 256, + ); + if (!reordered) { + await fsExtra.copy(convertedPath, finalIconPath); + } } return finalIconPath; } catch (error) { diff --git a/bin/options/index.ts b/bin/options/index.ts index 43618aaed5..73fb4955fe 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -3,13 +3,14 @@ import fsExtra from 'fs-extra'; import logger from '@/options/logger'; import { handleIcon } from './icon'; -import { getDomain } from '@/utils/url'; +import { getDomain, safeDomainsToRegex } from '@/utils/url'; import { promptText, capitalizeFirstLetter, resolveIdentifier, } from '@/utils/info'; import { generateLinuxPackageName } from '@/utils/name'; +import { PakeError } from '@/utils/error'; import { PakeAppOptions, PakeCliOptions } from '@/types'; function resolveAppName(name: string, platform: NodeJS.Platform): string { @@ -17,7 +18,7 @@ function resolveAppName(name: string, platform: NodeJS.Platform): string { return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain; } -function resolveLocalAppName( +export function resolveLocalAppName( filePath: string, platform: NodeJS.Platform, ): string { @@ -26,18 +27,18 @@ function resolveLocalAppName( return generateLinuxPackageName(baseName) || 'pake-app'; } const normalized = baseName - .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '') - .replace(/^[ -]+/, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff .-]/g, '') + .replace(/^[ .-]+/, '') .replace(/\s+/g, ' ') .trim(); return normalized || 'pake-app'; } -function isValidName(name: string, platform: NodeJS.Platform): boolean { +export function isValidName(name: string, platform: NodeJS.Platform): boolean { const reg = platform === 'linux' ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/ - : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/; + : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff .-]*$/; return !!name && reg.test(name); } @@ -65,15 +66,15 @@ export default async function handleOptions( if (name && !isValidName(name, platform)) { const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`; - const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`; + const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dots, dashes, and spaces (not leading dots, dashes, and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, Vectorizer.AI, 123.`; const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; - logger.error(errorMsg); if (isActions) { + logger.error(errorMsg); name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); } else { - process.exit(1); + throw new PakeError(errorMsg); } } @@ -85,6 +86,11 @@ export default async function handleOptions( identifier: resolveIdentifier(url, options.name, options.identifier), }; + // --safe-domain is sugar over --internal-url-regex; an explicit regex wins. + if (!options.internalUrlRegex && options.safeDomain) { + appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain); + } + const iconPath = await handleIcon(appOptions, url); appOptions.icon = iconPath || ''; diff --git a/bin/types.ts b/bin/types.ts index 9c947774ff..b6318be45b 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -38,7 +38,7 @@ export interface PakeCliOptions { // App version, the same as package.json version, default 1.0.0 appVersion: string; - // Force Mac to use dark mode, default false + // Force app to use dark mode (supports macOS, Windows, and Linux), default false darkMode: boolean; // Disable web shortcuts, default false @@ -63,7 +63,7 @@ export interface PakeCliOptions { multiArch: boolean; // Build target architecture/format: - // Linux: "deb", "appimage", "deb-arm64", "appimage-arm64"; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal" + // Linux: "deb", "appimage", "rpm", "zst" and "*-arm64" variants; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal" targets: string; // Debug mode, outputs more logs @@ -108,6 +108,12 @@ export interface PakeCliOptions { // Regex pattern to match URLs that should be considered internal internalUrlRegex: string; + // Comma-separated domains kept inside the app, compiled into internalUrlRegex, default empty + safeDomain: string; + + // Enable in-page Find UI and Cmd/Ctrl+F/G shortcuts, default false + enableFind: boolean; + // Initial page zoom level (50-200), default 100 zoom: number; @@ -167,6 +173,7 @@ export interface WindowConfig { start_to_tray: boolean; force_internal_navigation: boolean; internal_url_regex: string; + enable_find: boolean; zoom: number; min_width: number; min_height: number; diff --git a/bin/utils/error.ts b/bin/utils/error.ts new file mode 100644 index 0000000000..db6412065a --- /dev/null +++ b/bin/utils/error.ts @@ -0,0 +1,25 @@ +/** + * Error class used for user-facing CLI errors. + * + * The top-level catch in `bin/cli.ts` prints `message` directly without a + * stack trace and exits with code 1. Use this for predictable failures + * (invalid names, missing files, etc.) so users see a clean message instead + * of a Node.js stack dump. + */ +export class PakeError extends Error { + readonly isUserError = true; + + constructor(message: string) { + super(message); + this.name = 'PakeError'; + } +} + +export function isPakeError(error: unknown): error is PakeError { + return ( + error instanceof PakeError || + (typeof error === 'object' && + error !== null && + (error as { isUserError?: boolean }).isUserError === true) + ); +} diff --git a/bin/utils/ico.ts b/bin/utils/ico.ts index c082cba8c0..182d51cf4d 100644 --- a/bin/utils/ico.ts +++ b/bin/utils/ico.ts @@ -1,9 +1,13 @@ import path from 'path'; import fsExtra from 'fs-extra'; +import sharp from 'sharp'; const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; const ICO_TYPE_ICON = 1; +// Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48), +// shell (48/256) and high-DPI (128/256). Issue #1190. +export const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; type IcoEntry = { index: number; @@ -142,6 +146,122 @@ export async function writeIcoWithPreferredSize( } } +/** + * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an + * embedded PNG payload (PNG-in-ICO, supported since Windows Vista). + */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +function frameLooksLikePng(entry: IcoEntry): boolean { + return ( + entry.data.length >= PNG_SIGNATURE.length && + entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) + ); +} + +async function decodeFrameToPng(entry: IcoEntry): Promise { + if (frameLooksLikePng(entry)) { + return Buffer.from(entry.data); + } + // BMP DIB frames need to go through sharp's ico-to-PNG path, which only + // works on the full ICO container. Fall back to letting the caller use a + // sharp pipeline against the original ICO for the missing source. + return null; +} + +async function pickLargestFrameAsPng( + buffer: Buffer, + entries: IcoEntry[], +): Promise { + const largest = [...entries].sort( + (a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height), + )[0]; + if (largest) { + const decoded = await decodeFrameToPng(largest); + if (decoded) { + return decoded; + } + } + + // Fallback: let sharp render directly from the ICO buffer. sharp picks the + // largest embedded frame on its own. + try { + return await sharp(buffer).png().toBuffer(); + } catch { + return null; + } +} + +/** + * Ensures the produced ICO carries every Windows standard size so the OS + * never has to downsample a 256x256 frame to 16x16 for the tray. + * Falls back to `writeIcoWithPreferredSize` if rendering fails. + * + * Issue #1190. + */ +export async function ensureMultiResolutionIco( + sourcePath: string, + outputPath: string, + preferredSize: number = 256, + desiredSizes: readonly number[] = WIN_STANDARD_ICO_SIZES, +): Promise { + try { + const sourceBuffer = await fsExtra.readFile(sourcePath); + const entries = parseIcoBuffer(sourceBuffer); + + const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries); + if (!sourcePng) { + return await writeIcoWithPreferredSize( + sourcePath, + outputPath, + preferredSize, + ); + } + + const frames = await Promise.all( + desiredSizes.map(async (size) => { + // Reuse an existing exact-size PNG frame when possible to keep any + // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting). + const exact = entries.find( + (entry) => entry.width === size && entry.height === size, + ); + if (exact && frameLooksLikePng(exact)) { + return { size, png: Buffer.from(exact.data) }; + } + const png = await sharp(sourcePng) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .png() + .toBuffer(); + return { size, png }; + }), + ); + + // Order frames so the preferred size lands first (Windows shell uses the + // first-listed frame as a quality hint when choosing which to display). + frames.sort((a, b) => { + const aExact = a.size === preferredSize ? 0 : 1; + const bExact = b.size === preferredSize ? 0 : 1; + if (aExact !== bExact) return aExact - bExact; + return b.size - a.size; + }); + + const icoBuffer = buildIcoFromPngBuffers(frames); + await fsExtra.ensureDir(path.dirname(outputPath)); + await fsExtra.outputFile(outputPath, icoBuffer); + return true; + } catch { + return await writeIcoWithPreferredSize( + sourcePath, + outputPath, + preferredSize, + ); + } +} + /** * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format * (supported since Windows Vista). This preserves alpha transparency. diff --git a/bin/utils/ip.ts b/bin/utils/ip.ts deleted file mode 100644 index daf299990f..0000000000 --- a/bin/utils/ip.ts +++ /dev/null @@ -1,57 +0,0 @@ -import dns from 'dns'; -import http from 'http'; -import { promisify } from 'util'; - -import logger from '@/options/logger'; - -const resolve = promisify(dns.resolve); - -const ping = async (host: string) => { - const lookup = promisify(dns.lookup); - const ip = await lookup(host); - const start = new Date(); - - // Prevent timeouts from affecting user experience. - const requestPromise = new Promise((resolve, reject) => { - const req = http.get(`http://${ip.address}`, (res) => { - const delay = new Date().getTime() - start.getTime(); - res.resume(); - resolve(delay); - }); - - req.on('error', (err) => { - reject(err); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Request timed out after 3 seconds')); - }, 1000); - }); - - return Promise.race([requestPromise, timeoutPromise]); -}; - -async function isChinaDomain(domain: string): Promise { - try { - const [ip] = await resolve(domain); - return await isChinaIP(ip, domain); - } catch (error) { - logger.debug(`${domain} can't be parse!`); - return true; - } -} - -async function isChinaIP(ip: string, domain: string): Promise { - try { - const delay = await ping(ip); - logger.debug(`${domain} latency is ${delay} ms`); - return delay > 1000; - } catch (error) { - logger.debug(`ping ${domain} failed!`); - return true; - } -} - -export { isChinaDomain, isChinaIP }; diff --git a/bin/utils/mirror.ts b/bin/utils/mirror.ts new file mode 100644 index 0000000000..aa4d589c44 --- /dev/null +++ b/bin/utils/mirror.ts @@ -0,0 +1,7 @@ +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export const CN_MIRROR_ENV = 'PAKE_USE_CN_MIRROR'; + +export function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]): boolean { + return TRUE_VALUES.has((value ?? '').trim().toLowerCase()); +} diff --git a/bin/utils/name.ts b/bin/utils/name.ts index 2105d1aba2..abf71e5f9a 100644 --- a/bin/utils/name.ts +++ b/bin/utils/name.ts @@ -42,14 +42,3 @@ export function generateIdentifierSafeName(name: string): string { return cleaned; } - -export function generateWindowsFilename(name: string): string { - return name - .replace(/[<>:"/\\|?*]/g, '_') - .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, '$&_') - .slice(0, 255); -} - -export function generateMacOSFilename(name: string): string { - return name.replace(/[:]/g, '_').slice(0, 255); -} diff --git a/bin/utils/platform.ts b/bin/utils/platform.ts index 261581612c..155a522703 100644 --- a/bin/utils/platform.ts +++ b/bin/utils/platform.ts @@ -1,5 +1,101 @@ +import fs from 'fs'; + const { platform } = process; export const IS_MAC = platform === 'darwin'; export const IS_WIN = platform === 'win32'; export const IS_LINUX = platform === 'linux'; + +export type LinuxPackageFamily = 'deb' | 'rpm'; + +// Distro IDs / ID_LIKE families that ship an RPM-based package manager. +const RPM_FAMILY_IDS = new Set([ + 'rhel', + 'fedora', + 'centos', + 'rocky', + 'almalinux', + 'ol', // Oracle Linux + 'oracle', + 'amzn', // Amazon Linux + 'mariner', + 'azurelinux', + 'suse', + 'opensuse', + 'opensuse-leap', + 'opensuse-tumbleweed', + 'sles', +]); + +// Distro IDs / ID_LIKE families that ship a DEB-based package manager. +const DEB_FAMILY_IDS = new Set([ + 'debian', + 'ubuntu', + 'linuxmint', + 'pop', + 'elementary', + 'kali', + 'raspbian', + 'devuan', +]); + +// Parse the shell-style key=value pairs of an /etc/os-release file, stripping +// the optional surrounding quotes around values. +function parseOsRelease(content: string): Record { + const fields: Record = {}; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + if (key) fields[key] = value; + } + return fields; +} + +// Detect the package family from /etc/os-release. The distro's own ID wins over +// ID_LIKE hints, and an unknown distro falls back to 'deb' to preserve Pake's +// historical default. Accepts content directly so the decision is unit-testable +// without a real /etc/os-release. +export function detectLinuxPackageFamily( + osReleaseContent?: string, +): LinuxPackageFamily { + let content = osReleaseContent; + if (content === undefined) { + try { + content = fs.readFileSync('/etc/os-release', 'utf-8'); + } catch { + return 'deb'; + } + } + + const fields = parseOsRelease(content); + const id = (fields.ID ?? '').toLowerCase().trim(); + const idLike = (fields.ID_LIKE ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + + for (const token of [id, ...idLike]) { + if (DEB_FAMILY_IDS.has(token)) return 'deb'; + if (RPM_FAMILY_IDS.has(token)) return 'rpm'; + } + return 'deb'; +} + +// Default Linux bundle targets, chosen by the host distro's package family so +// RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) get a native .rpm +// instead of a .deb their package manager cannot install. AppImage stays as a +// universal fallback in both cases. +export function getDefaultLinuxTargets(): string { + return detectLinuxPackageFamily() === 'rpm' ? 'rpm,appimage' : 'deb,appimage'; +} diff --git a/bin/utils/shell.ts b/bin/utils/shell.ts index e857d8f74c..1c87d83faa 100644 --- a/bin/utils/shell.ts +++ b/bin/utils/shell.ts @@ -27,45 +27,11 @@ export async function shellExec( ); } - let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; - - // Provide helpful guidance for common Linux AppImage build failures - // caused by strip tool incompatibility with modern glibc (2.38+) - const lowerError = errorMessage.toLowerCase(); - - if ( - process.platform === 'linux' && - (lowerError.includes('linuxdeploy') || - lowerError.includes('appimage') || - lowerError.includes('strip')) - ) { - errorMsg += - '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'Linux AppImage Build Failed\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n' + - 'Cause: Strip tool incompatibility with glibc 2.38+\n' + - ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n' + - 'Quick fix:\n' + - ' NO_STRIP=1 pake --targets appimage --debug\n\n' + - 'Alternatives:\n' + - ' • Use DEB format: pake --targets deb\n' + - ' • Update binutils: sudo apt install binutils (or pacman -S binutils)\n' + - ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - - if ( - lowerError.includes('fuse') || - lowerError.includes('operation not permitted') || - lowerError.includes('/dev/fuse') - ) { - errorMsg += - '\n\nDocker / Container hint:\n' + - ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' + - ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' + - ' or run on the host directly.'; - } - } - - throw new Error(errorMsg); + // AppImage/linuxdeploy guidance is added by the caller (BaseBuilder), which + // knows the build target. We only have the command line here (the tool's + // diagnostics stream to the terminal via stdio:inherit, not into the error). + throw new Error( + `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`, + ); } } diff --git a/bin/utils/targets.ts b/bin/utils/targets.ts new file mode 100644 index 0000000000..99599d0e61 --- /dev/null +++ b/bin/utils/targets.ts @@ -0,0 +1,12 @@ +export const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst']; + +// Returns the valid Linux build targets from a comma-separated targets +// string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped. +export function filterLinuxTargets(targets: string): string[] { + const requested = targets.split(',').map((target) => target.trim()); + return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); +} + +export function needsTemporaryDebForZst(targets: string[]): boolean { + return targets.includes('zst') && !targets.includes('deb'); +} diff --git a/bin/utils/url.ts b/bin/utils/url.ts index 18292ba4bf..0bfca9dc77 100644 --- a/bin/utils/url.ts +++ b/bin/utils/url.ts @@ -40,3 +40,18 @@ export function normalizeUrl(urlToNormalize: string): string { ); } } + +// Compiles a comma-separated domain list into a regex source for +// internal_url_regex. Each domain is escaped and matched against the URL host +// and its subdomains so path or query text cannot accidentally opt a link in. +// Returns '' for empty input. +export function safeDomainsToRegex(domains: string): string { + const escaped = domains + .split(',') + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) + .map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return escaped.length + ? `^https?:\\/\\/(?:[^/?#@]+\\.)*(?:${escaped.join('|')})(?::\\d+)?(?:[/?#]|$)` + : ''; +} diff --git a/bin/utils/validate.ts b/bin/utils/validate.ts index 2ef83f0c2d..3ed66d681b 100644 --- a/bin/utils/validate.ts +++ b/bin/utils/validate.ts @@ -3,10 +3,16 @@ import { InvalidArgumentError } from 'commander'; import { normalizeUrl } from './url'; export function validateNumberInput(value: string) { + if (value.trim() === '') { + throw new InvalidArgumentError('Not a number.'); + } const parsedValue = Number(value); - if (isNaN(parsedValue)) { + if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } + if (parsedValue < 0) { + throw new InvalidArgumentError('Must not be negative.'); + } return parsedValue; } diff --git a/default_app_list.json b/default_app_list.json index e0e689833f..773b3c7593 100644 --- a/default_app_list.json +++ b/default_app_list.json @@ -3,7 +3,10 @@ "name": "wechat", "title": "WeChat", "name_zh": "微信", - "url": "https://wx.qq.com/" + "url": "https://wx.qq.com/", + "incognito": true, + "width": 1000, + "height": 720 }, { "name": "deepseek", diff --git a/dist/cli.js b/dist/cli.js index 9f4ddb8b59..33dac95be6 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1,40 +1,37 @@ #!/usr/bin/env node import log from 'loglevel'; +import chalk from 'chalk'; import updateNotifier from 'update-notifier'; import path from 'path'; import fsExtra from 'fs-extra'; import { fileURLToPath } from 'url'; -import chalk from 'chalk'; import prompts from 'prompts'; import os from 'os'; import { execa, execaSync } from 'execa'; import crypto from 'crypto'; import ora from 'ora'; -import dns from 'dns'; -import http from 'http'; -import { promisify } from 'util'; -import fs from 'fs/promises'; +import fs from 'fs'; +import fs$1 from 'fs/promises'; import { dir } from 'tmp-promise'; import { fileTypeFromBuffer } from 'file-type'; import icongen from 'icon-gen'; import sharp from 'sharp'; import * as psl from 'psl'; import { InvalidArgumentError, program as program$1, Option } from 'commander'; -import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.3"; +var version = "3.13.0"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" }; var packageManager = "pnpm@10.26.2"; var bin = { - pake: "./dist/cli.js" + pake: "dist/cli.js" }; var repository = { type: "git", - url: "https://github.com/tw93/pake.git" + url: "git+https://github.com/tw93/Pake.git" }; var author = { name: "Tw93", @@ -49,6 +46,7 @@ var keywords = [ "productivity" ]; var files = [ + "LICENSE-EXCEPTION", "dist", "src-tauri" ]; @@ -61,16 +59,18 @@ var scripts = { analyze: "cd src-tauri && cargo bloat --release --crates", tauri: "tauri", cli: "cross-env NODE_ENV=development rollup -c -w", + "cli:dev": "cross-env NODE_ENV=development rollup -c -w", "cli:build": "cross-env NODE_ENV=production rollup -c", test: "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", "format:check": "prettier --check . --ignore-unknown", + "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts", update: "pnpm update --verbose && cd src-tauri && cargo update", prepublishOnly: "pnpm run cli:build" }; var type = "module"; var exports$1 = "./dist/cli.js"; -var license = "MIT"; +var license = "GPL-3.0-or-later"; var dependencies = { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", @@ -220,10 +220,103 @@ function getSpinner(text) { }).start(); } +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const CN_MIRROR_ENV = 'PAKE_USE_CN_MIRROR'; +function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]) { + return TRUE_VALUES.has((value ?? '').trim().toLowerCase()); +} + const { platform: platform$1 } = process; const IS_MAC = platform$1 === 'darwin'; const IS_WIN = platform$1 === 'win32'; const IS_LINUX = platform$1 === 'linux'; +// Distro IDs / ID_LIKE families that ship an RPM-based package manager. +const RPM_FAMILY_IDS = new Set([ + 'rhel', + 'fedora', + 'centos', + 'rocky', + 'almalinux', + 'ol', // Oracle Linux + 'oracle', + 'amzn', // Amazon Linux + 'mariner', + 'azurelinux', + 'suse', + 'opensuse', + 'opensuse-leap', + 'opensuse-tumbleweed', + 'sles', +]); +// Distro IDs / ID_LIKE families that ship a DEB-based package manager. +const DEB_FAMILY_IDS = new Set([ + 'debian', + 'ubuntu', + 'linuxmint', + 'pop', + 'elementary', + 'kali', + 'raspbian', + 'devuan', +]); +// Parse the shell-style key=value pairs of an /etc/os-release file, stripping +// the optional surrounding quotes around values. +function parseOsRelease(content) { + const fields = {}; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) + continue; + const separator = line.indexOf('='); + if (separator === -1) + continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if (value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")))) { + value = value.slice(1, -1); + } + if (key) + fields[key] = value; + } + return fields; +} +// Detect the package family from /etc/os-release. The distro's own ID wins over +// ID_LIKE hints, and an unknown distro falls back to 'deb' to preserve Pake's +// historical default. Accepts content directly so the decision is unit-testable +// without a real /etc/os-release. +function detectLinuxPackageFamily(osReleaseContent) { + let content = osReleaseContent; + if (content === undefined) { + try { + content = fs.readFileSync('/etc/os-release', 'utf-8'); + } + catch { + return 'deb'; + } + } + const fields = parseOsRelease(content); + const id = (fields.ID ?? '').toLowerCase().trim(); + const idLike = (fields.ID_LIKE ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + for (const token of [id, ...idLike]) { + if (DEB_FAMILY_IDS.has(token)) + return 'deb'; + if (RPM_FAMILY_IDS.has(token)) + return 'rpm'; + } + return 'deb'; +} +// Default Linux bundle targets, chosen by the host distro's package family so +// RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) get a native .rpm +// instead of a .deb their package manager cannot install. AppImage stays as a +// universal fallback in both cases. +function getDefaultLinuxTargets() { + return detectLinuxPackageFamily() === 'rpm' ? 'rpm,appimage' : 'deb,appimage'; +} async function shellExec(command, timeout = 300000, env) { try { @@ -244,101 +337,10 @@ async function shellExec(command, timeout = 300000, env) { if (error.timedOut) { throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`); } - let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; - // Provide helpful guidance for common Linux AppImage build failures - // caused by strip tool incompatibility with modern glibc (2.38+) - const lowerError = errorMessage.toLowerCase(); - if (process.platform === 'linux' && - (lowerError.includes('linuxdeploy') || - lowerError.includes('appimage') || - lowerError.includes('strip'))) { - errorMsg += - '\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n' + - 'Linux AppImage Build Failed\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n' + - 'Cause: Strip tool incompatibility with glibc 2.38+\n' + - ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n' + - 'Quick fix:\n' + - ' NO_STRIP=1 pake --targets appimage --debug\n\n' + - 'Alternatives:\n' + - ' • Use DEB format: pake --targets deb\n' + - ' • Update binutils: sudo apt install binutils (or pacman -S binutils)\n' + - ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - if (lowerError.includes('fuse') || - lowerError.includes('operation not permitted') || - lowerError.includes('/dev/fuse')) { - errorMsg += - '\n\nDocker / Container hint:\n' + - ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' + - ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' + - ' or run on the host directly.'; - } - } - throw new Error(errorMsg); - } -} - -const logger = { - info(...msg) { - log.info(...msg.map((m) => chalk.white(m))); - }, - debug(...msg) { - log.debug(...msg); - }, - error(...msg) { - log.error(...msg.map((m) => chalk.red(m))); - }, - warn(...msg) { - log.warn(...msg.map((m) => chalk.yellow(m))); - }, - success(...msg) { - log.info(...msg.map((m) => chalk.green(m))); - }, -}; - -const resolve = promisify(dns.resolve); -const ping = async (host) => { - const lookup = promisify(dns.lookup); - const ip = await lookup(host); - const start = new Date(); - // Prevent timeouts from affecting user experience. - const requestPromise = new Promise((resolve, reject) => { - const req = http.get(`http://${ip.address}`, (res) => { - const delay = new Date().getTime() - start.getTime(); - res.resume(); - resolve(delay); - }); - req.on('error', (err) => { - reject(err); - }); - }); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Request timed out after 3 seconds')); - }, 1000); - }); - return Promise.race([requestPromise, timeoutPromise]); -}; -async function isChinaDomain(domain) { - try { - const [ip] = await resolve(domain); - return await isChinaIP(ip, domain); - } - catch (error) { - logger.debug(`${domain} can't be parse!`); - return true; - } -} -async function isChinaIP(ip, domain) { - try { - const delay = await ping(ip); - logger.debug(`${domain} latency is ${delay} ms`); - return delay > 1000; - } - catch (error) { - logger.debug(`ping ${domain} failed!`); - return true; + // AppImage/linuxdeploy guidance is added by the caller (BaseBuilder), which + // knows the build target. We only have the command line here (the tool's + // diagnostics stream to the terminal via stdio:inherit, not into the error). + throw new Error(`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`); } } @@ -389,15 +391,13 @@ function ensureRustEnv() { ensureCargoBinOnPath(); } async function installRust() { - const isActions = process.env.GITHUB_ACTIONS; - const isInChina = await isChinaDomain('sh.rustup.rs'); - const rustInstallScriptForMac = isInChina && !isActions + const rustInstallScriptForUnix = isCnMirrorEnabled() ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { - await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, 300000, undefined); + await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined); spinner.succeed(chalk.green('✔ Rust installed successfully!')); ensureRustEnv(); } @@ -426,7 +426,7 @@ function checkRustInstalled() { async function combineFiles(files, output) { const contents = await Promise.all(files.map(async (file) => { if (file.endsWith('.css')) { - const fileContent = await fs.readFile(file, 'utf-8'); + const fileContent = await fs$1.readFile(file, 'utf-8'); return `window.addEventListener('DOMContentLoaded', (_event) => { const css = ${JSON.stringify(fileContent)}; const style = document.createElement('style'); @@ -434,15 +434,33 @@ async function combineFiles(files, output) { document.head.appendChild(style); });`; } - const fileContent = await fs.readFile(file); + const fileContent = await fs$1.readFile(file); return ("window.addEventListener('DOMContentLoaded', (_event) => { " + fileContent + ' });'); })); - await fs.writeFile(output, contents.join('\n')); + await fs$1.writeFile(output, contents.join('\n')); return files; } +const logger = { + info(...msg) { + log.info(...msg.map((m) => chalk.white(m))); + }, + debug(...msg) { + log.debug(...msg); + }, + error(...msg) { + log.error(...msg.map((m) => chalk.red(m))); + }, + warn(...msg) { + log.warn(...msg.map((m) => chalk.yellow(m))); + }, + success(...msg) { + log.info(...msg.map((m) => chalk.green(m))); + }, +}; + function generateSafeFilename(name) { return name .replace(/[<>:"/\\|?*]/g, '_') @@ -480,6 +498,54 @@ function generateIdentifierSafeName(name) { return cleaned; } +const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst']; +// Returns the valid Linux build targets from a comma-separated targets +// string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped. +function filterLinuxTargets(targets) { + const requested = targets.split(',').map((target) => target.trim()); + return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); +} +function needsTemporaryDebForZst(targets) { + return targets.includes('zst') && !targets.includes('deb'); +} + +/** + * Pure transform from CLI options to the window-config slice that gets + * merged into pake.json. Exposed for snapshot testing so option drift + * (e.g. a new flag added in cli-program.ts but forgotten here) is caught. + * + * Keep this function side-effect free. + */ +function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) { + const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + const platformHideTitleBar = platform === 'darwin' ? options.hideTitleBar : false; + return { + width: options.width, + height: options.height, + fullscreen: options.fullscreen, + maximize: options.maximize, + resizable: options.resizable ?? true, + hide_title_bar: platformHideTitleBar, + activation_shortcut: options.activationShortcut, + always_on_top: options.alwaysOnTop, + dark_mode: options.darkMode, + disabled_web_shortcuts: options.disabledWebShortcuts, + hide_on_close: platformHideOnClose, + incognito: options.incognito, + title: options.title, + enable_wasm: options.wasm, + enable_drag_drop: options.enableDragDrop, + start_to_tray: options.startToTray && options.showSystemTray, + force_internal_navigation: options.forceInternalNavigation, + internal_url_regex: options.internalUrlRegex, + enable_find: options.enableFind, + zoom: options.zoom, + min_width: options.minWidth, + min_height: options.minHeight, + ignore_certificate_errors: options.ignoreCertificateErrors, + new_window: options.newWindow, + }; +} function asSupportedPlatform(platform) { if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') { throw new Error(`Pake only supports win32, darwin, and linux; detected '${platform}'.`); @@ -569,18 +635,15 @@ Terminal=false [desktopInstallPath]: `assets/${desktopFileName}`, }; const validTargets = [ - 'deb', - 'appimage', - 'rpm', - 'deb-arm64', - 'appimage-arm64', - 'rpm-arm64', + ...LINUX_TARGET_TYPES, + ...LINUX_TARGET_TYPES.map((target) => `${target}-arm64`), ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { - tauriConf.bundle.targets = [baseTarget]; + // zst is repacked from the deb payload, so Tauri itself bundles a deb. + tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`); @@ -735,34 +798,12 @@ async function writeAllConfigs(tauriConf, platform) { } async function mergeConfig(url, options, tauriConf) { await copyTemplateConfigs(); - const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', resizable = true, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, camera, microphone, } = options; + const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); - const platformHideOnClose = hideOnClose ?? platform === 'darwin'; - const tauriConfWindowOptions = { - width, - height, - fullscreen, - maximize, - resizable, - hide_title_bar: hideTitleBar, - activation_shortcut: activationShortcut, - always_on_top: alwaysOnTop, - dark_mode: darkMode, - disabled_web_shortcuts: disabledWebShortcuts, - hide_on_close: platformHideOnClose, - incognito, - title, - enable_wasm: wasm, - enable_drag_drop: enableDragDrop, - start_to_tray: startToTray && showSystemTray, - force_internal_navigation: forceInternalNavigation, - internal_url_regex: internalUrlRegex, - zoom, - min_width: minWidth, - min_height: minHeight, - ignore_certificate_errors: ignoreCertificateErrors, - new_window: newWindow, - }; + if (options.hideTitleBar && platform !== 'darwin') { + logger.warn('✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.'); + } + const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; @@ -808,70 +849,177 @@ async function mergeConfig(url, options, tauriConf) { await writeAllConfigs(tauriConf, platform); } -class BaseBuilder { - constructor(options) { - this.options = options; +/** + * Returns build environment variables overrides for macOS, where Rust crates + * sometimes need explicit C/C++ flags and a deterministic SDK target. Other + * platforms inherit `process.env` unchanged. + */ +function getBuildEnvironment() { + if (!IS_MAC) { + return undefined; } - getBuildEnvironment() { - if (!IS_MAC) { - return undefined; - } - const currentPath = process.env.PATH || ''; - const systemToolsPath = '/usr/bin'; - const buildPath = currentPath.startsWith(`${systemToolsPath}:`) - ? currentPath - : `${systemToolsPath}:${currentPath}`; - return { - CFLAGS: '-fno-modules', - CXXFLAGS: '-fno-modules', - MACOSX_DEPLOYMENT_TARGET: '14.0', - PATH: buildPath, - }; + const currentPath = process.env.PATH || ''; + const systemToolsPath = '/usr/bin'; + const buildPath = currentPath.startsWith(`${systemToolsPath}:`) + ? currentPath + : `${systemToolsPath}:${currentPath}`; + return { + CFLAGS: '-fno-modules', + CXXFLAGS: '-fno-modules', + MACOSX_DEPLOYMENT_TARGET: '14.0', + PATH: buildPath, + }; +} +/** + * Windows needs more time due to native compilation and antivirus scanning. + */ +function getInstallTimeout() { + return process.platform === 'win32' ? 900000 : 600000; +} +function getBuildTimeout() { + return 900000; +} +let packageManagerCache = null; +function parseMajorVersion(version) { + const match = version.match(/^v?(\d+)/); + return match ? Number(match[1]) : null; +} +function getPinnedPnpmMajorVersion() { + const packageManager = packageJson.packageManager; + const match = packageManager?.match(/^pnpm@(\d+)/); + return match ? Number(match[1]) : null; +} +async function detectNpm(execa) { + try { + await execa('npm', ['--version'], { stdio: 'ignore' }); + return true; } - getInstallTimeout() { - // Windows needs more time due to native compilation and antivirus scanning - return process.platform === 'win32' ? 900000 : 600000; + catch { + return false; } - getBuildTimeout() { - return 900000; +} +/** + * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. + * Cached after the first successful detection so tests can call repeatedly. + */ +async function detectPackageManager() { + if (packageManagerCache) { + return packageManagerCache; } - async detectPackageManager() { - if (BaseBuilder.packageManagerCache) { - return BaseBuilder.packageManagerCache; - } - const { execa } = await import('execa'); - try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ Using pnpm for package management.'); - BaseBuilder.packageManagerCache = 'pnpm'; - return 'pnpm'; - } - catch { - try { - await execa('npm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ pnpm not available, using npm for package management.'); - BaseBuilder.packageManagerCache = 'npm'; - return 'npm'; - } - catch { - throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); - } - } + const { execa } = await import('execa'); + let pnpmVersion; + try { + const { stdout } = await execa('pnpm', ['--version']); + pnpmVersion = stdout.trim(); + } + catch { + if (await detectNpm(execa)) { + logger.info('✺ pnpm not available, using npm for package management.'); + packageManagerCache = 'npm'; + return 'npm'; + } + throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); + } + const normalizedPnpmVersion = pnpmVersion.startsWith('v') + ? pnpmVersion + : `v${pnpmVersion}`; + const pnpmMajor = parseMajorVersion(pnpmVersion); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + if (pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor) { + if (!(await detectNpm(execa))) { + throw new Error(`Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}. Install npm so Pake can fall back, or use pnpm ${pinnedPnpmMajor}.x to match the project pin.`); + } + logger.warn(`✼ Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}; using npm for package management instead.`); + packageManagerCache = 'npm'; + return 'npm'; + } + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; +} +function getInstallCommand(packageManager, useCnMirror) { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; +} +async function copyFileWithSamePathGuard(sourcePath, destinationPath) { + if (path.resolve(sourcePath) === path.resolve(destinationPath)) { + return; } - async copyFileWithSamePathGuard(sourcePath, destinationPath) { - if (path.resolve(sourcePath) === path.resolve(destinationPath)) { + try { + await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); + } + catch (error) { + if (error instanceof Error && + error.message.includes('Source and destination must not be the same')) { return; } - try { - await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); - } - catch (error) { - if (error instanceof Error && - error.message.includes('Source and destination must not be the same')) { - return; - } - throw error; - } + throw error; + } +} +function isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) { + return projectConfig.trim() === cnMirrorConfig.trim(); +} +/** + * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in + * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config + * (or warns about a manual one) when they opt out. + */ +async function configureCargoRegistry(tauriSrcPath, useCnMirror) { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await copyFileWithSamePathGuard(projectCnConf, projectConf); + return; + } + if (!(await fsExtra.pathExists(projectConf))) { + return; + } + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + if (projectConfig.includes('rsproxy.cn')) { + logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); + } +} + +// Appended to the error when a Linux AppImage build fails for good. linuxdeploy's +// diagnostics stream to the terminal (stdio: 'inherit') and never reach +// error.message, so we cannot name the exact cause. We only reach here after +// NO_STRIP=1 has been applied and still failed, so strip is shown as ruled out. +const APPIMAGE_BAR = '━'.repeat(56); +const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` + + 'Linux AppImage Build Failed\n' + + `${APPIMAGE_BAR}\n\n` + + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + + ' the build still failed, so strip is likely not the cause.\n' + + ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + + " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' then: sudo gdk-pixbuf-query-loaders --update-cache\n' + + ' (Arch refreshes the cache automatically via a pacman hook)\n' + + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + + 'Still stuck? Build a DEB instead: pake --targets deb\n' + + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + APPIMAGE_BAR; +class BaseBuilder { + constructor(options) { + this.options = options; } async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); @@ -896,60 +1044,34 @@ class BaseBuilder { process.exit(1); } } - const isChina = await isChinaDomain('www.npmjs.com'); const spinner = getSpinner('Installing package...'); - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - await fsExtra.ensureDir(rustProjectDir); - // Detect available package manager - const packageManager = await this.detectPackageManager(); - const registryOption = ' --registry=https://registry.npmmirror.com'; - const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; - const timeout = this.getInstallTimeout(); - const buildEnv = this.getBuildEnvironment(); + const useCnMirror = isCnMirrorEnabled(); + await configureCargoRegistry(tauriSrcPath, useCnMirror); + const packageManager = await detectPackageManager(); + const timeout = getInstallTimeout(); + const buildEnv = getBuildEnvironment(); // Show helpful message for first-time users if (!tauriTargetPathExists) { logger.info(process.platform === 'win32' ? '✺ First-time setup may take 10-15 minutes on Windows (compiling dependencies)...' : '✺ First-time setup may take 5-10 minutes (installing dependencies)...'); } - let usedMirror = isChina; + if (useCnMirror) { + logger.info(`✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`); + } try { - if (isChina) { - logger.info(`✺ Located in China, using ${packageManager}/rsProxy CN mirror.`); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - } - else { - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - } + await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, { + ...buildEnv, + CI: 'true', + }); spinner.succeed(chalk.green('Package installed!')); } catch (error) { - // If installation times out and we haven't tried the mirror yet, retry with mirror - if (error instanceof Error && - error.message.includes('timed out') && - !usedMirror) { - spinner.fail(chalk.yellow('Installation timed out, retrying with CN mirror...')); - logger.info('✺ Retrying installation with CN mirror for better speed...'); - const retrySpinner = getSpinner('Retrying installation...'); - usedMirror = true; - try { - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - retrySpinner.succeed(chalk.green('Package installed with CN mirror!')); - } - catch (retryError) { - retrySpinner.fail(chalk.red('Installation failed')); - throw retryError; - } - } - else { - spinner.fail(chalk.red('Installation failed')); - throw error; + spinner.fail(chalk.red('Installation failed')); + if (!useCnMirror) { + logger.info(`✺ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`); } + throw error; } if (!tauriTargetPathExists) { logger.warn('✼ The first packaging may be slow, please be patient and wait, it will be faster afterwards.'); @@ -961,7 +1083,7 @@ class BaseBuilder { async start(url) { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); const features = this.getBuildFeatures(); const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : ''; @@ -969,11 +1091,10 @@ class BaseBuilder { const command = `cd "${npmDirectory}" && ${packageManager} run tauri${argSeparator} dev --config "${configPath}" ${featureArgs}`; await shellExec(command); } - async buildAndCopy(url, target) { + async buildAndCopy(url, target, logSuccess = true) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); - // Detect available package manager - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); // Build app const buildSpinner = getSpinner('Building app...'); // Let spinner run for a moment so user can see it, then stop before package manager command @@ -981,42 +1102,44 @@ class BaseBuilder { buildSpinner.stop(); // Show static message to keep the status visible logger.warn('✸ Building app...'); - const baseEnv = this.getBuildEnvironment(); + const baseEnv = getBuildEnvironment(); let buildEnv = { ...(baseEnv ?? {}), ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}), }; const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined; - // Warn users about potential AppImage build failures on modern Linux systems. - // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't - // recognize the .relr.dyn section introduced in glibc 2.38+. - if (process.platform === 'linux' && target === 'appimage') { - if (!buildEnv.NO_STRIP) { - logger.warn('⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+'); - logger.warn('⚠ If build fails, retry with: NO_STRIP=1 pake --targets appimage'); - } + const isLinuxAppImage = process.platform === 'linux' && target === 'appimage'; + // AppImage builds can fail at the linuxdeploy strip step on glibc 2.38+. + // A real failure now prints full guidance, so only hint in debug mode. + if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) { + logger.warn('⚠ AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.'); } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; - const buildTimeout = this.getBuildTimeout(); + const buildTimeout = getBuildTimeout(); try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (error) { - const shouldRetryWithoutStrip = process.platform === 'linux' && - target === 'appimage' && - !buildEnv.NO_STRIP && - this.isLinuxDeployStripError(error); - if (shouldRetryWithoutStrip) { - logger.warn('⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.'); - buildEnv = { - ...buildEnv, - NO_STRIP: '1', - }; - await shellExec(buildCommand, buildTimeout, resolveExecEnv()); + if (!isLinuxAppImage) { + throw error; } - else { + // linuxdeploy's diagnostics stream to the terminal (stdio: 'inherit') and + // never reach error.message, so we cannot classify the cause. strip is the + // most common AppImage failure, so retry once with NO_STRIP=1; if that + // (or an already-NO_STRIP run) still fails, surface all known causes. + if (buildEnv.NO_STRIP) { + error.message += APPIMAGE_FAILURE_GUIDANCE; throw error; } + logger.warn('⚠ AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).'); + buildEnv = { ...buildEnv, NO_STRIP: '1' }; + try { + await shellExec(buildCommand, buildTimeout, resolveExecEnv()); + } + catch (retryError) { + retryError.message += APPIMAGE_FAILURE_GUIDANCE; + throw retryError; + } } // Copy app const fileName = this.getFileName(); @@ -1029,8 +1152,10 @@ class BaseBuilder { await this.copyRawBinary(npmDirectory, name); } await fsExtra.remove(appPath); - logger.success('✔ Build success!'); - logger.success('✔ App installer located in', distPath); + if (logSuccess) { + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', distPath); + } // Log binary location if preserved if (this.options.keepBinary) { const binaryPath = this.getRawBinaryPath(name); @@ -1061,18 +1186,6 @@ class BaseBuilder { getFileType(target) { return target; } - isLinuxDeployStripError(error) { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - return (message.includes('linuxdeploy') || - message.includes('failed to run linuxdeploy') || - message.includes('strip:') || - message.includes('unable to recognise the format of the input file') || - message.includes('appimage tool failed') || - message.includes('strip tool')); - } resolveTargetArch(requestedArch) { if (requestedArch === 'auto' || !requestedArch) { return process.arch; @@ -1140,14 +1253,22 @@ class BaseBuilder { return 0; // Disable proxy feature if version detection fails } } + getCargoTargetDir() { + return process.env.CARGO_TARGET_DIR || path.join('src-tauri', 'target'); + } + resolveBuildPath(npmDirectory, buildPath) { + return path.isAbsolute(buildPath) + ? buildPath + : path.join(npmDirectory, buildPath); + } getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; - return `src-tauri/target/${basePath}/bundle/`; + return path.join(this.getCargoTargetDir(), basePath, 'bundle'); } getBuildAppPath(npmDirectory, fileName, fileType) { // For app bundles on macOS, the directory is 'macos', not 'app' const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); - return path.join(npmDirectory, this.getBasePath(), bundleDir, `${fileName}.${fileType}`); + return path.join(this.resolveBuildPath(npmDirectory, this.getBasePath()), bundleDir, `${fileName}.${fileType}`); } /** * Copy raw binary file to output directory @@ -1174,9 +1295,9 @@ class BaseBuilder { const binaryName = this.getBinaryName(appName); // Handle cross-platform builds if (this.options.multiArch || this.hasArchSpecificTarget()) { - return path.join(npmDirectory, this.getArchSpecificPath(), basePath, binaryName); + return path.join(this.resolveBuildPath(npmDirectory, this.getArchSpecificPath()), basePath, binaryName); } - return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName); + return path.join(this.resolveBuildPath(npmDirectory, this.getCargoTargetDir()), basePath, binaryName); } /** * Get the output path for the raw binary file @@ -1207,10 +1328,9 @@ class BaseBuilder { * Get architecture-specific path for binary */ getArchSpecificPath() { - return 'src-tauri/target'; // Override in subclasses if needed + return this.getCargoTargetDir(); // Override in subclasses if needed } } -BaseBuilder.packageManagerCache = null; BaseBuilder.ARCH_MAPPINGS = { darwin: { arm64: 'aarch64-apple-darwin', @@ -1239,7 +1359,9 @@ class MacBuilder extends BaseBuilder { this.buildArch = validArchs.includes(options.targets || '') ? options.targets : 'auto'; - if (options.iterativeBuild || + // `app` is a valid macOS bundle target (see merge.ts); honour it explicitly. + if (options.targets === 'app' || + options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1') { this.buildFormat = 'app'; @@ -1294,7 +1416,10 @@ class MacBuilder extends BaseBuilder { const basePath = this.options.debug ? 'debug' : 'release'; const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}/${basePath}/bundle`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } hasArchSpecificTarget() { return true; @@ -1302,7 +1427,10 @@ class MacBuilder extends BaseBuilder { getArchSpecificPath() { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target); } } @@ -1333,14 +1461,26 @@ class WinBuilder extends BaseBuilder { getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } hasArchSpecificTarget() { return true; } getArchSpecificPath() { const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`); + } + return path.join(this.getCargoTargetDir(), target); + } + getRawBinaryPath(appName) { + return `${appName}.exe`; + } + getBinaryName(appName) { + return `pake-${generateIdentifierSafeName(appName)}.exe`; } } @@ -1386,21 +1526,157 @@ class LinuxBuilder extends BaseBuilder { return `${name}_${version}_${arch}`; } async build(url) { - const targetTypes = ['deb', 'appimage', 'rpm']; - const requestedTargets = this.options.targets - .split(',') - .map((t) => t.trim()); - for (const target of targetTypes) { - if (requestedTargets.includes(target)) { - this.currentBuildType = target; - await this.buildAndCopy(url, target); + const targets = filterLinuxTargets(this.options.targets); + if (targets.length === 0) { + throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`); + } + const useTemporaryDebForZst = needsTemporaryDebForZst(targets); + // With a single explicit target, fail fast. With multiple targets (the + // distro-aware default, or an explicit comma list) keep building the rest + // when one fails, so a usable installer is still produced, e.g. AppImage + // survives a .deb bundler abort on RPM-based distros. + const isolateFailures = targets.length > 1; + const failed = []; + let firstError = null; + for (const target of targets) { + this.currentBuildType = target; + try { + if (target === 'zst') { + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); + } + else { + await this.buildAndCopy(url, target); + } } + catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (!isolateFailures) { + throw err; + } + if (!firstError) { + firstError = err; + } + failed.push(target); + logger.warn(`✼ Failed to build "${target}" target: ${err.message.split('\n')[0]}`); + } + } + // Every requested target failed: surface the first real error. + if (firstError && failed.length === targets.length) { + throw firstError; + } + if (failed.length > 0) { + logger.warn(`✼ Skipped failed Linux targets: ${failed.join(', ')}. Other formats built successfully.`); } } + async ensureArchPackagingTools() { + const requiredTools = [ + { tool: 'ar', pacmanPackage: 'binutils' }, + { tool: 'bsdtar', pacmanPackage: 'libarchive' }, + ]; + for (const { tool, pacmanPackage } of requiredTools) { + try { + await shellExec(`command -v ${tool} >/dev/null 2>&1`); + } + catch { + throw new Error(`Building a zst package requires "${tool}". Install it first, e.g. "sudo pacman -S ${pacmanPackage}".`); + } + } + } + async createArchPackageFromDeb({ removeSourceDeb, }) { + const { name = 'pake-app' } = this.options; + const packageName = generateLinuxPackageName(name); + const version = tauriConfig.version; + const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; + const debPath = path.resolve(`${name}.deb`); + const packagePath = path.resolve(`${name}-${version}-1-${arch}.pkg.tar.zst`); + const workDir = path.resolve('.pake-arch-package'); + const dataDir = path.join(workDir, 'data'); + const controlDir = path.join(workDir, 'control'); + await this.ensureArchPackagingTools(); + await fsExtra.remove(workDir); + await fsExtra.ensureDir(dataDir); + await fsExtra.ensureDir(controlDir); + try { + await shellExec(`cd "${controlDir}" && ar x "${debPath}"`); + const dataArchive = (await fsExtra.readdir(controlDir)).find((file) => file.startsWith('data.tar')); + if (!dataArchive) { + throw new Error(`Could not find data.tar payload in ${debPath}`); + } + await shellExec(`tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`); + // Drop the desktop entry auto-generated by the Tauri deb bundler; + // the payload already ships Pake's own com.pake..desktop. + await fsExtra.remove(path.join(dataDir, 'usr', 'share', 'applications', `${packageName}.desktop`)); + const installedSize = await this.getDirectorySize(dataDir); + const pkgInfo = `pkgname = ${packageName} +pkgbase = ${packageName} +pkgver = ${version}-1 +pkgdesc = ${name} Pake app +url = https://github.com/tw93/Pake +builddate = ${Math.floor(Date.now() / 1000)} +packager = Pake +size = ${installedSize} +arch = ${arch} +license = custom +depend = cairo +depend = desktop-file-utils +depend = gdk-pixbuf2 +depend = glib2 +depend = gtk3 +depend = hicolor-icon-theme +depend = libsoup3 +depend = pango +depend = webkit2gtk-4.1 +`; + await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); + await fsExtra.writeFile(path.join(dataDir, '.INSTALL'), `post_install() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} + +post_upgrade() { + post_install +} + +post_remove() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} +`); + await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`); + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', packagePath); + } + finally { + if (removeSourceDeb) { + await fsExtra.remove(debPath); + } + await fsExtra.remove(workDir); + } + } + async getDirectorySize(directory) { + let size = 0; + for (const entry of await fsExtra.readdir(directory, { + withFileTypes: true, + })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + size += await this.getDirectorySize(entryPath); + } + else if (entry.isFile()) { + size += (await fsExtra.stat(entryPath)).size; + } + } + return size; + } // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. - async buildAndCopy(url, target) { + async buildAndCopy(url, target, logSuccess = true) { this.currentBuildType = target; - await super.buildAndCopy(url, target); + await super.buildAndCopy(url, target, logSuccess); } getBuildCommand(packageManager = 'pnpm') { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); @@ -1426,7 +1702,10 @@ class LinuxBuilder extends BaseBuilder { const basePath = this.options.debug ? 'debug' : 'release'; if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Linux`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } return super.getBasePath(); } @@ -1442,7 +1721,10 @@ class LinuxBuilder extends BaseBuilder { getArchSpecificPath() { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Linux`); + } + return path.join(this.getCargoTargetDir(), target); } return super.getArchSpecificPath(); } @@ -1546,6 +1828,9 @@ function getIconSourcePriority(url, appName) { const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; const ICO_TYPE_ICON = 1; +// Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48), +// shell (48/256) and high-DPI (128/256). Issue #1190. +const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; function decodeDimension(value) { return value === 0 ? 256 : value; } @@ -1644,6 +1929,91 @@ async function writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize) return false; } } +/** + * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an + * embedded PNG payload (PNG-in-ICO, supported since Windows Vista). + */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]); +function frameLooksLikePng(entry) { + return (entry.data.length >= PNG_SIGNATURE.length && + entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)); +} +async function decodeFrameToPng(entry) { + if (frameLooksLikePng(entry)) { + return Buffer.from(entry.data); + } + // BMP DIB frames need to go through sharp's ico-to-PNG path, which only + // works on the full ICO container. Fall back to letting the caller use a + // sharp pipeline against the original ICO for the missing source. + return null; +} +async function pickLargestFrameAsPng(buffer, entries) { + const largest = [...entries].sort((a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height))[0]; + if (largest) { + const decoded = await decodeFrameToPng(largest); + if (decoded) { + return decoded; + } + } + // Fallback: let sharp render directly from the ICO buffer. sharp picks the + // largest embedded frame on its own. + try { + return await sharp(buffer).png().toBuffer(); + } + catch { + return null; + } +} +/** + * Ensures the produced ICO carries every Windows standard size so the OS + * never has to downsample a 256x256 frame to 16x16 for the tray. + * Falls back to `writeIcoWithPreferredSize` if rendering fails. + * + * Issue #1190. + */ +async function ensureMultiResolutionIco(sourcePath, outputPath, preferredSize = 256, desiredSizes = WIN_STANDARD_ICO_SIZES) { + try { + const sourceBuffer = await fsExtra.readFile(sourcePath); + const entries = parseIcoBuffer(sourceBuffer); + const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries); + if (!sourcePng) { + return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize); + } + const frames = await Promise.all(desiredSizes.map(async (size) => { + // Reuse an existing exact-size PNG frame when possible to keep any + // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting). + const exact = entries.find((entry) => entry.width === size && entry.height === size); + if (exact && frameLooksLikePng(exact)) { + return { size, png: Buffer.from(exact.data) }; + } + const png = await sharp(sourcePng) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .png() + .toBuffer(); + return { size, png }; + })); + // Order frames so the preferred size lands first (Windows shell uses the + // first-listed frame as a quality hint when choosing which to display). + frames.sort((a, b) => { + const aExact = a.size === preferredSize ? 0 : 1; + const bExact = b.size === preferredSize ? 0 : 1; + if (aExact !== bExact) + return aExact - bExact; + return b.size - a.size; + }); + const icoBuffer = buildIcoFromPngBuffers(frames); + await fsExtra.ensureDir(path.dirname(outputPath)); + await fsExtra.outputFile(outputPath, icoBuffer); + return true; + } + catch { + return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize); + } +} /** * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format * (supported since Windows Vista). This preserves alpha transparency. @@ -1693,7 +2063,7 @@ const ICON_CONFIG = { }, }; const PLATFORM_CONFIG = { - win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] }, + win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] }, linux: { format: '.png', size: 512 }, macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] }, }; @@ -1728,10 +2098,15 @@ async function copyWindowsIconIfNeeded(convertedPath, appName) { try { const finalIconPath = generateIconPath(appName); await fsExtra.ensureDir(path.dirname(finalIconPath)); - // Reorder ICO to prioritize 256px icons for better Windows display - const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256); - if (!reordered) { - await fsExtra.copy(convertedPath, finalIconPath); + // Re-render ICO so every Windows standard size is present and prefer the + // 256px frame as the leading entry; falls back to plain reordering if the + // ICO is non-decodable, then to a raw copy. (Issue #1190) + const upgraded = await ensureMultiResolutionIco(convertedPath, finalIconPath, 256); + if (!upgraded) { + const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256); + if (!reordered) { + await fsExtra.copy(convertedPath, finalIconPath); + } } return finalIconPath; } @@ -2187,6 +2562,42 @@ function normalizeUrl(urlToNormalize) { throw new Error(`Your url "${urlWithProtocol}" is invalid: ${err.message}`); } } +// Compiles a comma-separated domain list into a regex source for +// internal_url_regex. Each domain is escaped and matched against the URL host +// and its subdomains so path or query text cannot accidentally opt a link in. +// Returns '' for empty input. +function safeDomainsToRegex(domains) { + const escaped = domains + .split(',') + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) + .map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return escaped.length + ? `^https?:\\/\\/(?:[^/?#@]+\\.)*(?:${escaped.join('|')})(?::\\d+)?(?:[/?#]|$)` + : ''; +} + +/** + * Error class used for user-facing CLI errors. + * + * The top-level catch in `bin/cli.ts` prints `message` directly without a + * stack trace and exits with code 1. Use this for predictable failures + * (invalid names, missing files, etc.) so users see a clean message instead + * of a Node.js stack dump. + */ +class PakeError extends Error { + constructor(message) { + super(message); + this.isUserError = true; + this.name = 'PakeError'; + } +} +function isPakeError(error) { + return (error instanceof PakeError || + (typeof error === 'object' && + error !== null && + error.isUserError === true)); +} function resolveAppName(name, platform) { const domain = getDomain(name) || 'pake'; @@ -2198,8 +2609,8 @@ function resolveLocalAppName(filePath, platform) { return generateLinuxPackageName(baseName) || 'pake-app'; } const normalized = baseName - .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '') - .replace(/^[ -]+/, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff .-]/g, '') + .replace(/^[ .-]+/, '') .replace(/\s+/g, ' ') .trim(); return normalized || 'pake-app'; @@ -2207,7 +2618,7 @@ function resolveLocalAppName(filePath, platform) { function isValidName(name, platform) { const reg = platform === 'linux' ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/ - : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/; + : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff .-]*$/; return !!name && reg.test(name); } async function handleOptions(options, url) { @@ -2228,15 +2639,15 @@ async function handleOptions(options, url) { } if (name && !isValidName(name, platform)) { const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`; - const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`; + const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dots, dashes, and spaces (not leading dots, dashes, and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, Vectorizer.AI, 123.`; const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; - logger.error(errorMsg); if (isActions) { + logger.error(errorMsg); name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); } else { - process.exit(1); + throw new PakeError(errorMsg); } } const resolvedName = name || 'pake-app'; @@ -2245,6 +2656,10 @@ async function handleOptions(options, url) { name: resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; + // --safe-domain is sugar over --internal-url-regex; an explicit regex wins. + if (!options.internalUrlRegex && options.safeDomain) { + appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain); + } const iconPath = await handleIcon(appOptions, url); appOptions.icon = iconPath || ''; return appOptions; @@ -2268,7 +2683,7 @@ const DEFAULT_PAKE_OPTIONS = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return getDefaultLinuxTargets(); case 'darwin': return 'dmg'; case 'win32': @@ -2293,6 +2708,8 @@ const DEFAULT_PAKE_OPTIONS = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + safeDomain: '', + enableFind: false, iterativeBuild: false, zoom: 100, minWidth: 0, @@ -2305,14 +2722,20 @@ const DEFAULT_PAKE_OPTIONS = { }; function validateNumberInput(value) { + if (value.trim() === '') { + throw new InvalidArgumentError('Not a number.'); + } const parsedValue = Number(value); - if (isNaN(parsedValue)) { + if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } + if (parsedValue < 0) { + throw new InvalidArgumentError('Must not be negative.'); + } return parsedValue; } function validateUrlInput(url) { - const isFile = fs$1.existsSync(url); + const isFile = fs.existsSync(url); if (!isFile) { try { return normalizeUrl(url); @@ -2338,6 +2761,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with return program$1 .addHelpText('beforeAll', logo) .usage(`[url] [options]`) + .helpOption('-h, --help', 'Show all CLI options') .showHelpAfterError() .argument('[url]', 'The web URL you want to package', validateUrlInput) .option('--name ', 'Application name') @@ -2377,7 +2801,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--maximize', 'Start window maximized') .default(DEFAULT_PAKE_OPTIONS.maximize) .hideHelp()) - .addOption(new Option('--dark-mode', 'Force Mac app to use dark mode') + .addOption(new Option('--dark-mode', 'Force app to use dark mode (supports macOS, Windows, and Linux)') .default(DEFAULT_PAKE_OPTIONS.darkMode) .hideHelp()) .addOption(new Option('--disabled-web-shortcuts', 'Disabled webPage shortcuts') @@ -2426,11 +2850,11 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--start-to-tray', 'Start app minimized to tray') .default(DEFAULT_PAKE_OPTIONS.startToTray) .hideHelp()) - .addOption(new Option('--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers') - .default(DEFAULT_PAKE_OPTIONS.forceInternalNavigation) - .hideHelp()) - .addOption(new Option('--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal') - .default(DEFAULT_PAKE_OPTIONS.internalUrlRegex) + .addOption(new Option('--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers').default(DEFAULT_PAKE_OPTIONS.forceInternalNavigation)) + .addOption(new Option('--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal').default(DEFAULT_PAKE_OPTIONS.internalUrlRegex)) + .addOption(new Option('--safe-domain ', 'Comma-separated domains kept inside the app (e.g. SSO/workspace callbacks)').default(DEFAULT_PAKE_OPTIONS.safeDomain)) + .addOption(new Option('--enable-find', 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts') + .default(DEFAULT_PAKE_OPTIONS.enableFind) .hideHelp()) .addOption(new Option('--installer-language ', 'Installer language') .default(DEFAULT_PAKE_OPTIONS.installerLanguage) @@ -2438,8 +2862,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--zoom ', 'Initial page zoom level (50-200)') .default(DEFAULT_PAKE_OPTIONS.zoom) .argParser((value) => { - const zoom = parseInt(value); - if (isNaN(zoom) || zoom < 50 || zoom > 200) { + const zoom = Number(value); + if (!Number.isFinite(zoom) || zoom < 50 || zoom > 200) { throw new Error('--zoom must be a number between 50 and 200'); } return zoom; @@ -2459,9 +2883,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--iterative-build', 'Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging') .default(DEFAULT_PAKE_OPTIONS.iterativeBuild) .hideHelp()) - .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)') - .default(DEFAULT_PAKE_OPTIONS.newWindow) - .hideHelp()) + .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)').default(DEFAULT_PAKE_OPTIONS.newWindow)) .addOption(new Option('--install', 'Auto-install app to /Applications (macOS) after build and remove local bundle') .default(DEFAULT_PAKE_OPTIONS.install) .hideHelp()) @@ -2474,14 +2896,19 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .version(packageJson.version, '-v, --version') .configureHelp({ sortSubcommands: true, + visibleOptions: (command) => { + const options = [...command.options]; + const helpOption = command + ._helpOption; + if (helpOption) { + options.push(helpOption); + } + return options; + }, optionTerm: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.flags; }, optionDescription: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.description; }, }); @@ -2494,21 +2921,46 @@ async function checkUpdateTips() { }); } program.action(async (url, options) => { - await checkUpdateTips(); - if (!url) { - program.help({ - error: false, - }); - return; + try { + await checkUpdateTips(); + if (!url) { + program.help({ + error: false, + }); + return; + } + log.setDefaultLevel('info'); + log.setLevel('info'); + if (options.debug) { + log.setLevel('debug'); + } + const appOptions = await handleOptions(options, url); + const builder = BuilderProvider.create(appOptions); + await builder.prepare(); + await builder.build(url); } - log.setDefaultLevel('info'); - log.setLevel('info'); - if (options.debug) { - log.setLevel('debug'); + catch (error) { + if (isPakeError(error)) { + console.error(chalk.red(error.message)); + } + else if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + if (options?.debug && error.stack) { + console.error(chalk.gray(error.stack)); + } + } + else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); + } + process.exit(1); + } +}); +program.parseAsync().catch((error) => { + if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + } + else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); } - const appOptions = await handleOptions(options, url); - const builder = BuilderProvider.create(appOptions); - await builder.prepare(); - await builder.build(url); + process.exit(1); }); -program.parse(); diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 9db2034b0c..724c99a7a1 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -114,6 +114,8 @@ Configure window properties in `pake.json`: } ``` +`hideTitleBar` is only supported on macOS. It is ignored on Windows and Linux. + ## Static File Packaging Package local HTML/CSS/JS files: @@ -265,17 +267,7 @@ pnpm run dev #### CLI Development -For CLI development with hot reloading, modify the `DEFAULT_DEV_PAKE_OPTIONS` configuration in `bin/defaults.ts`: - -```typescript -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: "https://weekly.tw93.fun/en", - name: "Weekly", -}; -``` - -Then run: +For CLI development with hot reloading, run: ```bash pnpm run cli:dev diff --git a/docs/advanced-usage_CN.md b/docs/advanced-usage_CN.md index 8c8deaf359..e523e4480c 100644 --- a/docs/advanced-usage_CN.md +++ b/docs/advanced-usage_CN.md @@ -114,6 +114,8 @@ fn handle_scroll(scroll_y: f64, scroll_x: f64) { } ``` +`hideTitleBar` 仅支持 macOS,在 Windows 和 Linux 上会被忽略。 + ## 静态文件打包 打包本地 HTML/CSS/JS 文件: @@ -265,17 +267,7 @@ pnpm run dev #### CLI 开发调试 -对于需要热重载的 CLI 开发,可修改 `bin/defaults.ts` 中的 `DEFAULT_DEV_PAKE_OPTIONS` 配置: - -```typescript -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: "https://weekly.tw93.fun/en", - name: "Weekly", -}; -``` - -然后运行: +对于需要热重载的 CLI 开发,运行: ```bash pnpm run cli:dev diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 56ca718def..e53641d523 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -70,7 +70,7 @@ The URL is the link to the web page you want to package or the path to a local H ### [options] -Various options are available for customization. Here are the most commonly used ones: +Various options are available for customization. `pake --help` shows every supported CLI option. This page is the complete reference. | Option | Description | Example | | ------------------ | ----------------------------------------------- | ---------------------------------------------- | @@ -80,6 +80,8 @@ Various options are available for customization. Here are the most commonly used | `--height` | Window height (default: 780px) | `--height 900` | | `--hide-title-bar` | Immersive header (macOS only) | `--hide-title-bar` | | `--debug` | Enable development tools | `--debug` | +| `--help` | Show all CLI options | `--help` | +| `--version` | Show CLI version | `--version` | For complete options, see detailed sections below. @@ -216,12 +218,14 @@ Set the version number of the packaged application to be consistent with the nam #### [dark-mode] -Force Mac to package applications using dark mode, default is `false`. +Force packaging applications using dark mode (supports macOS, Windows, and Linux), default is `false`. ```shell --dark-mode ``` +On Linux this goes through WebKitGTK, so whether a page renders dark also depends on the WebKitGTK build honoring the window theme and the site implementing `prefers-color-scheme: dark`. + #### [disabled-web-shortcuts] Sets whether to disable web shortcuts in the original Pake container, defaults to `false`. @@ -230,6 +234,14 @@ Sets whether to disable web shortcuts in the original Pake container, defaults t --disabled-web-shortcuts ``` +#### [enable-find] + +Enable Pake's in-page Find UI. Default is `false`. When enabled, users can press `Cmd/Ctrl+F` to open Find, `Cmd/Ctrl+G` to jump to the next match, and `Cmd/Ctrl+Shift+G` to jump to the previous match. + +```shell +--enable-find +``` + #### [force-internal-navigation] Keeps every clicked link (even pointing to other domains) inside the Pake window instead of letting the OS open an external browser or helper. Default is `false`. @@ -252,6 +264,19 @@ Set a regex pattern to determine which URLs should be considered internal (opene --internal-url-regex "^https://(app|api)\\.example\\.com" ``` +#### [safe-domain] + +A simpler way to keep trusted domains and their subdomains inside the app. This is useful for workspace callbacks and enterprise SSO flows, for example Slack plus Okta. Pake compiles this list into `internal_url_regex`; if `--internal-url-regex` is also set, the explicit regex wins. + +`--safe-domain` matches URL hosts only, not arbitrary path or query text. + +```shell +--safe-domain + +# Keep Slack and Okta auth redirects inside the app +--safe-domain slack.com,okta.com +``` + #### [multi-arch] Package the application to support both Intel and M1 chips, exclusively for macOS. Default is `false`. @@ -281,9 +306,9 @@ Package the application to support both Intel and M1 chips, exclusively for macO Specify the build target architecture or format: -- **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64` (default: `deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: distro-aware, `deb, appimage` on Debian/Ubuntu and `rpm, appimage` on Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) - **Windows**: `x64`, `arm64` (auto-detects if not specified) -- **macOS**: `intel`, `apple`, `universal` (auto-detects if not specified) +- **macOS**: `intel`, `apple`, `universal` (architecture, auto-detects if not specified); `app`, `dmg` (output format, default: `dmg`) ```shell --targets @@ -294,12 +319,16 @@ Specify the build target architecture or format: --targets universal # macOS Universal (Intel + Apple Silicon) --targets apple # macOS Apple Silicon only --targets intel # macOS Intel only +--targets app # macOS app bundle only (.app, skips the DMG step) +--targets dmg # macOS DMG installer (default) --targets deb # Linux DEB package (x64) --targets rpm # Linux RPM package (x64) --targets appimage # Linux AppImage (x64) +--targets zst # Linux Arch package (x64 .pkg.tar.zst) --targets deb-arm64 # Linux DEB package (ARM64) --targets rpm-arm64 # Linux RPM package (ARM64) --targets appimage-arm64 # Linux AppImage (ARM64) +--targets zst-arm64 # Linux Arch package (ARM64 .pkg.tar.zst) ``` **Note for Linux ARM64**: @@ -307,6 +336,7 @@ Specify the build target architecture or format: - Cross-compilation requires additional setup. Install `gcc-aarch64-linux-gnu` and configure environment variables for cross-compilation. - ARM64 support enables Pake apps to run on ARM-based Linux devices, including Linux phones (postmarketOS, Ubuntu Touch), Raspberry Pi, and other ARM64 Linux systems. - Use `--target appimage-arm64` for portable ARM64 applications that work across different ARM64 Linux distributions. +- Use `--targets zst` on Arch Linux based distributions to produce a `.pkg.tar.zst` package directly. Pake follows Tauri's AUR packaging guidance by building the Linux package payload first, then emitting Arch package metadata and zstd-compressed output. Requires `binutils` (for `ar`) and `libarchive` (for `bsdtar`). #### [user-agent] diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 4e2fe61e47..d013e2d394 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -70,7 +70,7 @@ pake [url] [options] ### [options] -您可以通过传递以下选项来定制打包过程。以下是最常用的选项: +您可以通过传递以下选项来定制打包过程。`pake --help` 展示全部支持的 CLI 选项。本文档是完整参考。 | 选项 | 描述 | 示例 | | ------------------ | ------------------------------------ | ---------------------------------------------- | @@ -80,6 +80,8 @@ pake [url] [options] | `--height` | 窗口高度(默认:780px) | `--height 900` | | `--hide-title-bar` | 沉浸式标题栏(仅macOS) | `--hide-title-bar` | | `--debug` | 启用开发者工具 | `--debug` | +| `--help` | 显示全部 CLI 选项 | `--help` | +| `--version` | 显示 CLI 版本 | `--version` | 完整选项请参见下面的详细说明: @@ -214,12 +216,14 @@ pake https://github.com --name GitHub #### [dark-mode] -强制 Mac 打包应用使用黑暗模式,默认为 `false`。 +强制打包应用使用黑暗模式(支持 macOS、Windows 和 Linux),默认为 `false`。 ```shell --dark-mode ``` +在 Linux 上黑暗模式经由 WebKitGTK 实现,页面是否真正渲染为暗色还取决于 WebKitGTK 是否尊重窗口主题以及站点是否实现了 `prefers-color-scheme: dark`。 + #### [disabled-web-shortcuts] 设置是否禁用原有 Pake 容器里面的网页操作快捷键,默认为 `false`。 @@ -228,6 +232,14 @@ pake https://github.com --name GitHub --disabled-web-shortcuts ``` +#### [enable-find] + +启用 Pake 内置的页面查找浮层,默认 `false`。开启后用户可以使用 `Cmd/Ctrl+F` 打开查找,`Cmd/Ctrl+G` 跳到下一个匹配项,`Cmd/Ctrl+Shift+G` 跳到上一个匹配项。 + +```shell +--enable-find +``` + #### [force-internal-navigation] 启用后所有点击的链接(即使是跨域)都会在 Pake 窗口内打开,不会再调用外部浏览器或辅助程序。默认 `false`。 @@ -250,6 +262,19 @@ pake https://github.com --name GitHub --internal-url-regex "^https://(app|api)\\.example\\.com" ``` +#### [safe-domain] + +更简单地把可信域名及其子域名保留在应用内打开。适合工作区回调和企业 SSO 登录流程,例如 Slack 加 Okta。Pake 会把这个列表编译成 `internal_url_regex`;如果同时设置了 `--internal-url-regex`,则以显式正则为准。 + +`--safe-domain` 只匹配 URL 的 host,不会因为路径或查询参数里出现域名就误判为内部链接。 + +```shell +--safe-domain + +# 将 Slack 和 Okta 的认证跳转保留在应用内 +--safe-domain slack.com,okta.com +``` + #### [multi-arch] 设置打包结果同时支持 Intel 和 M1 芯片,仅适用于 macOS,默认为 `false`。 @@ -279,9 +304,9 @@ pake https://github.com --name GitHub 指定构建目标架构或格式: -- **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`(默认:`deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(默认:按发行版自适应,Debian/Ubuntu 为 `deb, appimage`,Fedora/RHEL/Oracle/Rocky/Alma/openSUSE 为 `rpm, appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) -- **macOS**: `intel`, `apple`, `universal`(未指定时自动检测) +- **macOS**: `intel`, `apple`, `universal`(架构,未指定时自动检测);`app`, `dmg`(输出格式,默认:`dmg`) ```shell --targets @@ -292,12 +317,16 @@ pake https://github.com --name GitHub --targets universal # macOS 通用版本(Intel + Apple Silicon) --targets apple # 仅 macOS Apple Silicon --targets intel # 仅 macOS Intel +--targets app # 仅 macOS 应用包(.app,跳过 DMG 步骤) +--targets dmg # macOS DMG 安装包(默认) --targets deb # Linux DEB 包(x64) --targets rpm # Linux RPM 包(x64) --targets appimage # Linux AppImage(x64) +--targets zst # Linux Arch 包(x64 .pkg.tar.zst) --targets deb-arm64 # Linux DEB 包(ARM64) --targets rpm-arm64 # Linux RPM 包(ARM64) --targets appimage-arm64 # Linux AppImage(ARM64) +--targets zst-arm64 # Linux Arch 包(ARM64 .pkg.tar.zst) ``` **Linux ARM64 注意事项**: @@ -305,6 +334,7 @@ pake https://github.com --name GitHub - 交叉编译需要额外设置。需要安装 `gcc-aarch64-linux-gnu` 并配置交叉编译环境变量。 - ARM64 支持让 Pake 应用可以在基于 ARM 的 Linux 设备上运行,包括 Linux 手机(postmarketOS、Ubuntu Touch)、树莓派和其他 ARM64 Linux 系统。 - 使用 `--target appimage-arm64` 可以创建便携式 ARM64 应用,在不同的 ARM64 Linux 发行版上运行。 +- 在基于 Arch Linux 的发行版上使用 `--targets zst` 可直接生成 `.pkg.tar.zst` 包。Pake 会按 Tauri 的 AUR 打包说明先生成 Linux 包内容,再写入 Arch 包元数据并输出 zstd 压缩包。需要预先安装 `binutils`(提供 `ar`)和 `libarchive`(提供 `bsdtar`)。 #### [user-agent] diff --git a/docs/faq.md b/docs/faq.md index 6c9a8cbc3e..da23320f47 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -9,7 +9,9 @@ Common issues and solutions when using Pake. - [Build Issues](#build-issues) - [Rust Version Error: "feature 'edition2024' is required"](#rust-version-error-feature-edition2024-is-required) - [Linux: Build Error "Can't detect any appindicator library" on Ubuntu 24.04](#linux-build-error-cant-detect-any-appindicator-library-on-ubuntu-2404) + - [Linux: Installing on Fedora / RHEL / Oracle Linux (RPM-based distros)](#linux-installing-on-fedora--rhel--oracle-linux-rpm-based-distros) - [Linux: AppImage Build Fails with "failed to run linuxdeploy"](#linux-appimage-build-fails-with-failed-to-run-linuxdeploy) + - [Linux: AppImage Crashes at Launch with WebKitNetworkProcess Not Found](#linux-appimage-crashes-at-launch-with-webkitnetworkprocess-not-found) - [Linux: "cargo: command not found" After Installing Rust](#linux-cargo-command-not-found-after-installing-rust) - [Windows: Installation Timeout During First Build](#windows-installation-timeout-during-first-build) - [Windows: Missing Visual Studio Build Tools](#windows-missing-visual-studio-build-tools) @@ -100,6 +102,46 @@ sudo apt-get install -y libayatana-appindicator3-dev --- +### Linux: Installing on Fedora / RHEL / Oracle Linux (RPM-based distros) + +**Problem:** +On RPM-based distros (Fedora, RHEL, Oracle Linux, Rocky, AlmaLinux, openSUSE) a +`.deb` package cannot be installed by the system package manager, and older Pake +versions always built a `.deb` first. + +**Solution:** + +Pake now picks the default bundle target from `/etc/os-release`: RPM-based +distros default to `rpm, appimage`, while Debian/Ubuntu keep `deb, appimage`. So +the basic command already produces an installable package: + +```bash +pake https://github.com --name GitHub +sudo dnf install ./GitHub.rpm # or: sudo rpm -i ./GitHub.rpm +``` + +You can also choose the format explicitly at any time: + +```bash +pake https://github.com --name GitHub --targets rpm # RPM package +pake https://github.com --name GitHub --targets appimage # portable AppImage +``` + +When several targets build (the default), one format failing no longer aborts +the others: if the `.rpm`/`.deb` bundler fails, the AppImage is still produced as +a portable fallback. AppImage runs without installation: + +```bash +chmod +x ./GitHub.AppImage +./GitHub.AppImage +``` + +> Building an `.rpm` requires `rpm-build` (`sudo dnf install rpm-build`). If you +> only need a runnable app without packaging, add `--keep-binary` to also copy +> the raw executable next to the installer. + +--- + ### Linux: AppImage Build Fails with "failed to run linuxdeploy" **Problem:** @@ -108,6 +150,22 @@ When building AppImage on Linux (Debian, Ubuntu, Arch, etc.), you may encounter ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file +ERROR: Failed to run plugin: gtk +cp: cannot stat '/usr/lib/gdk-pixbuf-2.0/2.10.0': No such file or directory +``` + +**Identify which failure you have first.** Two distinct problems share the `failed to run linuxdeploy` message: + +- `strip: Unable to recognise the format of the input file`: a strip incompatibility. Use Solution 1. +- `Failed to run plugin: gtk` together with `cannot stat '/usr/lib/gdk-pixbuf-2.0/...'`: linuxdeploy's gtk plugin cannot find the gdk-pixbuf loaders. `NO_STRIP` will not help. Install the loaders, refresh the cache, then rebuild: + +```bash +# Arch +sudo pacman -S gdk-pixbuf2 librsvg +# Debian / Ubuntu +sudo apt install librsvg2-common gdk-pixbuf2.0-bin +# refresh the loader cache, then rebuild +gdk-pixbuf-query-loaders --update-cache ``` **Solution 1: Automatic NO_STRIP Retry (Recommended)** @@ -181,6 +239,77 @@ The `NO_STRIP=1` environment variable is the official workaround recommended by --- +### Linux: AppImage Crashes at Launch with WebKitNetworkProcess Not Found + +**Problem:** +The AppImage builds successfully but crashes immediately at launch: + +```txt +** ERROR **: Unable to spawn a new child process: Failed to spawn child process +"././/lib/webkit2gtk-4.1/WebKitNetworkProcess" (No such file or directory) +``` + +This only affects AppImages built locally on a non-Debian distribution (Arch, Fedora, etc.). Pake's official AppImage releases are built in a Debian-based environment and are not affected. + +**Why This Happens:** +This is an upstream Tauri bundler limitation ([tauri-apps/tauri#5292](https://github.com/tauri-apps/tauri/issues/5292)). When bundling, Tauri rewrites the absolute WebKit helper path baked into `libwebkit2gtk*.so` to a relative `././...` form, and copies the helper binaries based on the Debian library layout (`/usr/lib//webkit2gtk-4.1`). On Arch the helpers live in `/usr/lib/webkit2gtk-4.1` with no architecture triple, so the patched relative path points at a `lib/webkit2gtk-4.1` directory that does not exist inside the bundle, and `WebKitNetworkProcess` can never be found. Pake does not control this step: the AppDir layout and path patching are produced entirely by `tauri build`. + +**Solution 1: Use the Arch native package (recommended on Arch)** + +```bash +pake https://example.com --name MyApp --targets zst +``` + +This produces a pacman package (`*.pkg.tar.zst`) that installs to system paths, so WebKit resolves its helper processes natively and there is no relocation problem. Install it with `sudo pacman -U MyApp-*.pkg.tar.zst`. + +**Solution 2: Build the AppImage in Docker (Debian-based)** + +Building inside Pake's Docker image matches the library layout the AppImage bundler expects: + +```bash +docker run --rm --privileged \ + --device /dev/fuse \ + --security-opt apparmor=unconfined \ + -v $(pwd)/output:/output \ + ghcr.io/tw93/pake:latest \ + https://example.com --name MyApp --targets appimage +``` + +**Workaround for an already-built AppImage:** +Extract it, add the missing symlink, then launch the inner `AppRun`: + +```bash +./MyApp.AppImage --appimage-extract +cd squashfs-root +mkdir -p lib && ln -s ../usr/lib/webkit2gtk-4.1 lib/webkit2gtk-4.1 +./AppRun +``` + +--- + +### Linux: AppImage Opens but Buttons or Keyboard Do Not Work on Wayland + +**Problem:** +On some pure Wayland compositors, especially niri, the AppImage can open but page buttons cannot be clicked or keyboard input does not reach the webview. + +**Solution:** +Pake automatically avoids the conservative WebKit rendering flags in niri sessions. To force the same native WebKit path manually, launch the app with: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./MyApp.AppImage +``` + +If your system shows a blank window instead, re-enable the conservative WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./MyApp.AppImage +``` + +**Why This Happens:** +Pake normally enables WebKitGTK workarounds that help blank-window cases on Linux, but those same flags can make input and window controls unreliable on some Wayland compositors. The `PAKE_LINUX_WEBKIT_SAFE_MODE` variable lets you choose the safer rendering mode for your compositor. + +--- + ### Linux: "cargo: command not found" After Installing Rust **Problem:** @@ -219,13 +348,23 @@ First-time installation on Windows can be slow due to: - Windows Defender real-time scanning - Network connectivity issues -**Solution 1: Automatic Retry (Built-in)** +**Solution 1: Enable CN Mirror Explicitly** -Pake CLI now automatically retries with CN mirror if the initial installation times out. Simply wait for the retry to complete. +Pake CLI uses the official npm and Rust sources by default. If downloads are slow in China, opt in to CN mirrors: + +```bash +# macOS/Linux +PAKE_USE_CN_MIRROR=1 pake https://github.com --name GitHub +``` + +```powershell +# Windows PowerShell +$env:PAKE_USE_CN_MIRROR="1"; pake https://github.com --name GitHub +``` **Solution 2: Manual Installation** -If automatic retry fails, manually install dependencies: +If dependency installation still fails, manually install dependencies: ```bash # Navigate to pake-cli installation directory @@ -385,6 +524,14 @@ This is usually due to web compatibility issues. Try: Some authentication providers, especially Google, may block sign-in inside embedded webviews. Because Pake packages sites into a desktop webview, Google properties or sites that rely on Google OAuth may still fail to sign in even when `--new-window` or `--multi-window` is enabled. This is provider policy, not a packaging bug. In those cases, use the normal browser, a browser-installed app, or a native desktop client. +5. **WeChat Web login environment error** + + WeChat detects the WebView and writes a flag cookie that blocks subsequent logins. Add `--incognito` when packaging to bypass it, at the cost of requiring a QR scan on every launch: + + ```bash + pake https://wx.qq.com --name WeChat --incognito + ``` + --- ## Installation Issues diff --git a/docs/faq_CN.md b/docs/faq_CN.md index ee08b8de1a..e9b31172be 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -9,7 +9,9 @@ - [构建问题](#构建问题) - [Rust 版本错误:"feature 'edition2024' is required"](#rust-版本错误feature-edition2024-is-required) - [Linux:Ubuntu 24.04 构建报错 "Can't detect any appindicator library"](#linuxubuntu-2404-构建报错-cant-detect-any-appindicator-library) + - [Linux:在 Fedora / RHEL / Oracle Linux 等 RPM 系发行版上安装](#linux在-fedora--rhel--oracle-linux-等-rpm-系发行版上安装) - [Linux:AppImage 构建失败,提示 "failed to run linuxdeploy"](#linuxappimage-构建失败提示-failed-to-run-linuxdeploy) + - [Linux:AppImage 启动即崩溃,提示找不到 WebKitNetworkProcess](#linuxappimage-启动即崩溃提示找不到-webkitnetworkprocess) - [Linux:"cargo: command not found" 即使已安装 Rust](#linuxcargo-command-not-found-即使已安装-rust) - [Windows:首次构建时安装超时](#windows首次构建时安装超时) - [Windows:缺少 Visual Studio 构建工具](#windows缺少-visual-studio-构建工具) @@ -100,6 +102,43 @@ sudo apt-get install -y libayatana-appindicator3-dev --- +### Linux:在 Fedora / RHEL / Oracle Linux 等 RPM 系发行版上安装 + +**问题:** +在 RPM 系发行版(Fedora、RHEL、Oracle Linux、Rocky、AlmaLinux、openSUSE)上, +`.deb` 包无法被系统包管理器安装,而旧版本 Pake 总是先构建 `.deb`。 + +**解决方法:** + +Pake 现在会读取 `/etc/os-release` 来决定默认打包目标:RPM 系发行版默认使用 +`rpm, appimage`,Debian/Ubuntu 仍然是 `deb, appimage`。所以基础命令就能直接产出 +可安装的包: + +```bash +pake https://github.com --name GitHub +sudo dnf install ./GitHub.rpm # 或:sudo rpm -i ./GitHub.rpm +``` + +你也可以随时显式指定格式: + +```bash +pake https://github.com --name GitHub --targets rpm # RPM 包 +pake https://github.com --name GitHub --targets appimage # 便携 AppImage +``` + +默认会构建多个目标,此时单个格式失败不再中断其余格式:如果 `.rpm`/`.deb` 打包失败, +仍会产出 AppImage 作为便携回退方案。AppImage 无需安装即可运行: + +```bash +chmod +x ./GitHub.AppImage +./GitHub.AppImage +``` + +> 构建 `.rpm` 需要 `rpm-build`(`sudo dnf install rpm-build`)。如果你只想要一个可运行 +> 的程序而不需要打包,可加上 `--keep-binary`,它会把原始可执行文件复制到安装包旁边。 + +--- + ### Linux:AppImage 构建失败,提示 "failed to run linuxdeploy" **问题描述:** @@ -108,6 +147,22 @@ sudo apt-get install -y libayatana-appindicator3-dev ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file +ERROR: Failed to run plugin: gtk +cp: cannot stat '/usr/lib/gdk-pixbuf-2.0/2.10.0': No such file or directory +``` + +**先判断你遇到的是哪一种失败。** 同样是 `failed to run linuxdeploy`,实际有两类不同原因: + +- `strip: Unable to recognise the format of the input file`:strip 不兼容,按解决方案 1 处理。 +- `Failed to run plugin: gtk` 且伴随 `cannot stat '/usr/lib/gdk-pixbuf-2.0/...'`:linuxdeploy 的 gtk 插件找不到 gdk-pixbuf loaders,`NO_STRIP` 无效。安装 loaders、刷新缓存后重新构建: + +```bash +# Arch +sudo pacman -S gdk-pixbuf2 librsvg +# Debian / Ubuntu +sudo apt install librsvg2-common gdk-pixbuf2.0-bin +# 刷新 loader 缓存后重新构建 +gdk-pixbuf-query-loaders --update-cache ``` **解决方案 1:自动 NO_STRIP 重试(推荐)** @@ -181,6 +236,77 @@ docker run --rm --privileged \ --- +### Linux:AppImage 启动即崩溃,提示找不到 WebKitNetworkProcess + +**问题描述:** +AppImage 构建成功,但启动时立即崩溃: + +```txt +** ERROR **: Unable to spawn a new child process: Failed to spawn child process +"././/lib/webkit2gtk-4.1/WebKitNetworkProcess" (No such file or directory) +``` + +这只影响在非 Debian 发行版(Arch、Fedora 等)本地构建出来的 AppImage。Pake 官方发布的 AppImage 在基于 Debian 的环境中构建,不受影响。 + +**原因:** +这是 Tauri 打包器的上游限制([tauri-apps/tauri#5292](https://github.com/tauri-apps/tauri/issues/5292))。打包时 Tauri 会把编译进 `libwebkit2gtk*.so` 的 WebKit 辅助进程绝对路径改写成相对的 `././...` 形式,并按 Debian 的库布局(`/usr/lib/<架构三元组>/webkit2gtk-4.1`)复制这些辅助二进制。Arch 上 WebKit 位于 `/usr/lib/webkit2gtk-4.1`,没有架构三元组,于是改写后的相对路径指向了 bundle 内并不存在的 `lib/webkit2gtk-4.1` 目录,`WebKitNetworkProcess` 永远找不到。Pake 不参与这一步:AppDir 布局和路径改写完全由 `tauri build` 生成。 + +**解决方案 1:使用 Arch 原生包(Arch 上推荐)** + +```bash +pake https://example.com --name MyApp --targets zst +``` + +这会生成 pacman 包(`*.pkg.tar.zst`),安装到系统路径,WebKit 按系统原生路径解析辅助进程,不存在重定位问题。用 `sudo pacman -U MyApp-*.pkg.tar.zst` 安装。 + +**解决方案 2:在 Docker(基于 Debian)中构建 AppImage** + +在 Pake 的 Docker 镜像中构建,库布局正好符合 AppImage 打包器的预期: + +```bash +docker run --rm --privileged \ + --device /dev/fuse \ + --security-opt apparmor=unconfined \ + -v $(pwd)/output:/output \ + ghcr.io/tw93/pake:latest \ + https://example.com --name MyApp --targets appimage +``` + +**已构建 AppImage 的临时绕过方法:** +解压后补上缺失的软链接,再运行内部的 `AppRun`: + +```bash +./MyApp.AppImage --appimage-extract +cd squashfs-root +mkdir -p lib && ln -s ../usr/lib/webkit2gtk-4.1 lib/webkit2gtk-4.1 +./AppRun +``` + +--- + +### Linux:AppImage 打开后按钮或键盘在 Wayland 下不可用 + +**问题描述:** +在某些纯 Wayland 合成器上,尤其是 niri,AppImage 可以打开,但页面按钮无法点击,键盘输入也无法进入 webview。 + +**解决方案:** +Pake 会在 niri 会话中自动避开保守的 WebKit 渲染参数。也可以手动强制使用原生 WebKit 渲染路径: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./MyApp.AppImage +``` + +如果你的系统反而出现白屏,可以重新启用保守 WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./MyApp.AppImage +``` + +**原因:** +Pake 默认启用的 WebKitGTK workaround 可以缓解 Linux 白屏,但在部分 Wayland 合成器上,这些参数可能导致输入和窗口控件不可用。`PAKE_LINUX_WEBKIT_SAFE_MODE` 可以按当前合成器选择更合适的渲染模式。 + +--- + ### Linux:"cargo: command not found" 即使已安装 Rust **问题描述:** @@ -219,13 +345,23 @@ Windows 首次安装可能较慢,原因包括: - Windows Defender 实时扫描 - 网络连接问题 -**解决方案 1:自动重试(内置)** +**解决方案 1:显式启用国内镜像** -Pake CLI 现在会在初次安装超时后自动使用国内镜像重试。只需等待重试完成即可。 +Pake CLI 默认使用官方 npm 和 Rust 源。如果在国内下载较慢,可以显式启用国内镜像: + +```bash +# macOS/Linux +PAKE_USE_CN_MIRROR=1 pake https://github.com --name GitHub +``` + +```powershell +# Windows PowerShell +$env:PAKE_USE_CN_MIRROR="1"; pake https://github.com --name GitHub +``` **解决方案 2:手动安装依赖** -如果自动重试失败,可手动安装依赖: +如果依赖安装仍然失败,可手动安装依赖: ```bash # 进入 pake-cli 安装目录 @@ -385,6 +521,14 @@ Pake 可以自动转换图标,但提供正确的格式更可靠。 某些认证提供方,尤其是 Google,可能会阻止在嵌入式 WebView 中完成登录。由于 Pake 是把网站包装进桌面 WebView,Google 自家站点或依赖 Google OAuth 的网站,即使启用了 `--new-window` 或 `--multi-window`,也仍然可能无法在应用内完成登录。这属于提供方策略限制,不是打包逻辑错误。遇到这种情况时,建议改用普通浏览器、浏览器安装版站点应用,或官方原生桌面客户端。 +5. **微信 Web 版登录环境异常** + + 微信检测到 WebView 后会写入标记 Cookie,导致后续持续被拦截。打包时加 `--incognito` 可解决,代价是每次启动都需要重新扫码登录: + + ```bash + pake https://wx.qq.com --name WeChat --incognito + ``` + --- ## 安装问题 diff --git a/package.json b/package.json index b9c66e6ee1..40e38d8682 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "pake-cli", - "version": "3.11.3", + "version": "3.13.0", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" }, "packageManager": "pnpm@10.26.2", "bin": { - "pake": "./dist/cli.js" + "pake": "dist/cli.js" }, "repository": { "type": "git", - "url": "https://github.com/tw93/pake.git" + "url": "git+https://github.com/tw93/Pake.git" }, "author": { "name": "Tw93", @@ -26,6 +26,7 @@ "productivity" ], "files": [ + "LICENSE-EXCEPTION", "dist", "src-tauri" ], @@ -38,16 +39,18 @@ "analyze": "cd src-tauri && cargo bloat --release --crates", "tauri": "tauri", "cli": "cross-env NODE_ENV=development rollup -c -w", + "cli:dev": "cross-env NODE_ENV=development rollup -c -w", "cli:build": "cross-env NODE_ENV=production rollup -c", "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", "format:check": "prettier --check . --ignore-unknown", + "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts", "update": "pnpm update --verbose && cd src-tauri && cargo update", "prepublishOnly": "pnpm run cli:build" }, "type": "module", "exports": "./dist/cli.js", - "license": "MIT", + "license": "GPL-3.0-or-later", "dependencies": { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", diff --git a/rollup.config.js b/rollup.config.js index 8cc8cc3e30..d5fa2b75f5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -49,7 +49,7 @@ export default { tsconfig: "./tsconfig.json", sourceMap: !isProduction, inlineSources: !isProduction, - noEmitOnError: false, + noEmitOnError: isProduction, compilerOptions: { target: "es2020", module: "esnext", @@ -77,7 +77,6 @@ function pakeCliDevPlugin() { let devHasStarted = false; - // 智能检测包管理器 const detectPackageManager = () => { if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"; if (fs.existsSync("yarn.lock")) return "yarn"; diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000000..5dcda6e01e --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +// Only trust GITHUB_REF_NAME when the workflow actually runs on a tag; +// on workflow_dispatch it holds the branch name, which is not a version. +const refTag = + process.env.GITHUB_REF_TYPE === "tag" ? process.env.GITHUB_REF_NAME : ""; +const tag = process.argv[2] || refTag; +const errors = []; + +function readText(filePath) { + return fs.readFileSync(path.join(root, filePath), "utf8"); +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)); +} + +function expectEqual(label, actual, expected) { + if (actual !== expected) { + errors.push(`${label}: expected ${expected}, got ${actual || ""}`); + } +} + +function extractCargoVersion() { + const cargoToml = readText("src-tauri/Cargo.toml"); + const match = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); + return match?.[1]; +} + +function extractCargoLockVersion() { + const cargoLock = readText("src-tauri/Cargo.lock"); + const match = cargoLock.match( + /\[\[package\]\]\s+name = "pake"\s+version = "([^"]+)"/, + ); + return match?.[1]; +} + +function extractDistVersion() { + const distCli = readText("dist/cli.js"); + const match = distCli.match( + /\b(?:var|let|const)\s+version\s*=\s*["']([^"']+)["'];/, + ); + return match?.[1]; +} + +const packageJson = readJson("package.json"); +const packageVersion = packageJson.version; +const expectedTag = tag || `V${packageVersion}`; + +if (!/^V\d+\.\d+\.\d+$/.test(expectedTag)) { + errors.push( + `release tag must match Vx.y.z, got ${expectedTag || ""}`, + ); +} else { + expectEqual("tag version", expectedTag.slice(1), packageVersion); +} + +expectEqual( + "src-tauri/Cargo.toml version", + extractCargoVersion(), + packageVersion, +); +expectEqual( + "src-tauri/Cargo.lock version", + extractCargoLockVersion(), + packageVersion, +); +expectEqual( + "src-tauri/tauri.conf.json version", + readJson("src-tauri/tauri.conf.json").version, + packageVersion, +); +expectEqual( + "dist/cli.js bundled version", + extractDistVersion(), + packageVersion, +); +expectEqual( + "package.json repository.url", + packageJson.repository?.url, + "git+https://github.com/tw93/Pake.git", +); + +if (!packageJson.files?.includes("LICENSE-EXCEPTION")) { + errors.push( + "package.json files: LICENSE-EXCEPTION must be included in the npm package", + ); +} + +if (errors.length > 0) { + console.error("Release version check failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`Release version check passed for V${packageVersion}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 817b3add8e..d3a702ea60 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2194,9 +2194,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" dependencies = [ "crossbeam-channel", "dpi", @@ -2564,8 +2564,11 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.3" +version = "3.13.0" dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", "serde", "serde_json", "tauri", @@ -3066,7 +3069,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c0d29bf1f..e99af7612b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "pake" -version = "3.11.3" +version = "3.13.0" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] -license = "MIT" +license = "GPL-3.0-or-later" repository = "https://github.com/tw93/Pake" edition = "2021" rust-version = "1.85.0" @@ -36,6 +36,11 @@ tauri-plugin-opener = { version = "2.5.3" } tauri-plugin-single-instance = "2.4.0" tauri-plugin-notification = "2.3.3" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSDockTile"] } +objc2-foundation = { version = "0.3", features = ["NSString"] } + [features] # this feature is used for development builds from development cli cli-build = [] diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a7..70cec8cf82 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,5 @@ fn main() { + println!("cargo:rerun-if-changed=.pake/pake.json"); + println!("cargo:rerun-if-changed=.pake/tauri.conf.json"); tauri_build::build() } diff --git a/src-tauri/pake.json b/src-tauri/pake.json index 202cb6e84d..c797c64430 100644 --- a/src-tauri/pake.json +++ b/src-tauri/pake.json @@ -20,6 +20,7 @@ "start_to_tray": false, "force_internal_navigation": false, "internal_url_regex": "", + "enable_find": false, "new_window": false } ], diff --git a/src-tauri/src/app/config.rs b/src-tauri/src/app/config.rs index a40550f3e7..67f63b084a 100644 --- a/src-tauri/src/app/config.rs +++ b/src-tauri/src/app/config.rs @@ -26,6 +26,8 @@ pub struct WindowConfig { pub force_internal_navigation: bool, #[serde(default)] pub internal_url_regex: String, + #[serde(default)] + pub enable_find: bool, #[serde(default = "default_zoom")] pub zoom: u32, #[serde(default)] diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index b74d66247a..667f25155d 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -1,25 +1,74 @@ use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType}; -use std::fs::{self, File}; +use std::fs::File; use std::io::Write; use std::str::FromStr; +use std::sync::atomic::{AtomicI64, Ordering}; use tauri::http::Method; use tauri::{command, AppHandle, Manager, Url, WebviewWindow}; use tauri_plugin_http::reqwest::{ClientBuilder, Request}; -#[cfg(target_os = "macos")] use tauri::Theme; -#[derive(serde::Deserialize)] -pub struct DownloadFileParams { - url: String, - filename: String, - language: Option, +static BADGE_COUNT: AtomicI64 = AtomicI64::new(0); +const MAX_BADGE_COUNT: i64 = 99_999; +const MAX_BADGE_LABEL_CHARS: usize = 16; + +fn normalize_badge_count(count: Option) -> Option { + count.filter(|n| (1..=MAX_BADGE_COUNT).contains(n)) +} + +fn normalize_badge_label(label: Option<&str>) -> Result, String> { + let Some(label) = label.map(str::trim).filter(|label| !label.is_empty()) else { + return Ok(None); + }; + + if label.chars().count() > MAX_BADGE_LABEL_CHARS { + return Err(format!( + "Badge label must be {MAX_BADGE_LABEL_CHARS} characters or fewer" + )); + } + + Ok(Some(label.to_string())) +} + +fn apply_badge(app: &AppHandle, count: Option) -> Result<(), String> { + let label = normalize_badge_count(count).map(|n| n.to_string()); + apply_badge_label(app, label.as_deref()) +} + +#[cfg(target_os = "macos")] +fn apply_badge_label(app: &AppHandle, label: Option<&str>) -> Result<(), String> { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use objc2_foundation::NSString; + + let label = label.map(str::to_owned); + app.run_on_main_thread(move || { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let dock_tile = NSApplication::sharedApplication(mtm).dockTile(); + let ns_label = label.as_deref().map(NSString::from_str); + dock_tile.setBadgeLabel(ns_label.as_deref()); + }) + .map_err(|e| format!("Failed to dispatch dock badge update: {e}")) +} + +#[cfg(not(target_os = "macos"))] +fn apply_badge_label(app: &AppHandle, label: Option<&str>) -> Result<(), String> { + let window = app + .get_webview_window("pake") + .ok_or("Main window not found")?; + let count = label.and_then(|s| s.parse::().ok()); + window + .set_badge_count(count) + .map_err(|e| format!("Failed to set badge count: {e}")) } #[derive(serde::Deserialize)] -pub struct BinaryDownloadParams { +pub struct DownloadFileParams { + url: String, filename: String, - binary: Vec, language: Option, } @@ -90,47 +139,6 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result } } -#[command] -pub async fn download_file_by_binary( - app: AppHandle, - params: BinaryDownloadParams, -) -> Result<(), String> { - let window: WebviewWindow = app.get_webview_window("pake").ok_or("Window not found")?; - - show_toast( - &window, - &get_download_message_with_lang(MessageType::Start, params.language.clone()), - ); - - let download_dir = app - .path() - .download_dir() - .map_err(|e| format!("Failed to get download dir: {}", e))?; - - let output_path = download_dir.join(¶ms.filename); - - let path_str = output_path.to_str().ok_or("Invalid output path")?; - - let file_path = check_file_or_append(path_str); - - match fs::write(file_path, ¶ms.binary) { - Ok(_) => { - show_toast( - &window, - &get_download_message_with_lang(MessageType::Success, params.language.clone()), - ); - Ok(()) - } - Err(e) => { - show_toast( - &window, - &get_download_message_with_lang(MessageType::Failure, params.language), - ); - Err(e.to_string()) - } - } -} - #[command] pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; @@ -145,41 +153,53 @@ pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<( } #[command] -pub async fn update_theme_mode(app: AppHandle, mode: String) { - #[cfg(target_os = "macos")] - { - if let Some(window) = app.get_webview_window("pake") { - let theme = if mode == "dark" { - Theme::Dark - } else { - Theme::Light - }; - let _ = window.set_theme(Some(theme)); - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = app; - let _ = mode; - } +pub fn set_dock_badge(app: AppHandle, count: Option) -> Result<(), String> { + let normalized = normalize_badge_count(count); + BADGE_COUNT.store(normalized.unwrap_or(0), Ordering::SeqCst); + apply_badge(&app, normalized) +} + +#[command] +pub fn increment_dock_badge(app: AppHandle) -> Result<(), String> { + let current = BADGE_COUNT.load(Ordering::SeqCst); + let next = current.saturating_add(1).clamp(1, MAX_BADGE_COUNT); + BADGE_COUNT.store(next, Ordering::SeqCst); + apply_badge(&app, Some(next)) } #[command] -#[allow(unreachable_code)] -pub fn clear_cache_and_restart(app: AppHandle) -> Result<(), String> { +pub fn clear_dock_badge(app: AppHandle) -> Result<(), String> { + BADGE_COUNT.store(0, Ordering::SeqCst); + apply_badge(&app, None) +} + +#[command] +pub fn set_dock_badge_label(app: AppHandle, label: Option) -> Result<(), String> { + BADGE_COUNT.store(0, Ordering::SeqCst); + let label = normalize_badge_label(label.as_deref())?; + apply_badge_label(&app, label.as_deref()) +} + +#[command] +pub async fn update_theme_mode(app: AppHandle, mode: String) { if let Some(window) = app.get_webview_window("pake") { - match window.clear_all_browsing_data() { - Ok(_) => { - // Clear all browsing data successfully - app.restart(); - Ok(()) - } - Err(e) => { - eprintln!("Failed to clear browsing data: {}", e); - Err(format!("Failed to clear browsing data: {}", e)) - } - } - } else { - Err("Main window not found".to_string()) + let theme = if mode == "dark" { + Theme::Dark + } else { + Theme::Light + }; + let _ = window.set_theme(Some(theme)); } } + +// Apply native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor / WebKitGTK +// zoom level) instead of CSS hacks. CSS `transform: scale` and `html.style.zoom` +// break complex SPAs like ChatGPT (fixed positioning shifts, unrepainted layers); +// native zoom recalculates layout the same way a browser does for Cmd/Ctrl +/-. +#[command] +pub fn set_zoom(window: WebviewWindow, percent: f64) -> Result<(), String> { + let factor = (percent / 100.0).clamp(0.3, 2.0); + window + .set_zoom(factor) + .map_err(|e| format!("Failed to set zoom: {}", e)) +} diff --git a/src-tauri/src/app/menu.rs b/src-tauri/src/app/menu.rs index cc6f498aeb..db59b9e3f0 100644 --- a/src-tauri/src/app/menu.rs +++ b/src-tauri/src/app/menu.rs @@ -1,29 +1,39 @@ -// Menu functionality is only used on macOS -#![cfg(target_os = "macos")] - +// Menu functionality is only used on macOS; the module is gated in app/mod.rs. use crate::app::window::open_additional_window_safe; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, Wry}; use tauri_plugin_opener::OpenerExt; -pub fn get_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result> { +pub fn set_app_menu( + app: &AppHandle, + allow_multi_window: bool, + enable_find: bool, +) -> tauri::Result<()> { let pake_version = env!("CARGO_PKG_VERSION"); let pake_menu_item_title = format!("Built with Pake V{}", pake_version); + let window_submenu = window_menu(app)?; + let menu = Menu::with_items( app, &[ &app_menu(app)?, &file_menu(app, allow_multi_window)?, - &edit_menu(app)?, + &edit_menu(app, enable_find)?, &view_menu(app)?, &navigation_menu(app)?, - &window_menu(app)?, + &window_submenu, &help_menu(app, &pake_menu_item_title)?, ], )?; - Ok(menu) + app.set_menu(menu)?; + + // AppKit injects Move & Resize, Fill, Center, Full Screen Tile, and + // window-cycling once the submenu is registered as the windows menu. + window_submenu.set_as_windows_menu_for_nsapp()?; + + Ok(()) } fn app_menu(app: &AppHandle) -> tauri::Result> { @@ -69,7 +79,7 @@ fn file_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result) -> tauri::Result> { +fn edit_menu(app: &AppHandle, enable_find: bool) -> tauri::Result> { let edit_menu = Submenu::new(app, "Edit", true)?; edit_menu.append(&PredefinedMenuItem::undo(app, None)?)?; edit_menu.append(&PredefinedMenuItem::redo(app, None)?)?; @@ -86,6 +96,30 @@ fn edit_menu(app: &AppHandle) -> tauri::Result> { )?)?; edit_menu.append(&PredefinedMenuItem::select_all(app, None)?)?; edit_menu.append(&PredefinedMenuItem::separator(app)?)?; + if enable_find { + edit_menu.append(&MenuItem::with_id( + app, + "find", + "Find", + true, + Some("CmdOrCtrl+F"), + )?)?; + edit_menu.append(&MenuItem::with_id( + app, + "find_next", + "Find Next", + true, + Some("CmdOrCtrl+G"), + )?)?; + edit_menu.append(&MenuItem::with_id( + app, + "find_previous", + "Find Previous", + true, + Some("CmdOrCtrl+Shift+G"), + )?)?; + edit_menu.append(&PredefinedMenuItem::separator(app)?)?; + } edit_menu.append(&MenuItem::with_id( app, "copy_url", @@ -255,9 +289,24 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) { let _ = window.eval("triggerPasteAsPlainText()"); } } + "find" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.open()"); + } + } + "find_next" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.next()"); + } + } + "find_previous" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.previous()"); + } + } "clear_cache_restart" => { if let Some(window) = app_handle.get_webview_window("pake") { - if let Ok(_) = window.clear_all_browsing_data() { + if window.clear_all_browsing_data().is_ok() { app_handle.restart(); } } diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index ff6e5e6ae0..8fc4aaaa96 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -61,13 +61,18 @@ pub fn set_system_tray( } } "quit" => { - let _ = app.save_window_state(StateFlags::all()); + let flags = if _init_fullscreen { + StateFlags::all() + } else { + StateFlags::all() & !StateFlags::FULLSCREEN + }; + let _ = app.save_window_state(flags); app.exit(0); } _ => (), }) - .on_tray_icon_event(move |tray, event| match event { - TrayIconEvent::Click { button, .. } => { + .on_tray_icon_event(move |tray, event| { + if let TrayIconEvent::Click { button, .. } = event { if button == tauri::tray::MouseButton::Left { if let Some(window) = tray.app_handle().get_webview_window("pake") { let is_visible = window.is_visible().unwrap_or(false); @@ -84,7 +89,6 @@ pub fn set_system_tray( } } } - _ => {} }); let resolved_icon = if tray_icon_path.is_empty() { @@ -119,49 +123,54 @@ pub fn set_global_shortcut( let app_handle = app.clone(); let shortcut_hotkey = match Shortcut::from_str(&shortcut) { Ok(s) => s, - Err(_) => return Ok(()), + Err(error) => { + eprintln!("[Pake] Invalid activation shortcut '{shortcut}': {error}"); + return Ok(()); + } }; let last_triggered = Arc::new(Mutex::new(Instant::now())); - app_handle - .plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler({ - let last_triggered = Arc::clone(&last_triggered); - move |app, event, _shortcut| { - let Ok(mut last_triggered) = last_triggered.lock() else { - return; - }; - if Instant::now().duration_since(*last_triggered) - < Duration::from_millis(300) - { - return; - } - *last_triggered = Instant::now(); + if let Err(error) = app_handle.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler({ + let last_triggered = Arc::clone(&last_triggered); + move |app, event, _shortcut| { + let Ok(mut last_triggered) = last_triggered.lock() else { + return; + }; + if Instant::now().duration_since(*last_triggered) < Duration::from_millis(300) { + return; + } + *last_triggered = Instant::now(); - if shortcut_hotkey.eq(event) { - if let Some(window) = app.get_webview_window("pake") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = window.hide(); - } else { - let _ = window.show(); - let _ = window.set_focus(); - #[cfg(target_os = "linux")] - if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) - { - let _ = window.set_fullscreen(true); - } + if shortcut_hotkey.eq(event) { + if let Some(window) = app.get_webview_window("pake") { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "linux")] + if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) { + let _ = window.set_fullscreen(true); } } } } - }) - .build(), - ) - .expect("Failed to set global shortcut"); + } + }) + .build(), + ) { + eprintln!( + "[Pake] Failed to register global shortcut plugin '{shortcut}': {error}; continuing without it." + ); + return Ok(()); + } - let _ = app.global_shortcut().register(shortcut_hotkey); + if let Err(error) = app.global_shortcut().register(shortcut_hotkey) { + eprintln!("[Pake] Failed to bind global shortcut '{shortcut}': {error}"); + } Ok(()) } diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 4cdb48df26..173f1b8c60 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -1,17 +1,21 @@ use crate::app::config::PakeConfig; -use crate::util::get_data_dir; +use crate::util::{ + check_file_or_append, get_data_dir, get_download_message_with_lang, show_toast, MessageType, +}; use std::{ path::PathBuf, str::FromStr, sync::atomic::{AtomicU32, Ordering}, }; use tauri::{ - webview::{NewWindowFeatures, NewWindowResponse}, + webview::{DownloadEvent, NewWindowFeatures, NewWindowResponse}, AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; +use tauri::Theme; + #[cfg(target_os = "macos")] -use tauri::{Theme, TitleBarStyle}; +use tauri::TitleBarStyle; #[cfg(target_os = "windows")] fn build_proxy_browser_arg(url: &Url) -> Option { @@ -50,8 +54,12 @@ impl MultiWindowState { } } -pub fn set_window(app: &AppHandle, config: &PakeConfig, tauri_config: &Config) -> WebviewWindow { - build_window_with_label(app, config, tauri_config, "pake").expect("Failed to build window") +pub fn set_window( + app: &AppHandle, + config: &PakeConfig, + tauri_config: &Config, +) -> tauri::Result { + build_window_with_label(app, config, tauri_config, "pake") } pub fn open_additional_window(app: &AppHandle) -> tauri::Result { @@ -122,10 +130,12 @@ fn build_window_with_label( tauri_config: &Config, label: &str, ) -> tauri::Result { - let window_config = config - .windows - .first() - .expect("At least one window configuration is required"); + let window_config = config.windows.first().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "pake.json must define at least one window configuration", + )) + })?; let url = match window_config.url_type.as_str() { "web" => { let parsed = window_config.url.parse().map_err(|err| { @@ -177,12 +187,14 @@ fn build_window( .product_name .clone() .unwrap_or_else(|| "pake".to_string()); - let _data_dir = get_data_dir(app, package_name); + let _data_dir = get_data_dir(app, package_name).map_err(tauri::Error::Io)?; - let window_config = config - .windows - .first() - .expect("At least one window configuration is required"); + let window_config = config.windows.first().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "pake.json must define at least one window configuration", + )) + })?; let user_agent = config.user_agent.get(); @@ -273,10 +285,15 @@ fn build_window( }); } - // Add initialization scripts + // Add initialization scripts. Order matters: pakeConfig must land before + // any script that reads it (e.g. fullscreen polyfill checks for an opt-out + // flag), and toast must register `window.pakeToast` before Rust code + // calls show_toast(). window_builder = window_builder .initialization_script(&config_script) - .initialization_script(include_str!("../inject/component.js")) + .initialization_script(include_str!("../inject/find.js")) + .initialization_script(include_str!("../inject/toast.js")) + .initialization_script(include_str!("../inject/fullscreen.js")) .initialization_script(include_str!("../inject/event.js")) .initialization_script(include_str!("../inject/style.js")) .initialization_script(include_str!("../inject/theme_refresh.js")) @@ -329,6 +346,14 @@ fn build_window( let mut parsed_proxy_url: Option = None; + // Default to following the system theme (None), only force dark when explicitly set. + // Computed once; the matching platform block below is the sole consumer. + let theme = if window_config.dark_mode { + Some(Theme::Dark) + } else { + None // Follow system theme + }; + // Platform-specific configuration must be set before proxy on Windows/Linux #[cfg(target_os = "macos")] { @@ -338,20 +363,13 @@ fn build_window( TitleBarStyle::Visible }; window_builder = window_builder.title_bar_style(title_bar_style); - - // Default to following system theme (None), only force dark when explicitly set - let theme = if window_config.dark_mode { - Some(Theme::Dark) - } else { - None // Follow system theme - }; window_builder = window_builder.theme(theme); } // Windows and Linux: set data_directory before proxy_url #[cfg(not(target_os = "macos"))] { - window_builder = window_builder.data_directory(_data_dir).theme(None); + window_builder = window_builder.data_directory(_data_dir).theme(theme); if !config.proxy_url.is_empty() { if let Ok(proxy_url) = Url::from_str(&config.proxy_url) { @@ -391,7 +409,9 @@ fn build_window( } if let Some(features) = new_window_features { - // macOS popup webviews must reuse the opener webview configuration. + // Reuse only opener-provided position/size on macOS; sharing the opener + // WKWebViewConfiguration triggers duplicate WKScriptMessageHandler + // registrations on macOS 26+ and crashes the app (issue #1194). #[cfg(target_os = "macos")] { if let Some(position) = features.position() { @@ -402,9 +422,7 @@ fn build_window( window_builder = window_builder.inner_size(size.width, size.height); } - window_builder = window_builder - .with_webview_configuration(features.opener().target_configuration.clone()) - .focused(true); + window_builder = window_builder.focused(true); } #[cfg(not(target_os = "macos"))] @@ -413,7 +431,96 @@ fn build_window( } } + // Capture webview-initiated downloads (blob:, data:, Content-Disposition, + // etc.) and write them to the OS Downloads folder. This is essential for + // sites with a strict Content-Security-Policy (e.g. Gemini): their + // `connect-src` blocks Tauri's IPC origin, so downloads cannot be routed + // through the JS bridge, and downloads triggered from a sandboxed iframe + // can't reach the IPC either. Letting the browser download natively and + // catching it here is independent of the page CSP and the IPC channel. + { + let download_handle = app.clone(); + window_builder = window_builder.on_download(move |_webview, event| match event { + DownloadEvent::Requested { url, destination } => { + match download_handle.path().download_dir() { + Ok(download_dir) => { + let filename = destination + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .filter(|name| !name.is_empty()) + .or_else(|| { + url.path_segments() + .and_then(|mut segments| segments.next_back()) + .map(|segment| segment.to_string()) + .filter(|segment| !segment.is_empty()) + }) + .unwrap_or_else(|| "download".to_string()); + + let target = download_dir.join(filename); + if let Some(path_str) = target.to_str() { + *destination = PathBuf::from(check_file_or_append(path_str)); + } + } + Err(error) => { + eprintln!("[Pake] Failed to resolve download dir: {error}"); + } + } + true + } + DownloadEvent::Finished { + url: _, + path: _, + success, + } => { + if let Some(window) = download_handle.get_webview_window("pake") { + let message_type = if success { + MessageType::Success + } else { + MessageType::Failure + }; + show_toast(&window, &get_download_message_with_lang(message_type, None)); + } + true + } + _ => true, + }); + } + window_builder = window_builder.on_navigation(|_| true); window_builder.build() } + +#[cfg(all(test, target_os = "windows"))] +mod proxy_arg_tests { + use super::*; + + fn parse(url: &str) -> Url { + Url::from_str(url).unwrap() + } + + #[test] + fn http_url_with_explicit_port() { + let arg = build_proxy_browser_arg(&parse("http://127.0.0.1:7890")).unwrap(); + assert_eq!(arg, "--proxy-server=http://127.0.0.1:7890"); + } + + #[test] + fn http_url_uses_default_port_when_missing() { + let arg = build_proxy_browser_arg(&parse("http://proxy.local")).unwrap(); + assert_eq!(arg, "--proxy-server=http://proxy.local:80"); + } + + #[test] + fn socks5_url_uses_default_port_when_missing() { + let arg = build_proxy_browser_arg(&parse("socks5://proxy.local")).unwrap(); + assert_eq!(arg, "--proxy-server=socks5://proxy.local:1080"); + } + + #[test] + fn https_scheme_is_not_supported_yet() { + // https proxies fall back to platform proxy_url; we only emit a CLI arg + // for http/socks5 today. + assert!(build_proxy_browser_arg(&parse("https://proxy.local:8443")).is_none()); + } +} diff --git a/src-tauri/src/inject/auth.js b/src-tauri/src/inject/auth.js index c045baa0db..4df84d7ff7 100644 --- a/src-tauri/src/inject/auth.js +++ b/src-tauri/src/inject/auth.js @@ -27,12 +27,26 @@ function matchesAuthUrl(url, baseUrl = window.location.href) { /\/o\/oauth2/, ]; - const isMatch = oauthPatterns.some( - (pattern) => - pattern.test(hostname) || - pattern.test(pathname) || - pattern.test(fullUrl), - ); + // Enterprise SSO. Match identity providers on the host, and SAML/SSO/ADFS on + // the pathname with endpoint-shaped patterns only, so ordinary pages such as + // /settings/sso/providers (or a query string carrying an SSO URL) are not + // misread as authentication. + const enterpriseHostPatterns = [/(^|\.)okta\.com$/, /(^|\.)onelogin\.com$/]; + const enterprisePathPatterns = [ + /\/saml2?\/(sso|acs|login|metadata|consume|redirect|callback|continue)/, + /\/sso\/(saml|oidc|oauth|login|authorize|redirect|callback|acs|start|continue|metadata)/, + /\/adfs\/ls\b/, + ]; + + const isMatch = + oauthPatterns.some( + (pattern) => + pattern.test(hostname) || + pattern.test(pathname) || + pattern.test(fullUrl), + ) || + enterpriseHostPatterns.some((pattern) => pattern.test(hostname)) || + enterprisePathPatterns.some((pattern) => pattern.test(pathname)); if (isMatch) { console.log("[Pake] OAuth URL detected:", url); diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 870c959212..c00962f8d4 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -11,19 +11,13 @@ const shortcuts = { }; function setZoom(zoom) { - const html = document.getElementsByTagName("html")[0]; - const body = document.body; - const zoomValue = parseFloat(zoom) / 100; - const isWindows = /windows/i.test(navigator.userAgent); - - if (isWindows) { - body.style.transform = `scale(${zoomValue})`; - body.style.transformOrigin = "top left"; - body.style.width = `${100 / zoomValue}%`; - body.style.height = `${100 / zoomValue}%`; - } else { - html.style.zoom = zoom; - window.dispatchEvent(new Event("resize")); + // Use native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor) instead of + // CSS hacks. `transform: scale` and `html.style.zoom` break complex SPAs like + // ChatGPT: the page shifts right on Windows and parts of the UI stop repainting + // on macOS. Native zoom recalculates layout exactly like a browser does. + const invoke = window.__TAURI__?.core?.invoke; + if (invoke) { + invoke("set_zoom", { percent: parseFloat(zoom) }).catch(() => {}); } window.localStorage.setItem("htmlZoom", zoom); @@ -272,6 +266,33 @@ function shouldBypassPakeLinkHandling(rawHref) { ); } +function shouldNavigateAuthInCurrentWindow() { + return /macintosh|mac os x/i.test(navigator.userAgent); +} + +function canNavigateAuthUrl(url) { + const normalizedUrl = normalizeAnchorHref(url).toLowerCase(); + return normalizedUrl !== "" && normalizedUrl !== "about:blank"; +} + +function navigateInCurrentWindow(url) { + window.location.href = url; + return window; +} + +function openAuthNavigation(originalWindowOpen, url, name, specs) { + if (shouldNavigateAuthInCurrentWindow() && canNavigateAuthUrl(url)) { + return navigateInCurrentWindow(url); + } + + const authWindow = originalWindowOpen.call(window, url, name, specs); + if (!authWindow) { + return navigateInCurrentWindow(url); + } + + return authWindow; +} + document.addEventListener("DOMContentLoaded", () => { const tauri = window.__TAURI__; const appWindow = tauri.window.getCurrentWindow(); @@ -340,118 +361,19 @@ document.addEventListener("DOMContentLoaded", () => { true, ); - // Collect blob urls to blob by overriding window.URL.createObjectURL - function collectUrlToBlobs() { - const backupCreateObjectURL = window.URL.createObjectURL; - window.blobToUrlCaches = new Map(); - window.URL.createObjectURL = (blob) => { - const url = backupCreateObjectURL.call(window.URL, blob); - window.blobToUrlCaches.set(url, blob); - return url; - }; - } - - function convertBlobUrlToBinary(blobUrl) { - return new Promise((resolve, reject) => { - const blob = window.blobToUrlCaches.get(blobUrl); - if (!blob) { - fetch(blobUrl) - .then((res) => res.arrayBuffer()) - .then((buffer) => resolve(Array.from(new Uint8Array(buffer)))) - .catch(reject); - return; - } - const reader = new FileReader(); - reader.readAsArrayBuffer(blob); - reader.onload = () => { - resolve(Array.from(new Uint8Array(reader.result))); - }; - reader.onerror = () => reject(reader.error); - }); - } - - function downloadFromDataUri(dataURI, filename) { - try { - const byteString = atob(dataURI.split(",")[1]); - // write the bytes of the string to an ArrayBuffer - const bufferArray = new ArrayBuffer(byteString.length); - - // create a view into the buffer - const binary = new Uint8Array(bufferArray); - - // set the bytes of the buffer to the correct values - for (let i = 0; i < byteString.length; i++) { - binary[i] = byteString.charCodeAt(i); - } - - // write the ArrayBuffer to a binary, and you're done - const userLanguage = getUserLanguage(); - invoke("download_file_by_binary", { - params: { - filename, - binary: Array.from(binary), - language: userLanguage, - }, - }).catch((error) => { - console.error("Failed to download data URI file:", filename, error); - showDownloadError(filename); - }); - } catch (error) { - console.error("Failed to process data URI:", dataURI, error); - showDownloadError(filename || "file"); - } - } - - function downloadFromBlobUrl(blobUrl, filename) { - convertBlobUrlToBinary(blobUrl) - .then((binary) => { - const userLanguage = getUserLanguage(); - invoke("download_file_by_binary", { - params: { - filename, - binary, - language: userLanguage, - }, - }).catch((error) => { - console.error("Failed to download blob file:", filename, error); - showDownloadError(filename); - }); - }) - .catch((error) => { - console.error("Failed to convert blob to binary:", blobUrl, error); - showDownloadError(filename); - }); - } - - // detect blob download by createElement("a") - function detectDownloadByCreateAnchor() { - const createEle = document.createElement; - document.createElement = (el) => { - if (el !== "a") return createEle.call(document, el); - const anchorEle = createEle.call(document, el); - - // use addEventListener to avoid overriding the original click event. - anchorEle.addEventListener( - "click", - (e) => { - const url = anchorEle.href; - const filename = anchorEle.download || getFilenameFromUrl(url); - if (window.blobToUrlCaches.has(url)) { - e.preventDefault(); - e.stopImmediatePropagation(); - downloadFromBlobUrl(url, filename); - // case: download from dataURL -> convert dataURL -> - } else if (url.startsWith("data:")) { - e.preventDefault(); - e.stopImmediatePropagation(); - downloadFromDataUri(url, filename); - } - }, - true, - ); - - return anchorEle; - }; + // Trigger a native browser download via a transient anchor click. The Rust + // on_download handler then writes the file to the Downloads folder. This is + // used for blob:/data: URLs because routing their bytes through the Tauri + // IPC fails on strict-CSP sites (e.g. Gemini), whose connect-src blocks the + // IPC origin. The native download path is independent of the page CSP. + function triggerNativeDownload(url, filename) { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename || ""; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); } // process special download protocol['data:','blob:'] @@ -531,24 +453,23 @@ document.addEventListener("DOMContentLoaded", () => { const absoluteUrl = hrefUrl.href; let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl); - // Keep OAuth/authentication flows inside the app when popup support is enabled. + // Keep OAuth/authentication flows inside the app. Without --new-window, + // navigate in place so the SSO redirect chain and callback stay in the + // webview instead of falling through to the system browser. if (window.isAuthLink(absoluteUrl)) { console.log("[Pake] Handling OAuth navigation in-app:", absoluteUrl); + e.preventDefault(); + e.stopImmediatePropagation(); if (window.pakeConfig?.new_window) { - e.preventDefault(); - e.stopImmediatePropagation(); - - const authWindow = originalWindowOpen.call( - window, + openAuthNavigation( + originalWindowOpen, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); - - if (!authWindow) { - window.location.href = absoluteUrl; - } + } else { + window.location.href = absoluteUrl; } return; @@ -564,7 +485,15 @@ document.addEventListener("DOMContentLoaded", () => { } if (isInternalUrl(absoluteUrl)) { - // For internal links (based on regex or domain), let the browser handle it naturally + // With --new-window the Rust on_new_window handler opens an in-app + // window; without it, deferring to the native handler sends the + // _blank target to the system browser and strands SSO callbacks. + // Navigate in place so internal links stay inside the webview. + if (!window.pakeConfig?.new_window) { + e.preventDefault(); + e.stopImmediatePropagation(); + window.location.href = absoluteUrl; + } return; } @@ -587,11 +516,16 @@ document.addEventListener("DOMContentLoaded", () => { return; } - // Process download links for Rust to handle. - if ( - isDownloadRequired(absoluteUrl, anchorElement, e) && - !isSpecialDownload(absoluteUrl) - ) { + // Process download links. + if (isDownloadRequired(absoluteUrl, anchorElement, e)) { + // Let the browser download blob:/data: URLs natively; the Rust + // on_download handler saves them to the Downloads folder. Routing them + // through the IPC fails on strict-CSP sites (e.g. Gemini), whose + // connect-src blocks the IPC origin, and on downloads triggered from a + // sandboxed iframe where the IPC can't be reached. + if (isSpecialDownload(absoluteUrl)) { + return; + } e.preventDefault(); e.stopImmediatePropagation(); const userLanguage = getUserLanguage(); @@ -625,9 +559,6 @@ document.addEventListener("DOMContentLoaded", () => { // Prevent some special websites from executing in advance, before the click event is triggered. document.addEventListener("click", detectAnchorElementClick, true); - collectUrlToBlobs(); - detectDownloadByCreateAnchor(); - // Rewrite the window.open function. const originalWindowOpen = window.open; window.open = function (url, name, specs) { @@ -641,9 +572,15 @@ document.addEventListener("DOMContentLoaded", () => { return originalWindowOpen.call(window, url, name, specs); } - // Allow authentication popups to open normally + // Avoid macOS WebKit auth-popup crashes by navigating auth URLs in-place. if (window.isAuthPopup(url, name)) { - return originalWindowOpen.call(window, url, name, specs); + try { + const baseUrl = window.location.origin + window.location.pathname; + const absoluteUrl = new URL(url, baseUrl).href; + return openAuthNavigation(originalWindowOpen, absoluteUrl, name, specs); + } catch (error) { + return openAuthNavigation(originalWindowOpen, url, name, specs); + } } try { @@ -660,6 +597,14 @@ document.addEventListener("DOMContentLoaded", () => { return null; } + // With --new-window the native handler opens an in-app window; without it, + // originalWindowOpen would route the internal target to the system browser + // and strand SSO callbacks, so navigate in place instead. + if (!window.pakeConfig?.new_window) { + window.location.href = absoluteUrl; + return window; + } + return originalWindowOpen.call(window, absoluteUrl, name, specs); } catch (error) { return originalWindowOpen.call(window, url, name, specs); @@ -863,12 +808,10 @@ document.addEventListener("DOMContentLoaded", () => { const filename = getFilenameFromUrl(imageUrl) || "image"; // Handle different URL types - if (imageUrl.startsWith("data:")) { - downloadFromDataUri(imageUrl, filename); - } else if (imageUrl.startsWith("blob:")) { - if (window.blobToUrlCaches && window.blobToUrlCaches.has(imageUrl)) { - downloadFromBlobUrl(imageUrl, filename); - } + if (isSpecialDownload(imageUrl)) { + // Download blob:/data: natively so it works under strict CSP; the Rust + // on_download handler saves it to the Downloads folder. + triggerNativeDownload(imageUrl, filename); } else { // Regular HTTP(S) image const userLanguage = getUserLanguage(); @@ -1025,10 +968,46 @@ document.addEventListener("DOMContentLoaded", () => { }); }); -document.addEventListener("DOMContentLoaded", function () { +// Bridge the Web Notification + Web Badging APIs to Pake's Rust commands so +// pages running inside the webview can drive the macOS dock badge (and +// taskbar badge on Linux/Windows). Installs synchronously instead of waiting +// for DOMContentLoaded so feature-detection on Notification/setAppBadge +// returns the polyfill before site scripts run. +(function () { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) return; + let permVal = "granted"; let lastNotifTime = 0; let lastNotif = null; + // Pages that drive the badge directly via setAppBadge own its lifecycle; + // notifications-driven counts auto-clear on the next user interaction. + let pageManagedBadge = false; + let autoBadgeActive = false; + + const normalizeBadgeCount = (count) => { + if (typeof count !== "number" || !Number.isFinite(count)) { + throw new TypeError("Badge count must be a finite number."); + } + const normalized = Math.floor(count); + return normalized > 0 ? Math.min(normalized, 99999) : null; + }; + const setBadge = (count) => { + pageManagedBadge = true; + autoBadgeActive = false; + return invoke("set_dock_badge", { count }).catch(() => {}); + }; + const clearBadge = () => invoke("clear_dock_badge").catch(() => {}); + const setLabel = (label) => { + pageManagedBadge = true; + autoBadgeActive = false; + return invoke("set_dock_badge_label", { label }).catch(() => {}); + }; + const incrementAutoBadge = () => { + if (pageManagedBadge) return Promise.resolve(); + autoBadgeActive = true; + return invoke("increment_dock_badge").catch(() => {}); + }; window.addEventListener("focus", () => { if (lastNotif?.onclick && Date.now() - lastNotifTime < 5000) { @@ -1037,11 +1016,17 @@ document.addEventListener("DOMContentLoaded", function () { } }); - window.Notification = function (title, options) { - const { invoke } = window.__TAURI__.core; + const clearAutoBadge = () => { + if (pageManagedBadge || !autoBadgeActive) return; + autoBadgeActive = false; + clearBadge(); + }; + document.addEventListener("click", clearAutoBadge, true); + document.addEventListener("keydown", clearAutoBadge, true); + + const wrappedNotification = function (title, options) { const body = options?.body || ""; let icon = options?.icon || ""; - if (icon.startsWith("/")) { icon = window.location.origin + icon; } @@ -1056,24 +1041,68 @@ document.addEventListener("DOMContentLoaded", function () { lastNotifTime = Date.now(); lastNotif = notif; - - invoke("send_notification", { params: { title, body, icon } }).then(() => { - if (notif.onshow) notif.onshow(new Event("show")); - }); + invoke("send_notification", { params: { title, body, icon } }) + .then(() => incrementAutoBadge()) + .then(() => { + if (notif.onshow) notif.onshow(new Event("show")); + }); return notif; }; - window.Notification.requestPermission = async () => "granted"; - - Object.defineProperty(window.Notification, "permission", { + wrappedNotification.requestPermission = async () => "granted"; + Object.defineProperty(wrappedNotification, "permission", { enumerable: true, get: () => permVal, set: (v) => { permVal = v; }, }); -}); + + try { + Object.defineProperty(window, "Notification", { + configurable: true, + writable: true, + value: wrappedNotification, + }); + } catch (_) {} + + // Web Badging API: https://wicg.github.io/badging/ + // setAppBadge() with no argument shows an indicator dot; with a number, + // shows the count (0 clears). clearAppBadge() removes the badge entirely. + const setAppBadge = (count) => { + if (count === undefined) return setLabel("•"); + let normalized; + try { + normalized = normalizeBadgeCount(count); + } catch (error) { + return Promise.reject(error); + } + if (normalized === null) { + pageManagedBadge = false; + autoBadgeActive = false; + return clearBadge(); + } + return setBadge(normalized); + }; + const clearAppBadge = () => { + pageManagedBadge = false; + autoBadgeActive = false; + return clearBadge(); + }; + try { + Object.defineProperty(navigator, "setAppBadge", { + configurable: true, + writable: true, + value: setAppBadge, + }); + Object.defineProperty(navigator, "clearAppBadge", { + configurable: true, + writable: true, + value: clearAppBadge, + }); + } catch (_) {} +})(); function setDefaultZoom() { const htmlZoom = window.localStorage.getItem("htmlZoom"); diff --git a/src-tauri/src/inject/find.js b/src-tauri/src/inject/find.js new file mode 100644 index 0000000000..a749d9b74a --- /dev/null +++ b/src-tauri/src/inject/find.js @@ -0,0 +1,708 @@ +(function () { + if (window.__PAKE_FIND_SCRIPT__) { + return; + } + window.__PAKE_FIND_SCRIPT__ = true; + + const PANEL_ID = "pake-find-panel"; + const STYLE_ID = "pake-find-style"; + const MARK_ATTR = "data-pake-find"; + const ACTIVE_ATTR = "data-pake-find-active"; + const MATCH_HIGHLIGHT = "pake-find-match"; + const ACTIVE_HIGHLIGHT = "pake-find-active"; + const MAX_MATCHES = 1000; + const SEARCH_DEBOUNCE_MS = 120; + const SKIPPED_TAGS = new Set([ + "script", + "style", + "noscript", + "input", + "textarea", + "select", + "option", + ]); + + const state = { + enabled: window.pakeConfig?.enable_find === true, + panel: null, + input: null, + counter: null, + status: null, + matches: [], + activeIndex: -1, + query: "", + truncated: false, + domMarks: [], + observer: null, + searchTimer: null, + isOpen: false, + }; + + function getState() { + return { + enabled: state.enabled, + isOpen: state.isOpen, + query: state.query, + matchCount: state.matches.length, + activeIndex: state.activeIndex, + truncated: state.truncated, + }; + } + + function noop() { + return getState(); + } + + if (!state.enabled) { + window.pakeFind = { + open: noop, + close: noop, + next: noop, + previous: noop, + search: noop, + getState, + getFindShortcutAction: () => "", + }; + return; + } + + function getNodeFilter() { + return ( + window.NodeFilter || + globalThis.NodeFilter || { + SHOW_TEXT: 4, + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + } + ); + } + + function supportsCustomHighlight() { + return ( + typeof CSS !== "undefined" && + CSS.highlights && + typeof Highlight === "function" + ); + } + + function isFindPanelNode(node) { + const element = + node?.nodeType === 1 ? node : node?.parentElement || node?.parentNode; + if (!element) { + return false; + } + if (element.id === PANEL_ID) { + return true; + } + return element.closest?.(`#${PANEL_ID}`) != null; + } + + function shouldSkipElement(element) { + for (let current = element; current; current = current.parentElement) { + if (current.id === PANEL_ID) { + return true; + } + + const tagName = current.tagName?.toLowerCase(); + if (tagName && SKIPPED_TAGS.has(tagName)) { + return true; + } + + if ( + current.isContentEditable || + current.getAttribute?.("contenteditable") === "true" + ) { + return true; + } + + if (current.hidden || current.getAttribute?.("aria-hidden") === "true") { + return true; + } + } + + return false; + } + + function getSearchableTextNodes(root = document.body) { + if (!root || !document.createTreeWalker) { + return []; + } + + const nodeFilter = getNodeFilter(); + const walker = document.createTreeWalker(root, nodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue || node.nodeValue.length === 0) { + return nodeFilter.FILTER_REJECT; + } + if (shouldSkipElement(node.parentElement)) { + return nodeFilter.FILTER_REJECT; + } + return nodeFilter.FILTER_ACCEPT; + }, + }); + + const nodes = []; + let current = walker.nextNode(); + while (current) { + nodes.push(current); + current = walker.nextNode(); + } + return nodes; + } + + function createRange(node, start, end) { + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + return range; + } + + function collectMatches(query) { + const matches = []; + const normalizedQuery = query.toLocaleLowerCase(); + if (!normalizedQuery) { + return { matches, truncated: false }; + } + + for (const node of getSearchableTextNodes()) { + const text = node.nodeValue || ""; + const normalizedText = text.toLocaleLowerCase(); + let searchFrom = 0; + + while (searchFrom <= normalizedText.length) { + const index = normalizedText.indexOf(normalizedQuery, searchFrom); + if (index === -1) { + break; + } + + matches.push({ + node, + start: index, + end: index + query.length, + range: createRange(node, index, index + query.length), + mark: null, + }); + + if (matches.length >= MAX_MATCHES) { + return { matches, truncated: true }; + } + + searchFrom = index + Math.max(query.length, 1); + } + } + + return { matches, truncated: false }; + } + + function ensureStyle() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${PANEL_ID} { + position: fixed; + top: 14px; + right: 14px; + z-index: 2147483647; + display: none; + align-items: center; + gap: 6px; + box-sizing: border-box; + min-width: 278px; + max-width: min(420px, calc(100vw - 28px)); + padding: 8px; + border: 1px solid rgba(0, 0, 0, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + color: #1f2328; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + backdrop-filter: blur(16px); + } + #${PANEL_ID}[data-visible="true"] { + display: flex; + } + #${PANEL_ID} input { + min-width: 0; + flex: 1 1 auto; + height: 28px; + box-sizing: border-box; + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: #1f2328; + font: inherit; + outline: none; + } + #${PANEL_ID} input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.16); + } + #${PANEL_ID} [data-pake-find-counter] { + flex: 0 0 auto; + min-width: 42px; + color: #5f6b7a; + text-align: center; + font-size: 12px; + white-space: nowrap; + } + #${PANEL_ID} button { + flex: 0 0 auto; + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: #30363d; + font: 15px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + cursor: pointer; + } + #${PANEL_ID} button:hover { + background: rgba(0, 0, 0, 0.08); + } + #${PANEL_ID} [data-pake-find-status] { + position: absolute; + left: 10px; + top: calc(100% + 4px); + color: #d1242f; + font-size: 12px; + white-space: nowrap; + } + @media (prefers-color-scheme: dark) { + #${PANEL_ID} { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(31, 35, 40, 0.94); + color: #f0f3f6; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.36); + } + #${PANEL_ID} input { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + color: #f0f3f6; + } + #${PANEL_ID} [data-pake-find-counter] { + color: #b7c0cc; + } + #${PANEL_ID} button { + color: #f0f3f6; + } + #${PANEL_ID} button:hover { + background: rgba(255, 255, 255, 0.12); + } + } + ::highlight(${MATCH_HIGHLIGHT}) { + background: rgba(255, 214, 10, 0.58); + color: inherit; + } + ::highlight(${ACTIVE_HIGHLIGHT}) { + background: rgba(255, 149, 0, 0.9); + color: inherit; + } + mark[${MARK_ATTR}] { + background: rgba(255, 214, 10, 0.58); + color: inherit; + padding: 0; + } + mark[${MARK_ATTR}][${ACTIVE_ATTR}] { + background: rgba(255, 149, 0, 0.9); + } + `; + + (document.head || document.body || document.documentElement)?.appendChild( + style, + ); + } + + function createButton(label, title, onClick) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.setAttribute("aria-label", title); + button.addEventListener("click", onClick); + return button; + } + + function ensurePanel() { + if (state.panel) { + return state.panel; + } + + ensureStyle(); + + const panel = document.createElement("div"); + panel.id = PANEL_ID; + panel.setAttribute("role", "search"); + panel.setAttribute("aria-label", "Find in page"); + + const input = document.createElement("input"); + input.type = "search"; + input.autocomplete = "off"; + input.spellcheck = false; + input.placeholder = "Find"; + input.setAttribute("aria-label", "Find in page"); + + const counter = document.createElement("span"); + counter.setAttribute("data-pake-find-counter", ""); + counter.textContent = "0/0"; + + const previousButton = createButton("<", "Find Previous", () => previous()); + const nextButton = createButton(">", "Find Next", () => next()); + const closeButton = createButton("x", "Close Find", () => close()); + + const status = document.createElement("span"); + status.setAttribute("data-pake-find-status", ""); + + input.addEventListener("input", () => { + debounceSearch(input.value); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + previous(); + } else { + next(); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(); + } + }); + + panel.append( + input, + counter, + previousButton, + nextButton, + closeButton, + status, + ); + (document.body || document.documentElement).appendChild(panel); + + state.panel = panel; + state.input = input; + state.counter = counter; + state.status = status; + + return panel; + } + + function clearCustomHighlights() { + if (!supportsCustomHighlight()) { + return; + } + + CSS.highlights.delete(MATCH_HIGHLIGHT); + CSS.highlights.delete(ACTIVE_HIGHLIGHT); + } + + function clearDomMarks() { + const marks = Array.from( + document.querySelectorAll?.(`mark[${MARK_ATTR}]`) || state.domMarks, + ); + + for (const mark of marks) { + const parent = mark.parentNode; + const text = document.createTextNode(mark.textContent || ""); + mark.replaceWith?.(text); + parent?.normalize?.(); + } + + state.domMarks = []; + } + + function clearHighlights() { + clearCustomHighlights(); + clearDomMarks(); + } + + function applyCustomHighlights() { + if (!supportsCustomHighlight()) { + return false; + } + + const ranges = state.matches.map((match) => match.range); + CSS.highlights.set(MATCH_HIGHLIGHT, new Highlight(...ranges)); + updateActiveHighlight(); + return true; + } + + function applyDomHighlights() { + const grouped = new Map(); + for (const match of state.matches) { + const nodeMatches = grouped.get(match.node) || []; + nodeMatches.push(match); + grouped.set(match.node, nodeMatches); + } + + for (const nodeMatches of grouped.values()) { + nodeMatches.sort((a, b) => b.start - a.start); + for (const match of nodeMatches) { + try { + const mark = document.createElement("mark"); + mark.setAttribute(MARK_ATTR, ""); + match.range.surroundContents(mark); + match.mark = mark; + state.domMarks.push(mark); + } catch (error) { + // Some browser-generated text ranges cannot be wrapped safely. + } + } + } + + updateDomActiveMark(); + } + + function updateDomActiveMark() { + state.matches.forEach((match, index) => { + const mark = match.mark; + if (!mark) { + return; + } + + if (mark.toggleAttribute) { + mark.toggleAttribute(ACTIVE_ATTR, index === state.activeIndex); + } else if (index === state.activeIndex) { + mark.setAttribute(ACTIVE_ATTR, ""); + } else { + mark.removeAttribute?.(ACTIVE_ATTR); + } + }); + } + + function updateActiveHighlight() { + if (!supportsCustomHighlight()) { + updateDomActiveMark(); + return; + } + + CSS.highlights.delete(ACTIVE_HIGHLIGHT); + if (state.activeIndex >= 0 && state.matches[state.activeIndex]) { + CSS.highlights.set( + ACTIVE_HIGHLIGHT, + new Highlight(state.matches[state.activeIndex].range), + ); + } + } + + function scrollActiveIntoView() { + const active = state.matches[state.activeIndex]; + if (!active) { + return; + } + + const target = active.mark || active.range.startContainer?.parentElement; + if (target?.scrollIntoView) { + target.scrollIntoView({ block: "center", inline: "nearest" }); + } + } + + function updateCounter() { + if (!state.counter) { + return; + } + + const total = state.matches.length; + const active = state.activeIndex >= 0 ? state.activeIndex + 1 : 0; + state.counter.textContent = `${active}/${total}${state.truncated ? "+" : ""}`; + + if (state.status) { + state.status.textContent = state.query && total === 0 ? "No results" : ""; + } + } + + function runSearch(query = state.query) { + state.query = query; + clearHighlights(); + + if (!query) { + state.matches = []; + state.activeIndex = -1; + state.truncated = false; + updateCounter(); + return getState(); + } + + const result = collectMatches(query); + state.matches = result.matches; + state.truncated = result.truncated; + state.activeIndex = state.matches.length > 0 ? 0 : -1; + + if (!applyCustomHighlights()) { + applyDomHighlights(); + } + + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function debounceSearch(query) { + clearTimeout(state.searchTimer); + state.searchTimer = setTimeout(() => runSearch(query), SEARCH_DEBOUNCE_MS); + } + + function next() { + if (!state.query && state.input?.value) { + runSearch(state.input.value); + } + + if (state.matches.length === 0) { + return getState(); + } + + state.activeIndex = (state.activeIndex + 1) % state.matches.length; + updateActiveHighlight(); + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function previous() { + if (!state.query && state.input?.value) { + runSearch(state.input.value); + } + + if (state.matches.length === 0) { + return getState(); + } + + state.activeIndex = + (state.activeIndex - 1 + state.matches.length) % state.matches.length; + updateActiveHighlight(); + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function observeDocumentChanges() { + if ( + state.observer || + !document.body || + typeof MutationObserver !== "function" + ) { + return; + } + + state.observer = new MutationObserver((mutations) => { + if (!state.isOpen || !state.query) { + return; + } + if (mutations.every((mutation) => isFindPanelNode(mutation.target))) { + return; + } + debounceSearch(state.query); + }); + + state.observer.observe(document.body, { + childList: true, + characterData: true, + subtree: true, + }); + } + + function stopObservingDocumentChanges() { + state.observer?.disconnect(); + state.observer = null; + } + + function open() { + if (!state.enabled) { + return getState(); + } + + const panel = ensurePanel(); + panel.setAttribute("data-visible", "true"); + state.isOpen = true; + observeDocumentChanges(); + + requestAnimationFrame(() => { + state.input?.focus(); + state.input?.select(); + }); + + if (state.input?.value) { + runSearch(state.input.value); + } else { + updateCounter(); + } + + return getState(); + } + + function close() { + clearTimeout(state.searchTimer); + state.isOpen = false; + state.panel?.removeAttribute("data-visible"); + clearHighlights(); + stopObservingDocumentChanges(); + state.matches = []; + state.activeIndex = -1; + state.truncated = false; + updateCounter(); + return getState(); + } + + function search(query) { + if (state.input) { + state.input.value = query; + } + return runSearch(query); + } + + function getFindShortcutAction(event) { + const userAgent = navigator.userAgent || ""; + const isMac = /macintosh|mac os x/i.test(userAgent); + const hasModifier = isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + + if (!hasModifier || event.altKey) { + return ""; + } + + const key = event.key?.toLowerCase(); + if (key === "f" && !event.shiftKey) { + return "open"; + } + if (key === "g") { + return event.shiftKey ? "previous" : "next"; + } + return ""; + } + + function handleFindShortcut(event) { + const action = getFindShortcutAction(event); + if (!action) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + window.pakeFind[action](); + } + + window.pakeFind = { + open, + close, + next, + previous, + search, + getState, + getFindShortcutAction, + }; + + if (state.enabled) { + document.addEventListener("keydown", handleFindShortcut, true); + } +})(); diff --git a/src-tauri/src/inject/component.js b/src-tauri/src/inject/fullscreen.js similarity index 83% rename from src-tauri/src/inject/component.js rename to src-tauri/src/inject/fullscreen.js index 8a068fcd2d..c9f2485a63 100644 --- a/src-tauri/src/inject/component.js +++ b/src-tauri/src/inject/fullscreen.js @@ -1,28 +1,10 @@ -document.addEventListener("DOMContentLoaded", () => { - // Toast - function pakeToast(msg) { - const m = document.createElement("div"); - m.innerHTML = msg; - m.style.cssText = - "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;"; - document.body.appendChild(m); - setTimeout(function () { - const d = 0.5; - m.style.transition = - "transform " + d + "s ease-in, opacity " + d + "s ease-in"; - m.style.opacity = "0"; - setTimeout(function () { - document.body.removeChild(m); - }, d * 1000); - }, 3000); - } - - window.pakeToast = pakeToast; -}); - -// Polyfill for HTML5 Fullscreen API in Tauri webview -// This bridges the HTML5 Fullscreen API to Tauri's native window fullscreen -// Works for all video sites (YouTube, Vimeo, Bilibili, etc.) +// Polyfill for HTML5 Fullscreen API in Tauri webview. +// Bridges the standard requestFullscreen / exitFullscreen DOM API to Tauri's +// native window fullscreen so video sites (YouTube, Vimeo, Bilibili, etc.) can +// go true fullscreen on their player buttons. +// +// Split out from component.js so a future CLI flag (or custom.js override) +// can short-circuit the polyfill for apps that don't need video fullscreen. (function () { if (window.__PAKE_FULLSCREEN_POLYFILL__) return; window.__PAKE_FULLSCREEN_POLYFILL__ = true; @@ -42,7 +24,6 @@ document.addEventListener("DOMContentLoaded", () => { let wasInBody = false; let monitorId = null; - // Inject fullscreen styles if (!document.getElementById("pake-fullscreen-style")) { const styleEl = document.createElement("style"); styleEl.id = "pake-fullscreen-style"; @@ -93,7 +74,6 @@ document.addEventListener("DOMContentLoaded", () => { monitorId = null; } - // Find the actual video element function findMediaElement() { const videos = document.querySelectorAll("video"); if (videos.length > 0) { @@ -112,11 +92,9 @@ document.addEventListener("DOMContentLoaded", () => { return null; } - // Enter fullscreen function enterFullscreen(element) { fullscreenElement = element; - // If html/body element, find the video instead let targetElement = element; if (element === document.documentElement || element === document.body) { const mediaElement = findMediaElement(); @@ -130,7 +108,6 @@ document.addEventListener("DOMContentLoaded", () => { actualFullscreenElement = element; } - // Save original state originalStyles = { position: targetElement.style.position, top: targetElement.style.top, @@ -152,7 +129,6 @@ document.addEventListener("DOMContentLoaded", () => { originalNextSibling = targetElement.nextSibling; } - // Apply fullscreen targetElement.classList.add("pake-fullscreen-element"); document.body.classList.add("pake-fullscreen-active"); @@ -160,7 +136,6 @@ document.addEventListener("DOMContentLoaded", () => { document.body.appendChild(targetElement); } - // Fullscreen window appWindow.setFullscreen(true).then(() => { startFullscreenMonitor(); const event = new Event("fullscreenchange", { bubbles: true }); @@ -177,7 +152,6 @@ document.addEventListener("DOMContentLoaded", () => { return Promise.resolve(); } - // Exit fullscreen function exitFullscreen() { if (!fullscreenElement) { return Promise.resolve(); @@ -188,7 +162,6 @@ document.addEventListener("DOMContentLoaded", () => { const exitingElement = fullscreenElement; const targetElement = actualFullscreenElement; - // Restore styles and position targetElement.classList.remove("pake-fullscreen-element"); document.body.classList.remove("pake-fullscreen-active"); @@ -209,7 +182,6 @@ document.addEventListener("DOMContentLoaded", () => { } } - // Reset state fullscreenElement = null; actualFullscreenElement = null; originalStyles = null; @@ -217,7 +189,6 @@ document.addEventListener("DOMContentLoaded", () => { originalNextSibling = null; wasInBody = false; - // Exit window fullscreen return appWindow.setFullscreen(false).then(() => { const event = new Event("fullscreenchange", { bubbles: true }); document.dispatchEvent(event); @@ -231,7 +202,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // Override fullscreenEnabled Object.defineProperty(document, "fullscreenEnabled", { get: () => true, configurable: true, @@ -241,7 +211,6 @@ document.addEventListener("DOMContentLoaded", () => { configurable: true, }); - // Override fullscreenElement Object.defineProperty(document, "fullscreenElement", { get: () => fullscreenElement, configurable: true, @@ -255,7 +224,6 @@ document.addEventListener("DOMContentLoaded", () => { configurable: true, }); - // Override requestFullscreen Element.prototype.requestFullscreen = function () { return enterFullscreen(this); }; @@ -266,12 +234,10 @@ document.addEventListener("DOMContentLoaded", () => { return enterFullscreen(this); }; - // Override exitFullscreen document.exitFullscreen = exitFullscreen; document.webkitExitFullscreen = exitFullscreen; document.webkitCancelFullScreen = exitFullscreen; - // Handle Escape key document.addEventListener( "keydown", (e) => { diff --git a/src-tauri/src/inject/toast.js b/src-tauri/src/inject/toast.js new file mode 100644 index 0000000000..b0758e44a6 --- /dev/null +++ b/src-tauri/src/inject/toast.js @@ -0,0 +1,22 @@ +// Lightweight in-page toast used by Rust `show_toast` (download status, etc). +// Kept tiny and always loaded so the Rust side can rely on `window.pakeToast`. +document.addEventListener("DOMContentLoaded", () => { + function pakeToast(msg) { + const m = document.createElement("div"); + m.innerHTML = msg; + m.style.cssText = + "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;"; + document.body.appendChild(m); + setTimeout(function () { + const d = 0.5; + m.style.transition = + "transform " + d + "s ease-in, opacity " + d + "s ease-in"; + m.style.opacity = "0"; + setTimeout(function () { + document.body.removeChild(m); + }, d * 1000); + }, 3000); + } + + window.pakeToast = pakeToast; +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62441120ae..d3ed106095 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,23 +10,132 @@ use tauri_plugin_window_state::StateFlags; use std::time::Duration; const WINDOW_SHOW_DELAY: u64 = 50; +#[cfg(target_os = "linux")] +const PAKE_LINUX_WEBKIT_SAFE_MODE: &str = "PAKE_LINUX_WEBKIT_SAFE_MODE"; +#[cfg(target_os = "linux")] +const WEBKIT_DISABLE_DMABUF_RENDERER: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; +#[cfg(target_os = "linux")] +const WEBKIT_DISABLE_COMPOSITING_MODE: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; +#[cfg(target_os = "linux")] +const GDK_BACKEND: &str = "GDK_BACKEND"; use app::{ invoke::{ - clear_cache_and_restart, download_file, download_file_by_binary, send_notification, - update_theme_mode, + clear_dock_badge, download_file, increment_dock_badge, send_notification, set_dock_badge, + set_dock_badge_label, set_zoom, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, }; use util::get_pake_config; +#[cfg(any(target_os = "linux", test))] +fn is_disabled_env_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" | "native" | "disabled" + ) +} + +#[cfg(any(target_os = "linux", test))] +fn is_non_empty_env_value(value: Option<&str>) -> bool { + value.map(|value| !value.trim().is_empty()).unwrap_or(false) +} + +#[cfg(any(target_os = "linux", test))] +fn contains_niri(value: &str) -> bool { + value + .split([':', ';', ',', ' ']) + .any(|part| part.eq_ignore_ascii_case("niri")) +} + +#[cfg(any(target_os = "linux", test))] +fn should_enable_linux_webkit_safe_mode_from_values( + safe_mode: Option<&str>, + niri_socket: Option<&str>, + desktop_values: &[Option<&str>], +) -> bool { + if let Some(value) = safe_mode.filter(|value| !value.trim().is_empty()) { + return !is_disabled_env_value(value); + } + + let is_niri_session = is_non_empty_env_value(niri_socket) + || desktop_values + .iter() + .flatten() + .any(|value| contains_niri(value)); + + !is_niri_session +} + +#[cfg(any(target_os = "linux", test))] +fn should_force_wayland_gdk_backend( + gdk_backend: Option<&str>, + wayland_display: Option<&str>, + display: Option<&str>, +) -> bool { + // Respect an explicit user choice. + if is_non_empty_env_value(gdk_backend) { + return false; + } + + // On pure Wayland compositors without XWayland (e.g. Niri), $DISPLAY is unset + // and GTK defaults to the X11 backend, which aborts with "Failed to initialize + // GTK". Wayland is then the only viable backend, so forcing it is safe. + is_non_empty_env_value(wayland_display) && !is_non_empty_env_value(display) +} + +#[cfg(target_os = "linux")] +fn apply_linux_gdk_backend() { + if should_force_wayland_gdk_backend( + std::env::var(GDK_BACKEND).ok().as_deref(), + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + std::env::var("DISPLAY").ok().as_deref(), + ) { + std::env::set_var(GDK_BACKEND, "wayland"); + } +} + +#[cfg(target_os = "linux")] +fn apply_linux_webkit_runtime_flags() { + let safe_mode = std::env::var(PAKE_LINUX_WEBKIT_SAFE_MODE).ok(); + if safe_mode.as_deref().is_some_and(is_disabled_env_value) { + std::env::remove_var(WEBKIT_DISABLE_DMABUF_RENDERER); + std::env::remove_var(WEBKIT_DISABLE_COMPOSITING_MODE); + return; + } + + let desktop_values = [ + std::env::var("XDG_CURRENT_DESKTOP").ok(), + std::env::var("XDG_SESSION_DESKTOP").ok(), + std::env::var("DESKTOP_SESSION").ok(), + ]; + let desktop_refs = desktop_values + .iter() + .map(|value| value.as_deref()) + .collect::>(); + + if !should_enable_linux_webkit_safe_mode_from_values( + safe_mode.as_deref(), + std::env::var("NIRI_SOCKET").ok().as_deref(), + &desktop_refs, + ) { + return; + } + + if std::env::var(WEBKIT_DISABLE_DMABUF_RENDERER).is_err() { + std::env::set_var(WEBKIT_DISABLE_DMABUF_RENDERER, "1"); + } + if std::env::var(WEBKIT_DISABLE_COMPOSITING_MODE).is_err() { + std::env::set_var(WEBKIT_DISABLE_COMPOSITING_MODE, "1"); + } +} + pub fn run_app() { #[cfg(target_os = "linux")] { - if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } + apply_linux_gdk_backend(); + apply_linux_webkit_runtime_flags(); } let (pake_config, tauri_config) = get_pake_config(); @@ -39,13 +148,16 @@ pub fn run_app() { let start_to_tray = pake_config.windows[0].start_to_tray && show_system_tray; // Only valid when tray is enabled let multi_instance = pake_config.multi_instance; let multi_window = pake_config.multi_window; + let _enable_find = pake_config.windows[0].enable_find; let window_state_plugin = WindowStatePlugin::default() .with_state_flags(if init_fullscreen { StateFlags::FULLSCREEN } else { // Prevent flickering on the first open. - StateFlags::all() & !StateFlags::VISIBLE + // Exclude FULLSCREEN so a prior --fullscreen build's persisted state + // doesn't force fullscreen on a rebuild without --fullscreen. + StateFlags::all() & !StateFlags::VISIBLE & !StateFlags::FULLSCREEN }) .build(); @@ -76,10 +188,13 @@ pub fn run_app() { app_builder .invoke_handler(tauri::generate_handler![ download_file, - download_file_by_binary, send_notification, + increment_dock_badge, + set_dock_badge, + set_dock_badge_label, + clear_dock_badge, update_theme_mode, - clear_cache_and_restart, + set_zoom, ]) .setup(move |app| { app.manage(MultiWindowState::new( @@ -90,8 +205,7 @@ pub fn run_app() { // --- Menu Construction Start --- #[cfg(target_os = "macos")] { - let menu = app::menu::get_menu(app.app_handle(), multi_window)?; - app.set_menu(menu)?; + app::menu::set_app_menu(app.app_handle(), multi_window, _enable_find)?; // Event Handling for Custom Menu Item app.on_menu_event(move |app_handle, event| { @@ -100,7 +214,7 @@ pub fn run_app() { } // --- Menu Construction End --- - let window = set_window(app.app_handle(), &pake_config, &tauri_config); + let window = set_window(app.app_handle(), &pake_config, &tauri_config)?; set_system_tray( app.app_handle(), show_system_tray, @@ -171,7 +285,10 @@ pub fn run_app() { } }) .build(tauri::generate_context!()) - .expect("error while building tauri application") + .unwrap_or_else(|error| { + eprintln!("[Pake] Fatal error while building Tauri application: {error}"); + std::process::exit(1); + }) .run(|_app, _event| { // Handle macOS dock icon click to reopen hidden window #[cfg(target_os = "macos")] @@ -193,3 +310,99 @@ pub fn run_app() { pub fn run() { run_app() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linux_webkit_safe_mode_stays_on_by_default() { + assert!(should_enable_linux_webkit_safe_mode_from_values( + None, + None, + &[None, None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_is_disabled_for_niri_socket() { + assert!(!should_enable_linux_webkit_safe_mode_from_values( + None, + Some("/run/user/501/niri.sock"), + &[None, None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_is_disabled_for_niri_desktop() { + assert!(!should_enable_linux_webkit_safe_mode_from_values( + None, + None, + &[Some("niri"), None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_can_be_forced_on_for_niri() { + assert!(should_enable_linux_webkit_safe_mode_from_values( + Some("1"), + Some("/run/user/501/niri.sock"), + &[Some("niri"), None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_can_be_disabled_explicitly() { + for value in ["0", "false", "off", "no", "native", "disabled"] { + assert!( + !should_enable_linux_webkit_safe_mode_from_values( + Some(value), + None, + &[None, None, None] + ), + "expected {value} to disable safe mode" + ); + } + } + + #[test] + fn forces_wayland_backend_on_pure_wayland() { + assert!(should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + None + )); + } + + #[test] + fn forces_wayland_backend_when_display_is_blank() { + assert!(should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + Some(" ") + )); + } + + #[test] + fn keeps_default_backend_when_x11_display_present() { + assert!(!should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + Some(":0") + )); + } + + #[test] + fn keeps_default_backend_without_wayland_display() { + assert!(!should_force_wayland_gdk_backend(None, None, None)); + } + + #[test] + fn respects_explicit_gdk_backend_override() { + assert!(!should_force_wayland_gdk_backend( + Some("x11"), + Some("wayland-0"), + None + )); + } +} diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 22b48d2367..3e178572ed 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -23,25 +23,35 @@ pub fn get_pake_config() -> (PakeConfig, Config) { (pake_config, tauri_config) } -pub fn get_data_dir(app: &AppHandle, package_name: String) -> PathBuf { - { - let data_dir = app - .path() - .config_dir() - .expect("Failed to get data dirname") - .join(package_name); - - if !data_dir.exists() { - std::fs::create_dir(&data_dir) - .unwrap_or_else(|_| panic!("Can't create dir {}", data_dir.display())); - } - data_dir +pub fn get_data_dir(app: &AppHandle, package_name: String) -> std::io::Result { + let data_dir = app + .path() + .config_dir() + .map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Failed to resolve config dir: {err}"), + ) + })? + .join(package_name); + + if !data_dir.exists() { + std::fs::create_dir_all(&data_dir).map_err(|err| { + std::io::Error::new( + err.kind(), + format!("Can't create dir {}: {err}", data_dir.display()), + ) + })?; } + + Ok(data_dir) } pub fn show_toast(window: &WebviewWindow, message: &str) { let script = format!(r#"pakeToast("{message}");"#); - window.eval(&script).unwrap(); + if let Err(error) = window.eval(&script) { + eprintln!("[Pake] Failed to show toast: {error}"); + } } pub enum MessageType { @@ -101,10 +111,16 @@ pub fn get_download_message_with_lang( .to_string() } -// Check if the file exists, if it exists, add a number to file name +/// Check if the file exists. If it does, append `-N` to the stem until a free +/// path is found. +/// +/// Robustness notes: +/// - Files without an extension are handled (we keep them extensionless). +/// - If the numeric suffix would overflow `u32::MAX` we fall back to the +/// original file_path so the caller never enters an infinite loop on +/// pathologically large filenames (regression guard for #1183). pub fn check_file_or_append(file_path: &str) -> String { let mut new_path = PathBuf::from(file_path); - let mut counter = 0; while new_path.exists() { let file_stem = new_path @@ -116,16 +132,24 @@ pub fn check_file_or_append(file_path: &str) -> String { .map(|e| e.to_string_lossy().to_string()); let parent_dir = new_path.parent().unwrap_or(Path::new("")); - let new_file_stem = match file_stem.rfind('-') { - Some(index) if file_stem[index + 1..].parse::().is_ok() => { + let parsed_suffix = file_stem.rfind('-').and_then(|index| { + file_stem[index + 1..] + .parse::() + .ok() + .map(|n| (index, n)) + }); + + let new_file_stem = match parsed_suffix { + Some((index, current)) => { + let Some(next) = current.checked_add(1) else { + // u32::MAX collisions are a sign of something pathological; + // bail with the original path instead of looping forever. + return file_path.to_string(); + }; let base_name = &file_stem[..index]; - counter = file_stem[index + 1..].parse::().unwrap() + 1; - format!("{base_name}-{counter}") - } - _ => { - counter += 1; - format!("{file_stem}-{counter}") + format!("{base_name}-{next}") } + None => format!("{file_stem}-1"), }; new_path = match &extension { @@ -136,3 +160,86 @@ pub fn check_file_or_append(file_path: &str) -> String { new_path.to_string_lossy().into_owned() } + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + use std::path::PathBuf; + + fn temp_path(name: &str) -> PathBuf { + let mut dir = env::temp_dir(); + dir.push(format!( + "pake-util-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + fs::create_dir_all(&dir).unwrap(); + dir.push(name); + dir + } + + #[test] + fn check_file_or_append_returns_input_when_missing() { + let path = temp_path("ghost.txt"); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert_eq!(resolved, path.to_string_lossy()); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_increments_suffix() { + let path = temp_path("dup.txt"); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.ends_with("dup-1.txt"), "got {resolved}"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_handles_files_without_extension() { + let path = temp_path("README"); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.ends_with("README-1"), "got {resolved}"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_does_not_panic_on_huge_suffix() { + let path = temp_path(&format!("huge-{}.txt", u32::MAX)); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.contains("huge-")); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn download_message_falls_back_to_english_for_unknown_locale() { + let msg = get_download_message_with_lang(MessageType::Start, Some("fr-FR".to_string())); + assert_eq!(msg, "Start downloading~"); + } + + #[test] + fn download_message_picks_chinese_for_zh_locales() { + for tag in ["zh", "zh-CN", "zh-TW", "en-CN", "en-HK"] { + let msg = get_download_message_with_lang(MessageType::Success, Some(tag.to_string())); + assert_eq!( + msg, "下载成功,已保存到下载目录~", + "expected Chinese for {tag}" + ); + } + } + + #[test] + fn download_message_failure_localized() { + let en = get_download_message_with_lang(MessageType::Failure, Some("en".into())); + let zh = get_download_message_with_lang(MessageType::Failure, Some("zh".into())); + assert!(en.contains("Download failed")); + assert!(zh.contains("下载失败")); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f41d38a22f..184e93b690 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.3", + "version": "3.13.0", "app": { "withGlobalTauri": true, "trayIcon": { diff --git a/tests/index.js b/tests/index.js index 6cf3dde0a4..63c5be29d3 100644 --- a/tests/index.js +++ b/tests/index.js @@ -136,11 +136,15 @@ class PakeTestRunner { try { execSync(`node "${config.CLI_PATH}" --version`, { encoding: "utf8", - timeout: 3000, + timeout: TIMEOUTS.QUICK, }); - console.log("[PASS] CLI is executable"); + console.log("[PASS] CLI responds"); } catch (error) { - console.log("[FAIL] CLI is not executable"); + const reason = + error.signal === "SIGTERM" + ? `timed out after ${TIMEOUTS.QUICK}ms` + : error.message; + console.log(`[FAIL] CLI did not respond: ${reason}`); process.exit(1); } diff --git a/tests/integration/workflow-paths.test.js b/tests/integration/workflow-paths.test.js index 4d6603e6af..cc209dfb5f 100644 --- a/tests/integration/workflow-paths.test.js +++ b/tests/integration/workflow-paths.test.js @@ -6,6 +6,7 @@ */ import { describe, it, expect } from "vitest"; +import fs from "fs"; import path from "path"; describe("Workflow path integration", () => { @@ -27,6 +28,9 @@ describe("Workflow path integration", () => { primary: "appname.rpm", fallback: "src-tauri/target/release/bundle/rpm", }, + zst: { + primary: "appname-1.0.0-1-x86_64.pkg.tar.zst", + }, }; // Verify paths are defined @@ -34,6 +38,8 @@ describe("Workflow path integration", () => { expect(linuxPaths.deb.fallback).toBeTruthy(); expect(linuxPaths.appimage.primary).toBeTruthy(); expect(linuxPaths.appimage.fallback).toBeTruthy(); + expect(linuxPaths.rpm.primary).toBeTruthy(); + expect(linuxPaths.zst.primary).toBeTruthy(); }); it("should match Windows output paths", () => { @@ -99,12 +105,36 @@ describe("Workflow path integration", () => { it("should filter valid targets", () => { const targets = "deb,invalid,appimage"; const parsedTargets = targets.split(",").map((t) => t.trim()); - const validTargets = ["deb", "appimage", "rpm"]; + const validTargets = ["deb", "appimage", "rpm", "zst"]; const filtered = parsedTargets.filter((t) => validTargets.includes(t)); expect(filtered).toEqual(["deb", "appimage"]); expect(filtered).not.toContain("invalid"); }); + + it("should keep zst in valid Linux targets", () => { + const targets = "deb,zst"; + const parsedTargets = targets.split(",").map((t) => t.trim()); + const validTargets = ["deb", "appimage", "rpm", "zst"]; + const filtered = parsedTargets.filter((t) => validTargets.includes(t)); + + expect(filtered).toEqual(["deb", "zst"]); + }); + }); + + describe("Workflow artifact uploads", () => { + it("should upload every Linux workflow package format", () => { + const workflow = fs.readFileSync( + ".github/workflows/pake-cli.yaml", + "utf8", + ); + + expect(workflow).toContain("Upload DEB (Linux)"); + expect(workflow).toContain("Upload AppImage (Linux)"); + expect(workflow).toContain("Upload RPM (Linux)"); + expect(workflow).toContain("Upload ZST (Linux)"); + expect(workflow).toContain("path: ${{ inputs.name }}-*.pkg.tar.zst"); + }); }); describe("Architecture-specific paths", () => { diff --git a/tests/unit/__snapshots__/merge-window-options.test.ts.snap b/tests/unit/__snapshots__/merge-window-options.test.ts.snap new file mode 100644 index 0000000000..42b1144098 --- /dev/null +++ b/tests/unit/__snapshots__/merge-window-options.test.ts.snap @@ -0,0 +1,88 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildWindowConfigOverrides > matches the default snapshot on Linux 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_find": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": false, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; + +exports[`buildWindowConfigOverrides > matches the default snapshot on Windows 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_find": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": false, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; + +exports[`buildWindowConfigOverrides > matches the default snapshot on macOS 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_find": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": true, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; diff --git a/tests/unit/auth-sso-patterns.test.js b/tests/unit/auth-sso-patterns.test.js new file mode 100644 index 0000000000..35bf65fb17 --- /dev/null +++ b/tests/unit/auth-sso-patterns.test.js @@ -0,0 +1,71 @@ +import fs from "fs"; +import path from "path"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +function loadAuthHelpers() { + const source = fs.readFileSync( + path.join(process.cwd(), "src-tauri/src/inject/auth.js"), + "utf-8", + ); + + const context = { + console, + URL, + window: { + location: { href: "https://example.com/app" }, + }, + }; + + runInNewContext(source, context); + return context.window; +} + +describe("auth SSO patterns", () => { + const { isAuthLink, isAuthPopup } = loadAuthHelpers(); + + it("matches enterprise SSO providers and endpoints", () => { + expect(isAuthLink("https://mycompany.okta.com/app/sign-on")).toBe(true); + expect(isAuthLink("https://acme.onelogin.com/login")).toBe(true); + expect(isAuthLink("https://idp.example.com/saml/acs")).toBe(true); + expect(isAuthLink("https://idp.example.com/sso/redirect")).toBe(true); + expect(isAuthLink("https://fs.example.com/adfs/ls/?wa=wsignin1.0")).toBe( + true, + ); + }); + + it("still matches the original OAuth providers", () => { + expect(isAuthLink("https://accounts.google.com/o/oauth2/auth")).toBe(true); + expect(isAuthLink("https://login.microsoftonline.com/common")).toBe(true); + }); + + it("does not flag ordinary application URLs", () => { + expect(isAuthLink("https://example.com/dashboard")).toBe(false); + expect(isAuthLink("https://example.com/reports/q3")).toBe(false); + }); + + it("does not flag ordinary pages that merely contain sso or saml in the path", () => { + expect(isAuthLink("https://app.example.com/settings/sso/providers")).toBe( + false, + ); + expect(isAuthLink("https://app.example.com/docs/saml/overview")).toBe( + false, + ); + }); + + it("does not flag a query string that carries an SSO URL", () => { + expect( + isAuthLink( + "https://app.example.com/?next=https://idp.example.com/sso/saml", + ), + ).toBe(false); + }); + + it("does not flag look-alike provider suffix hosts", () => { + expect(isAuthLink("https://okta.com.evil.test/app")).toBe(false); + }); + + it("treats known auth window names as popups", () => { + expect(isAuthPopup("https://example.com/dashboard", "oauth2")).toBe(true); + }); +}); diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 61d50293b3..380e64e8a7 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -1,13 +1,30 @@ +import os from 'os'; import path from 'path'; import fsExtra from 'fs-extra'; import { afterEach, describe, expect, it, vi } from 'vitest'; +const execaMock = vi.hoisted(() => vi.fn()); + +vi.mock('execa', () => ({ + execa: execaMock, +})); + vi.mock('@/utils/dir', () => ({ npmDirectory: process.cwd(), tauriConfigDirectory: path.join(process.cwd(), 'src-tauri', '.pake'), })); import BaseBuilder from '@/builders/BaseBuilder'; +import WinBuilder from '@/builders/WinBuilder'; +import { + _resetPackageManagerCache, + configureCargoRegistry, + detectPackageManager, + getBuildEnvironment, + getInstallCommand, +} from '@/builders/env'; +import logger from '@/options/logger'; +import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; class TestBuilder extends BaseBuilder { getFileName(): string { @@ -15,22 +32,90 @@ class TestBuilder extends BaseBuilder { } } +const originalCnMirrorEnv = process.env[CN_MIRROR_ENV]; +const originalCargoTargetDir = process.env.CARGO_TARGET_DIR; +const tempDirs: string[] = []; + +const GENERATED_MIRROR_CONFIG = `[source.crates-io] +replace-with = 'rsproxy-sparse' +[source.rsproxy] +registry = "https://rsproxy.cn/crates.io-index" +[source.rsproxy-sparse] +registry = "sparse+https://rsproxy.cn/index/" +[registries.rsproxy] +index = "https://rsproxy.cn/crates.io-index" +[net] +git-fetch-with-cli = true +`; + +async function createCargoFixture(projectConfig?: string) { + const tempDir = await fsExtra.mkdtemp( + path.join(os.tmpdir(), 'pake-base-builder-'), + ); + tempDirs.push(tempDir); + + const tauriSrcPath = path.join(tempDir, 'src-tauri'); + const projectConf = path.join(tauriSrcPath, '.cargo', 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + + await fsExtra.outputFile(projectCnConf, GENERATED_MIRROR_CONFIG); + if (projectConfig !== undefined) { + await fsExtra.outputFile(projectConf, projectConfig); + } + + return { tauriSrcPath, projectConf, projectCnConf }; +} + +function mockPackageManagers(options: { + pnpm?: string | Error; + npm?: string | Error; +}) { + execaMock.mockImplementation(async (command: string) => { + const value = options[command as 'pnpm' | 'npm']; + + if (value instanceof Error) { + throw value; + } + + if (typeof value === 'string') { + return { stdout: value }; + } + + throw new Error(`${command} not found`); + }); +} + describe('BaseBuilder guards', () => { - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + execaMock.mockReset(); + _resetPackageManagerCache(); + + if (originalCnMirrorEnv === undefined) { + delete process.env[CN_MIRROR_ENV]; + } else { + process.env[CN_MIRROR_ENV] = originalCnMirrorEnv; + } + + if (originalCargoTargetDir === undefined) { + delete process.env.CARGO_TARGET_DIR; + } else { + process.env.CARGO_TARGET_DIR = originalCargoTargetDir; + } + + await Promise.all(tempDirs.splice(0).map((dir) => fsExtra.remove(dir))); }); it('prepends /usr/bin to PATH for macOS build environment', () => { - const builder = new TestBuilder({} as any); const originalPath = process.env.PATH; process.env.PATH = '/opt/homebrew/bin:/usr/local/bin'; try { - const env = (builder as any).getBuildEnvironment(); + const env = getBuildEnvironment(); if (process.platform === 'darwin') { expect(env).toBeDefined(); - expect(env.PATH.startsWith('/usr/bin:')).toBe(true); + expect(env!.PATH.startsWith('/usr/bin:')).toBe(true); } else { expect(env).toBeUndefined(); } @@ -39,35 +124,239 @@ describe('BaseBuilder guards', () => { } }); - it('skips copy when source and destination are the same path', async () => { - const builder = new TestBuilder({} as any); - const copySpy = vi - .spyOn(fsExtra, 'copy') - .mockResolvedValue(undefined as any); + it('skips Cargo registry copy when source and destination resolve to the same path', async () => { + // configureCargoRegistry uses a same-path guard internally; if the + // CN-mirror file and the project config end up identical we should not + // crash with "source and destination must not be the same". + const tempDir = await fsExtra.mkdtemp( + path.join(os.tmpdir(), 'pake-base-builder-same-'), + ); + tempDirs.push(tempDir); + const tauriSrcPath = path.join(tempDir, 'src-tauri'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + const projectConf = path.join(tauriSrcPath, '.cargo', 'config.toml'); + await fsExtra.outputFile(projectCnConf, GENERATED_MIRROR_CONFIG); + await fsExtra.outputFile(projectConf, GENERATED_MIRROR_CONFIG); await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/same', '/tmp/same'), + configureCargoRegistry(tauriSrcPath, true), ).resolves.toBeUndefined(); - expect(copySpy).not.toHaveBeenCalled(); }); - it('suppresses same-path fs-extra copy errors', async () => { - const builder = new TestBuilder({} as any); - vi.spyOn(fsExtra, 'copy').mockRejectedValue( - new Error('Source and destination must not be the same.'), + it('does not enable CN mirror by default', () => { + delete process.env[CN_MIRROR_ENV]; + + expect(isCnMirrorEnabled()).toBe(false); + expect(isCnMirrorEnabled('false')).toBe(false); + expect(isCnMirrorEnabled('0')).toBe(false); + }); + + it.each(['1', 'true', 'yes', 'on', ' TRUE '])( + 'enables CN mirror for %s', + (value) => { + process.env[CN_MIRROR_ENV] = value; + + expect(isCnMirrorEnabled()).toBe(true); + }, + ); + + it('uses official npm registry by default', () => { + const command = getInstallCommand('pnpm', false); + + expect(command).toContain('pnpm install'); + expect(command).not.toContain('registry.npmmirror.com'); + }); + + it('uses npmmirror only when CN mirror is enabled', () => { + const command = getInstallCommand('npm', true); + + expect(command).toContain( + 'npm install --registry=https://registry.npmmirror.com --legacy-peer-deps', ); + }); - await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/a', '/tmp/b'), - ).resolves.toBeUndefined(); + it('uses pnpm when the installed major matches the pinned package manager', async () => { + mockPackageManagers({ pnpm: '10.26.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('pnpm'); + expect(execaMock).toHaveBeenCalledTimes(1); + expect(execaMock).toHaveBeenCalledWith('pnpm', ['--version']); + }); + + it('falls back to npm when the installed pnpm major does not match the pinned major', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + mockPackageManagers({ pnpm: '11.2.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + expect(execaMock).toHaveBeenCalledWith('pnpm', ['--version']); + expect(execaMock).toHaveBeenCalledWith('npm', ['--version'], { + stdio: 'ignore', + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('using npm for package management instead'), + ); + }); + + it('parses v-prefixed pnpm versions before comparing majors', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + mockPackageManagers({ pnpm: 'v11.2.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Detected pnpm v11.2.2'), + ); }); - it('rethrows non-same-path copy errors', async () => { + it('throws a clear error when pnpm is incompatible and npm is unavailable', async () => { + mockPackageManagers({ + pnpm: '11.2.2', + npm: new Error('missing npm'), + }); + + await expect(detectPackageManager()).rejects.toThrow( + 'Detected pnpm v11.2.2, but Pake is pinned to pnpm@10.26.2', + ); + expect(execaMock).toHaveBeenCalledWith('npm', ['--version'], { + stdio: 'ignore', + }); + }); + + it('falls back to npm when pnpm is unavailable', async () => { + mockPackageManagers({ pnpm: new Error('missing pnpm'), npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + }); + + it('throws when neither pnpm nor npm is available', async () => { + mockPackageManagers({ + pnpm: new Error('missing pnpm'), + npm: new Error('missing npm'), + }); + + await expect(detectPackageManager()).rejects.toThrow( + 'Neither pnpm nor npm is available', + ); + }); + + it('caches the detected package manager until reset', async () => { + mockPackageManagers({ pnpm: '10.26.2' }); + + await expect(detectPackageManager()).resolves.toBe('pnpm'); + mockPackageManagers({ pnpm: new Error('missing pnpm'), npm: '11.12.1' }); + await expect(detectPackageManager()).resolves.toBe('pnpm'); + expect(execaMock).toHaveBeenCalledTimes(1); + + _resetPackageManagerCache(); + await expect(detectPackageManager()).resolves.toBe('npm'); + }); + + it('copies Cargo mirror config only when CN mirror is enabled', async () => { + const { tauriSrcPath, projectConf, projectCnConf } = + await createCargoFixture(); + + await configureCargoRegistry(tauriSrcPath, true); + + expect(await fsExtra.readFile(projectConf, 'utf8')).toBe( + await fsExtra.readFile(projectCnConf, 'utf8'), + ); + }); + + it('removes generated Cargo mirror config when CN mirror is disabled', async () => { + const { tauriSrcPath, projectConf } = await createCargoFixture( + GENERATED_MIRROR_CONFIG, + ); + + await configureCargoRegistry(tauriSrcPath, false); + + expect(await fsExtra.pathExists(projectConf)).toBe(false); + }); + + it('keeps custom Cargo config when CN mirror is disabled', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const customConfig = `${GENERATED_MIRROR_CONFIG} +# custom user setting +`; + const { tauriSrcPath, projectConf } = + await createCargoFixture(customConfig); + + await configureCargoRegistry(tauriSrcPath, false); + + expect(await fsExtra.readFile(projectConf, 'utf8')).toBe(customConfig); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('still references rsproxy.cn'), + ); + }); + + it('keeps the BaseBuilder hierarchy intact', () => { + // Sanity check that subclasses can still construct against the slimmer + // BaseBuilder after env helpers were extracted. const builder = new TestBuilder({} as any); - vi.spyOn(fsExtra, 'copy').mockRejectedValue(new Error('permission denied')); + expect(builder).toBeInstanceOf(BaseBuilder); + expect(builder.getFileName()).toBe('test-app'); + }); - await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/a', '/tmp/b'), - ).rejects.toThrow('permission denied'); + it('builds with generated .pake config and cli-build feature', () => { + const builder = new TestBuilder({ + debug: false, + targets: 'deb', + } as any); + + const command = (builder as any).getBuildCommand('pnpm'); + const normalizedCommand = command.replace(/\\/g, '/'); + + expect(normalizedCommand).toContain('src-tauri/.pake/tauri.conf.json'); + expect(command).toContain('--features cli-build'); + }); + + it('copies Windows build artifacts from CARGO_TARGET_DIR when it is set', () => { + const cargoTargetDir = path.join(process.cwd(), '.short-cargo-target'); + process.env.CARGO_TARGET_DIR = cargoTargetDir; + + const builder = new WinBuilder({ + debug: false, + name: 'ChatGPT', + targets: 'x64', + } as any); + + const appPath = (builder as any).getBuildAppPath( + process.cwd(), + 'ChatGPT_1.0.0_x64_en-US', + 'msi', + ); + const binaryPath = (builder as any).getRawBinarySourcePath( + process.cwd(), + 'ChatGPT', + ); + + expect(appPath).toBe( + path.join( + cargoTargetDir, + 'x86_64-pc-windows-msvc', + 'release', + 'bundle', + 'msi', + 'ChatGPT_1.0.0_x64_en-US.msi', + ), + ); + expect(binaryPath).toBe( + path.join( + cargoTargetDir, + 'x86_64-pc-windows-msvc', + 'release', + 'pake-chatgpt.exe', + ), + ); + }); + + it('tracks generated Pake config files in the Cargo build script', async () => { + const buildScript = await fsExtra.readFile( + path.join(process.cwd(), 'src-tauri', 'build.rs'), + 'utf8', + ); + + expect(buildScript).toContain('cargo:rerun-if-changed=.pake/pake.json'); + expect(buildScript).toContain( + 'cargo:rerun-if-changed=.pake/tauri.conf.json', + ); }); }); diff --git a/tests/unit/builders.test.ts b/tests/unit/builders.test.ts index dfedb9ca2c..cafe1e059f 100644 --- a/tests/unit/builders.test.ts +++ b/tests/unit/builders.test.ts @@ -1,131 +1,68 @@ import { describe, it, expect } from 'vitest'; - -/** - * Tests for multi-target build parsing logic - * These tests verify the core logic used in LinuxBuilder without needing to instantiate the class - */ -describe('Multi-target build parsing', () => { - /** - * Simulates the logic from LinuxBuilder.build() - */ - function parseAndFilterTargets(targetsString: string): string[] { - const validTargets = ['deb', 'appimage', 'rpm']; - const requestedTargets = targetsString - .split(',') - .map((t: string) => t.trim()); - - return validTargets.filter((target) => requestedTargets.includes(target)); - } - - describe('Target parsing', () => { - it('should parse single target', () => { - const result = parseAndFilterTargets('deb'); - - expect(result).toEqual(['deb']); - expect(result).toHaveLength(1); - }); - - it('should parse comma-separated targets', () => { - const result = parseAndFilterTargets('deb,appimage'); - - expect(result).toEqual(['deb', 'appimage']); - expect(result).toHaveLength(2); - }); - - it('should handle targets with spaces', () => { - const result = parseAndFilterTargets('deb, appimage, rpm'); - - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); - }); - - it('should filter out invalid targets', () => { - const result = parseAndFilterTargets('deb,invalid,appimage'); - - expect(result).toEqual(['deb', 'appimage']); - expect(result).not.toContain('invalid'); - expect(result).toHaveLength(2); - }); - - it('should handle all valid targets', () => { - const result = parseAndFilterTargets('deb,appimage,rpm'); - - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); - }); - - it('should return empty array for all invalid targets', () => { - const result = parseAndFilterTargets('invalid1,invalid2'); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it('should handle excessive whitespace', () => { - const result = parseAndFilterTargets(' deb , appimage , rpm '); - - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); - }); - - it('should be case-sensitive', () => { - const result = parseAndFilterTargets('DEB,APPIMAGE'); - - // Should not match uppercase - expect(result).toEqual([]); - }); - - it('should handle single target with comma', () => { - const result = parseAndFilterTargets('deb,'); - - expect(result).toEqual(['deb']); - expect(result).toHaveLength(1); - }); +import { + LINUX_TARGET_TYPES, + filterLinuxTargets, + needsTemporaryDebForZst, +} from '../../bin/utils/targets.js'; + +describe('Linux target filtering', () => { + it('parses a single target', () => { + expect(filterLinuxTargets('deb')).toEqual(['deb']); }); - describe('Target validation', () => { - it('should validate against Linux target types', () => { - const validTargets = ['deb', 'appimage', 'rpm']; - - expect(validTargets).toContain('deb'); - expect(validTargets).toContain('appimage'); - expect(validTargets).toContain('rpm'); - expect(validTargets).not.toContain('msi'); - expect(validTargets).not.toContain('dmg'); - }); + it('parses comma-separated targets', () => { + expect(filterLinuxTargets('deb,appimage')).toEqual(['deb', 'appimage']); + }); - it('should check if target is valid', () => { - const validTargets = ['deb', 'appimage', 'rpm']; - const testTargets = ['deb', 'invalid', 'appimage', 'msi']; + it('handles targets with spaces', () => { + expect(filterLinuxTargets('deb, appimage, rpm, zst')).toEqual([ + 'deb', + 'appimage', + 'rpm', + 'zst', + ]); + }); - const valid = testTargets.filter((t) => validTargets.includes(t)); - const invalid = testTargets.filter((t) => !validTargets.includes(t)); + it('filters out invalid targets', () => { + expect(filterLinuxTargets('deb,invalid,appimage')).toEqual([ + 'deb', + 'appimage', + ]); + }); - expect(valid).toEqual(['deb', 'appimage']); - expect(invalid).toEqual(['invalid', 'msi']); - }); + it('returns empty array when no target is valid', () => { + expect(filterLinuxTargets('invalid1,invalid2')).toEqual([]); }); - describe('Architecture suffix handling', () => { - it('should extract format from arm64 target', () => { - const target = 'deb-arm64'; - const format = target.replace('-arm64', ''); + it('handles excessive whitespace', () => { + expect(filterLinuxTargets(' deb , appimage , rpm , zst ')).toEqual([ + 'deb', + 'appimage', + 'rpm', + 'zst', + ]); + }); - expect(format).toBe('deb'); - }); + it('is case-sensitive', () => { + expect(filterLinuxTargets('DEB,APPIMAGE')).toEqual([]); + }); - it('should keep format without suffix', () => { - const target = 'deb'; - const format = target.replace('-arm64', ''); + it('ignores trailing commas', () => { + expect(filterLinuxTargets('deb,')).toEqual(['deb']); + }); - expect(format).toBe('deb'); - }); + it('preserves canonical order regardless of input order', () => { + expect(filterLinuxTargets('zst,deb')).toEqual(['deb', 'zst']); + }); - it('should handle appimage-arm64', () => { - const target = 'appimage-arm64'; - const format = target.replace('-arm64', ''); + it('covers exactly the supported Linux formats', () => { + expect(LINUX_TARGET_TYPES).toEqual(['deb', 'appimage', 'rpm', 'zst']); + }); - expect(format).toBe('appimage'); - }); + it('uses a temporary deb only when zst is requested without deb', () => { + expect(needsTemporaryDebForZst(['zst'])).toBe(true); + expect(needsTemporaryDebForZst(['appimage', 'zst'])).toBe(true); + expect(needsTemporaryDebForZst(['deb', 'zst'])).toBe(false); + expect(needsTemporaryDebForZst(['deb', 'appimage'])).toBe(false); }); }); diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 9b8c800502..0b8a3376e1 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -1,9 +1,25 @@ import { describe, expect, it } from 'vitest'; import { getCliProgram } from '../../bin/helpers/cli-program.js'; +import { validateNumberInput } from '../../bin/utils/validate.js'; describe('CLI options', () => { const program = getCliProgram(); + it('shows meta options in help', () => { + const help = program.helpInformation(); + + expect(help).toContain('-h, --help'); + expect(help).toContain('-v, --version'); + }); + + it('shows advanced options in help', () => { + const help = program.helpInformation(); + + expect(help).toContain('--enable-find'); + expect(help).toContain('--internal-url-regex'); + expect(help).toContain('--hide-on-close'); + }); + it('registers hidden --multi-window option', () => { const option = program.options.find( (item) => item.long === '--multi-window', @@ -13,13 +29,42 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); }); - it('registers hidden --internal-url-regex option', () => { + it('exposes --internal-url-regex option', () => { const option = program.options.find( (item) => item.long === '--internal-url-regex', ); expect(option).toBeDefined(); expect(option?.defaultValue).toBe(''); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --safe-domain option', () => { + const option = program.options.find( + (item) => item.long === '--safe-domain', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(''); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --force-internal-navigation option', () => { + const option = program.options.find( + (item) => item.long === '--force-internal-navigation', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --new-window option', () => { + const option = program.options.find((item) => item.long === '--new-window'); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBeFalsy(); }); it('registers hidden --identifier option', () => { @@ -36,4 +81,40 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); expect(option?.hidden).toBe(true); }); + + it('registers hidden --enable-find option', () => { + const option = program.options.find( + (item) => item.long === '--enable-find', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBe(true); + }); + + it('rejects malformed zoom values instead of truncating them', () => { + const option = program.options.find((item) => item.long === '--zoom'); + + expect(option).toBeDefined(); + expect(option?.parseArg?.('80', undefined)).toBe(80); + expect(() => option?.parseArg?.('80abc', undefined)).toThrow( + '--zoom must be a number between 50 and 200', + ); + }); + + it('rejects non-finite numeric option values', () => { + expect(() => validateNumberInput('Infinity')).toThrow('Not a number.'); + expect(() => validateNumberInput('-Infinity')).toThrow('Not a number.'); + expect(validateNumberInput('1200')).toBe(1200); + }); + + it('rejects blank numeric option values', () => { + expect(() => validateNumberInput('')).toThrow('Not a number.'); + expect(() => validateNumberInput(' ')).toThrow('Not a number.'); + }); + + it('rejects negative numeric option values', () => { + expect(() => validateNumberInput('-100')).toThrow('Must not be negative.'); + expect(validateNumberInput('0')).toBe(0); + }); }); diff --git a/tests/unit/event-link-guard.test.js b/tests/unit/event-link-guard.test.js index ff0fc31e27..5eb3c6e11c 100644 --- a/tests/unit/event-link-guard.test.js +++ b/tests/unit/event-link-guard.test.js @@ -1,14 +1,53 @@ import fs from "fs"; import path from "path"; import { runInNewContext } from "node:vm"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -function loadEventHelpers() { +function loadEventHelpers({ + withTauri = false, + userAgent = "Mozilla/5.0", +} = {}) { const source = fs.readFileSync( path.join(process.cwd(), "src-tauri/src/inject/event.js"), "utf-8", ); + const invokeCalls = []; + const invoke = (command, payload) => { + invokeCalls.push([command, payload]); + return Promise.resolve(); + }; + const eventListeners = {}; + const elementsById = new Map(); + const registerListener = (type, handler, options) => { + eventListeners[type] = eventListeners[type] || []; + eventListeners[type].push({ handler, options }); + }; + const createElement = (tagName = "div") => ({ + tagName: tagName.toUpperCase(), + style: {}, + children: [], + addEventListener: () => {}, + appendChild(child) { + this.children.push(child); + if (child.id) elementsById.set(child.id, child); + }, + removeChild(child) { + this.children = this.children.filter((item) => item !== child); + if (child.id) elementsById.delete(child.id); + }, + click: () => {}, + set id(value) { + this._id = value; + elementsById.set(value, this); + }, + get id() { + return this._id; + }, + }); + const body = createElement("body"); + body.scrollHeight = 0; + const context = { console, URL, @@ -18,7 +57,7 @@ function loadEventHelpers() { clearTimeout, scrollTo: () => {}, navigator: { - userAgent: "Mozilla/5.0", + userAgent, language: "en-US", }, window: { @@ -28,27 +67,75 @@ function loadEventHelpers() { }, location: { href: "https://example.com/app", + origin: "https://example.com", + pathname: "/app", reload: () => {}, }, localStorage: { getItem: () => null, setItem: () => {}, }, + addEventListener: registerListener, dispatchEvent: () => {}, + open: () => ({}), + isAuthLink: () => false, + isAuthPopup: () => false, + pakeConfig: {}, }, document: { - addEventListener: () => {}, + addEventListener: registerListener, + createElement, + getElementById: (id) => elementsById.get(id) || null, getElementsByTagName: () => [{ style: {} }], - body: { - style: {}, - scrollHeight: 0, - }, + body, execCommand: () => {}, }, }; + context.window.navigator = context.navigator; + if (withTauri) { + context.window.__TAURI__ = { + core: { invoke }, + window: { + getCurrentWindow: () => ({ + startDragging: () => {}, + isFullscreen: () => Promise.resolve(false), + setFullscreen: () => {}, + }), + }, + }; + } runInNewContext(source, context); - return context; + return { ...context, eventListeners, invokeCalls }; +} + +function runDomReady(context) { + context.eventListeners.DOMContentLoaded[0].handler(); +} + +function getClickGuard(context) { + return context.eventListeners.click.find( + ({ handler }) => handler.name === "detectAnchorElementClick", + ).handler; +} + +function makeAnchor(href, target = "_blank") { + return { + href, + target, + download: "", + getAttribute: (name) => (name === "href" ? href : ""), + }; +} + +function makeClickEvent(anchor) { + return { + target: { + closest: () => anchor, + }, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + }; } describe("event link guard", () => { @@ -71,4 +158,127 @@ describe("event link guard", () => { false, ); }); + + it("navigates macOS auth URLs in the current window", () => { + const { openAuthNavigation, window } = loadEventHelpers({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5)", + }); + const openCalls = []; + const originalWindowOpen = (url, name, specs) => { + openCalls.push({ url, name, specs }); + return {}; + }; + + const result = openAuthNavigation( + originalWindowOpen, + "https://www.linkedin.com/login", + "_blank", + "width=1200,height=800", + ); + + expect(openCalls).toEqual([]); + expect(window.location.href).toBe("https://www.linkedin.com/login"); + expect(result).toBe(window); + }); + + it("keeps blank macOS auth popups on the native popup path", () => { + const popup = {}; + const { openAuthNavigation, window } = loadEventHelpers({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5)", + }); + const openCalls = []; + const originalWindowOpen = (url, name, specs) => { + openCalls.push({ url, name, specs }); + return popup; + }; + + const result = openAuthNavigation( + originalWindowOpen, + "about:blank", + "login", + "width=1200,height=800", + ); + + expect(openCalls).toEqual([ + { + url: "about:blank", + name: "login", + specs: "width=1200,height=800", + }, + ]); + expect(window.location.href).toBe("https://example.com/app"); + expect(result).toBe(popup); + }); + + it("navigates target blank auth links in-place when new-window is disabled", () => { + const context = loadEventHelpers({ withTauri: true }); + context.window.pakeConfig = { new_window: false }; + context.window.isAuthLink = (url) => url.includes("okta.com"); + runDomReady(context); + + const event = makeClickEvent( + makeAnchor("https://mycompany.okta.com/sso", "_blank"), + ); + getClickGuard(context)(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + expect(context.window.location.href).toBe("https://mycompany.okta.com/sso"); + }); + + it("navigates target blank internal links in-place when new-window is disabled", () => { + const context = loadEventHelpers({ withTauri: true }); + context.window.pakeConfig = { + new_window: false, + internal_url_regex: "^https://app\\.example\\.com", + }; + runDomReady(context); + + const event = makeClickEvent( + makeAnchor("https://app.example.com/callback", "_blank"), + ); + getClickGuard(context)(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + expect(context.window.location.href).toBe( + "https://app.example.com/callback", + ); + }); + + it("bridges Web Badging API calls to explicit badge commands", async () => { + const { navigator, invokeCalls } = loadEventHelpers({ withTauri: true }); + + await navigator.setAppBadge(3.8); + await navigator.setAppBadge(); + await navigator.setAppBadge(0); + + expect(invokeCalls).toEqual([ + ["set_dock_badge", { count: 3 }], + ["set_dock_badge_label", { label: "•" }], + ["clear_dock_badge", undefined], + ]); + }); + + it("keeps notification display separate from badge increment", async () => { + const { window, invokeCalls } = loadEventHelpers({ withTauri: true }); + + new window.Notification("Hello", { body: "World", icon: "/icon.png" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(invokeCalls).toEqual([ + [ + "send_notification", + { + params: { + title: "Hello", + body: "World", + icon: "https://example.com/icon.png", + }, + }, + ], + ["increment_dock_badge", undefined], + ]); + }); }); diff --git a/tests/unit/find-shortcuts.test.js b/tests/unit/find-shortcuts.test.js new file mode 100644 index 0000000000..278c935314 --- /dev/null +++ b/tests/unit/find-shortcuts.test.js @@ -0,0 +1,288 @@ +import fs from "fs"; +import path from "path"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +function createElement(tagName) { + const element = { + tagName: tagName.toUpperCase(), + id: "", + type: "", + textContent: "", + style: {}, + children: [], + attributes: new Map(), + parentElement: null, + parentNode: null, + hidden: false, + isContentEditable: false, + appendChild(child) { + child.parentElement = element; + child.parentNode = element; + element.children.push(child); + return child; + }, + append(...children) { + children.forEach((child) => element.appendChild(child)); + }, + addEventListener(type, handler) { + element.listeners = element.listeners || {}; + element.listeners[type] = element.listeners[type] || []; + element.listeners[type].push(handler); + }, + setAttribute(name, value) { + element.attributes.set(name, String(value)); + if (name === "id") element.id = String(value); + }, + getAttribute(name) { + return element.attributes.get(name) ?? null; + }, + removeAttribute(name) { + element.attributes.delete(name); + }, + toggleAttribute(name, force) { + if (force) { + element.setAttribute(name, ""); + } else { + element.removeAttribute(name); + } + }, + closest(selector) { + if (selector.startsWith("#")) { + const id = selector.slice(1); + for (let current = element; current; current = current.parentElement) { + if (current.id === id) return current; + } + } + return null; + }, + replaceWith() {}, + scrollIntoView() {}, + normalize() {}, + focus() {}, + select() {}, + }; + return element; +} + +function createTextNode(value, parent) { + return { + nodeType: 3, + nodeValue: value, + textContent: value, + parentElement: parent, + parentNode: parent, + }; +} + +function createDocument(textNodes) { + const listeners = {}; + const body = createElement("body"); + const head = createElement("head"); + + const document = { + body, + head, + documentElement: createElement("html"), + listeners, + addEventListener(type, handler, options) { + listeners[type] = listeners[type] || []; + listeners[type].push({ handler, options }); + }, + createElement, + createTextNode(value) { + return createTextNode(value, null); + }, + createRange() { + return { + setStart(node, start) { + this.startContainer = node; + this.start = start; + }, + setEnd(node, end) { + this.endContainer = node; + this.end = end; + }, + surroundContents(mark) { + mark.textContent = this.startContainer.nodeValue.slice( + this.start, + this.end, + ); + }, + }; + }, + createTreeWalker(root, _whatToShow, filter) { + const accepted = textNodes.filter( + (node) => filter.acceptNode(node) === 1, + ); + let index = -1; + return { + nextNode() { + index += 1; + return accepted[index] || null; + }, + }; + }, + getElementById(id) { + if (head.children.some((child) => child.id === id)) { + return head.children.find((child) => child.id === id); + } + if (body.children.some((child) => child.id === id)) { + return body.children.find((child) => child.id === id); + } + return null; + }, + querySelectorAll() { + return []; + }, + }; + + return document; +} + +function createKeyboardEvent(key, overrides = {}) { + const event = { + key, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...overrides, + }; + return event; +} + +function loadFindScript({ + enabled = true, + userAgent = "Mozilla/5.0", + nodes = [], +} = {}) { + const source = fs.readFileSync( + path.join(process.cwd(), "src-tauri/src/inject/find.js"), + "utf-8", + ); + const context = { + console, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => callback(), + navigator: { userAgent }, + NodeFilter: { + SHOW_TEXT: 4, + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + }, + window: { + pakeConfig: { enable_find: enabled }, + }, + document: createDocument(nodes), + }; + context.window.NodeFilter = context.NodeFilter; + context.window.navigator = context.navigator; + + runInNewContext(source, context); + return context; +} + +describe("Find injection", () => { + it("does not register shortcuts when enable_find is false", () => { + const paragraph = createElement("p"); + const context = loadFindScript({ + enabled: false, + nodes: [createTextNode("Alpha alpha", paragraph)], + }); + + expect(context.document.listeners.keydown).toBeUndefined(); + expect(context.window.pakeFind.getState().enabled).toBe(false); + expect( + context.window.pakeFind.getFindShortcutAction( + createKeyboardEvent("f", { ctrlKey: true }), + ), + ).toBe(""); + expect(context.window.pakeFind.open().isOpen).toBe(false); + expect(context.window.pakeFind.search("alpha").matchCount).toBe(0); + expect(context.window.pakeFind.next().activeIndex).toBe(-1); + expect(context.window.pakeFind.previous().activeIndex).toBe(-1); + expect(context.window.pakeFind.close().matchCount).toBe(0); + expect(context.document.head.children).toHaveLength(0); + expect(context.document.body.children).toHaveLength(0); + }); + + it("handles Cmd/Ctrl+F and Cmd/Ctrl+G shortcuts when enabled", () => { + const context = loadFindScript({ enabled: true }); + const calls = []; + context.window.pakeFind.open = () => calls.push("open"); + context.window.pakeFind.next = () => calls.push("next"); + context.window.pakeFind.previous = () => calls.push("previous"); + + const [listener] = context.document.listeners.keydown; + + const findEvent = createKeyboardEvent("f", { ctrlKey: true }); + listener.handler(findEvent); + const nextEvent = createKeyboardEvent("g", { ctrlKey: true }); + listener.handler(nextEvent); + const previousEvent = createKeyboardEvent("g", { + ctrlKey: true, + shiftKey: true, + }); + listener.handler(previousEvent); + + expect(calls).toEqual(["open", "next", "previous"]); + expect(findEvent.defaultPrevented).toBe(true); + expect(previousEvent.propagationStopped).toBe(true); + }); + + it("uses the macOS modifier for Find shortcuts", () => { + const context = loadFindScript({ + enabled: true, + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }); + const calls = []; + context.window.pakeFind.open = () => calls.push("open"); + + const [listener] = context.document.listeners.keydown; + listener.handler(createKeyboardEvent("f", { ctrlKey: true })); + listener.handler(createKeyboardEvent("f", { metaKey: true })); + + expect(calls).toEqual(["open"]); + }); + + it("counts text matches and skips input and script content", () => { + const paragraph = createElement("p"); + const script = createElement("script"); + const input = createElement("input"); + const nodes = [ + createTextNode("Alpha beta alpha", paragraph), + createTextNode("alpha", script), + createTextNode("alpha", input), + ]; + const context = loadFindScript({ enabled: true, nodes }); + + const result = context.window.pakeFind.search("alpha"); + + expect(result.matchCount).toBe(2); + expect(result.activeIndex).toBe(0); + }); + + it("clears matches on Escape", () => { + const paragraph = createElement("p"); + const context = loadFindScript({ + enabled: true, + nodes: [createTextNode("Alpha alpha", paragraph)], + }); + + context.window.pakeFind.search("alpha"); + expect(context.window.pakeFind.getState().matchCount).toBe(2); + + context.window.pakeFind.close(); + expect(context.window.pakeFind.getState().matchCount).toBe(0); + }); +}); diff --git a/tests/unit/ico.test.ts b/tests/unit/ico.test.ts index 318f08315a..c473c415cd 100644 --- a/tests/unit/ico.test.ts +++ b/tests/unit/ico.test.ts @@ -2,8 +2,14 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; +import sharp from 'sharp'; -import { buildIcoFromPngBuffers, writeIcoWithPreferredSize } from '@/utils/ico'; +import { + buildIcoFromPngBuffers, + ensureMultiResolutionIco, + writeIcoWithPreferredSize, + WIN_STANDARD_ICO_SIZES, +} from '@/utils/ico'; const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; @@ -166,3 +172,90 @@ describe('writeIcoWithPreferredSize', () => { expect(ok).toBe(false); }); }); + +describe('ensureMultiResolutionIco', () => { + async function realPng(size: number, fillColor = 'red'): Promise { + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: fillColor, + }, + }) + .png() + .toBuffer(); + } + + it('expands a single-frame 256x256 ICO into every Windows standard size (#1190)', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const big = await realPng(256, 'blue'); + fs.writeFileSync(source, buildIcoFromPngBuffers([{ size: 256, png: big }])); + + const ok = await ensureMultiResolutionIco(source, output); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + const sizes = entries.map((entry) => Math.max(entry.width, entry.height)); + for (const expected of WIN_STANDARD_ICO_SIZES) { + expect(sizes).toContain(expected); + } + }); + + it('places the preferred size as the first directory entry', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const big = await realPng(256, 'green'); + fs.writeFileSync(source, buildIcoFromPngBuffers([{ size: 256, png: big }])); + + const ok = await ensureMultiResolutionIco(source, output, 32); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + expect(entries[0].width).toBe(32); + expect(entries[0].height).toBe(32); + }); + + it('preserves any exact-size PNG frame already in the ICO', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const tiny = await realPng(16, 'magenta'); + const big = await realPng(256, 'cyan'); + fs.writeFileSync( + source, + buildIcoFromPngBuffers([ + { size: 16, png: tiny }, + { size: 256, png: big }, + ]), + ); + + const ok = await ensureMultiResolutionIco(source, output); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + const sixteen = entries.find( + (entry) => entry.width === 16 && entry.height === 16, + ); + expect(sixteen).toBeDefined(); + expect(sixteen!.data.equals(tiny)).toBe(true); + }); + + it('falls back gracefully when the source ICO is malformed', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'bad.ico'); + fs.writeFileSync(source, Buffer.from([0, 0])); + + const ok = await ensureMultiResolutionIco( + source, + path.join(dir, 'out.ico'), + ); + expect(ok).toBe(false); + }); +}); diff --git a/tests/unit/linux-distro.test.ts b/tests/unit/linux-distro.test.ts new file mode 100644 index 0000000000..e9513d46db --- /dev/null +++ b/tests/unit/linux-distro.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { detectLinuxPackageFamily } from '../../bin/utils/platform.js'; + +describe('detectLinuxPackageFamily', () => { + it('detects Debian/Ubuntu families as deb', () => { + expect(detectLinuxPackageFamily('ID=debian')).toBe('deb'); + expect(detectLinuxPackageFamily('ID=ubuntu\nID_LIKE=debian')).toBe('deb'); + expect( + detectLinuxPackageFamily('ID=linuxmint\nID_LIKE="ubuntu debian"'), + ).toBe('deb'); + }); + + it('detects Fedora/RHEL families as rpm', () => { + expect(detectLinuxPackageFamily('ID=fedora')).toBe('rpm'); + expect(detectLinuxPackageFamily('ID=rhel')).toBe('rpm'); + expect( + detectLinuxPackageFamily('ID=rocky\nID_LIKE="rhel centos fedora"'), + ).toBe('rpm'); + }); + + it('detects Oracle Linux (ID=ol) as rpm', () => { + expect( + detectLinuxPackageFamily('ID="ol"\nID_LIKE="fedora"\nVERSION_ID="10.1"'), + ).toBe('rpm'); + }); + + it('detects openSUSE/SLES as rpm', () => { + expect( + detectLinuxPackageFamily('ID=opensuse-leap\nID_LIKE="suse opensuse"'), + ).toBe('rpm'); + expect(detectLinuxPackageFamily('ID=sles')).toBe('rpm'); + }); + + it('prefers the distro ID over ID_LIKE hints', () => { + // A deb-based distro that lists no rpm hint stays deb. + expect(detectLinuxPackageFamily('ID=ubuntu')).toBe('deb'); + // A distro whose own ID is rpm-based wins even with mixed-looking input. + expect(detectLinuxPackageFamily('ID=fedora\nID_LIKE=')).toBe('rpm'); + }); + + it('falls back to deb for unknown or empty os-release', () => { + expect(detectLinuxPackageFamily('')).toBe('deb'); + expect(detectLinuxPackageFamily('ID=arch')).toBe('deb'); + expect(detectLinuxPackageFamily('# just a comment')).toBe('deb'); + }); +}); diff --git a/tests/unit/mac-builder-targets.test.ts b/tests/unit/mac-builder-targets.test.ts new file mode 100644 index 0000000000..1b39372d6f --- /dev/null +++ b/tests/unit/mac-builder-targets.test.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// tauriConfig.ts reads pake.json at module load, keyed off npmDirectory. +// Point it at the repo root so the import chain resolves under vitest. +vi.mock('@/utils/dir', () => ({ + npmDirectory: process.cwd(), + tauriConfigDirectory: path.join(process.cwd(), 'src-tauri', '.pake'), +})); + +import MacBuilder from '@/builders/MacBuilder'; +import { PakeAppOptions } from '@/types'; + +const makeBuilder = (targets?: string) => + new MacBuilder({ name: 'Demo', targets } as PakeAppOptions); + +describe('MacBuilder target selection', () => { + // The CI fast-test step runs with PAKE_CREATE_APP=1, which forces the macOS + // build format to `app` and strips the DMG name suffix. Clear it so these + // DMG-naming assertions stay deterministic regardless of the ambient env. + beforeEach(() => { + vi.stubEnv('PAKE_CREATE_APP', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('builds an app bundle when --targets app is requested', () => { + // The app format ships a bare `.app`, so the file name carries no + // version/arch suffix. This proves `--targets app` is honoured rather + // than silently coerced to the default DMG. + expect(makeBuilder('app').getFileName()).toBe('Demo'); + }); + + it('builds a DMG when --targets dmg is requested', () => { + expect(makeBuilder('dmg').getFileName()).toMatch(/^Demo_.+/); + }); + + it('defaults to a DMG when no target is given', () => { + expect(makeBuilder(undefined).getFileName()).toMatch(/^Demo_.+/); + }); + + it('keeps treating arch values as DMG builds with an arch suffix', () => { + expect(makeBuilder('apple').getFileName()).toMatch(/_aarch64$/); + expect(makeBuilder('intel').getFileName()).toMatch(/_x64$/); + expect(makeBuilder('universal').getFileName()).toMatch(/_universal$/); + }); +}); diff --git a/tests/unit/merge-window-options.test.ts b/tests/unit/merge-window-options.test.ts new file mode 100644 index 0000000000..6e453a5add --- /dev/null +++ b/tests/unit/merge-window-options.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest'; +import { buildWindowConfigOverrides } from '../../bin/helpers/merge'; +import { DEFAULT_PAKE_OPTIONS } from '../../bin/defaults'; +import type { PakeAppOptions } from '../../bin/types'; + +function makeOptions(overrides: Partial = {}): PakeAppOptions { + return { + ...DEFAULT_PAKE_OPTIONS, + identifier: 'com.pake.test', + ...overrides, + }; +} + +describe('buildWindowConfigOverrides', () => { + it('matches the default snapshot on macOS', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'darwin'); + expect(result).toMatchSnapshot(); + }); + + it('matches the default snapshot on Windows', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'win32'); + expect(result).toMatchSnapshot(); + }); + + it('matches the default snapshot on Linux', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'linux'); + expect(result).toMatchSnapshot(); + }); + + it('respects explicit hideOnClose=false on macOS', () => { + const result = buildWindowConfigOverrides( + makeOptions({ hideOnClose: false }), + 'darwin', + ); + expect(result.hide_on_close).toBe(false); + }); + + it('defaults hideOnClose to true on macOS when undefined', () => { + const result = buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'darwin', + ); + expect(result.hide_on_close).toBe(true); + }); + + it('defaults hideOnClose to false on Linux/Windows when undefined', () => { + expect( + buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'linux', + ).hide_on_close, + ).toBe(false); + expect( + buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'win32', + ).hide_on_close, + ).toBe(false); + }); + + it('only forwards hideTitleBar on macOS', () => { + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'darwin', + ).hide_title_bar, + ).toBe(true); + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'linux', + ).hide_title_bar, + ).toBe(false); + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'win32', + ).hide_title_bar, + ).toBe(false); + }); + + it('only enables start_to_tray when both flag and tray are on', () => { + expect( + buildWindowConfigOverrides( + makeOptions({ startToTray: true, showSystemTray: false }), + 'darwin', + ).start_to_tray, + ).toBe(false); + expect( + buildWindowConfigOverrides( + makeOptions({ startToTray: true, showSystemTray: true }), + 'darwin', + ).start_to_tray, + ).toBe(true); + }); + + it('forwards window/zoom/wasm/new_window flags verbatim', () => { + const result = buildWindowConfigOverrides( + makeOptions({ + width: 1400, + height: 900, + zoom: 120, + minWidth: 800, + minHeight: 600, + wasm: true, + enableDragDrop: true, + ignoreCertificateErrors: true, + newWindow: true, + enableFind: true, + forceInternalNavigation: true, + internalUrlRegex: '^https://example\\.com', + }), + 'darwin', + ); + expect(result).toMatchObject({ + width: 1400, + height: 900, + zoom: 120, + min_width: 800, + min_height: 600, + enable_wasm: true, + enable_drag_drop: true, + ignore_certificate_errors: true, + new_window: true, + enable_find: true, + force_internal_navigation: true, + internal_url_regex: '^https://example\\.com', + }); + }); +}); diff --git a/tests/unit/new-window-macos.test.js b/tests/unit/new-window-macos.test.js new file mode 100644 index 0000000000..eefc6e7483 --- /dev/null +++ b/tests/unit/new-window-macos.test.js @@ -0,0 +1,41 @@ +import fs from "fs"; +import path from "path"; +import { describe, expect, it } from "vitest"; + +const sourcePath = path.join(process.cwd(), "src-tauri/src/app/window.rs"); + +describe("macOS new-window handling (regression: #1194)", () => { + it("creates popups via open_requested_window on every platform", () => { + const source = fs.readFileSync(sourcePath, "utf-8"); + + const blockStart = source.indexOf("if window_config.new_window"); + const blockEnd = source.indexOf( + "// Add initialization scripts", + blockStart, + ); + expect(blockStart).toBeGreaterThan(-1); + expect(blockEnd).toBeGreaterThan(blockStart); + + const newWindowBlock = source.slice(blockStart, blockEnd); + + // The fix for #1194 unifies all platforms behind open_requested_window so + // popups never reuse the opener WKWebViewConfiguration. Guard against + // accidental reintroduction of NewWindowResponse::Allow which crashes + // macOS 26 with WKUserContentController duplicate handler errors. + expect(newWindowBlock).toContain("open_requested_window"); + expect(newWindowBlock).toContain("NewWindowResponse::Create"); + expect(newWindowBlock).not.toMatch(/NewWindowResponse::Allow\b/); + expect(newWindowBlock).not.toMatch(/#\[cfg\(target_os = "macos"\)\]/); + }); + + it("does not clone the opener WKWebViewConfiguration on macOS popup features", () => { + // The popup-features handler in build_window must never call + // .with_webview_configuration(features.opener().target_configuration) + // because the cloned configuration carries the parent's + // WKScriptMessageHandler set, which WebKit refuses to register twice and + // aborts the process on macOS 26. + const source = fs.readFileSync(sourcePath, "utf-8"); + expect(source).not.toContain("with_webview_configuration"); + expect(source).not.toContain("target_configuration.clone()"); + }); +}); diff --git a/tests/unit/options-name.test.ts b/tests/unit/options-name.test.ts new file mode 100644 index 0000000000..e6792e711b --- /dev/null +++ b/tests/unit/options-name.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { isValidName, resolveLocalAppName } from '@/options/index'; + +describe('option name validation', () => { + it('allows dots inside macOS and Windows app names', () => { + expect(isValidName('Vectorizer.AI', 'darwin')).toBe(true); + expect(isValidName('Vectorizer.AI', 'win32')).toBe(true); + }); + + it('rejects leading dots, dashes, and spaces on macOS and Windows', () => { + expect(isValidName('.hidden', 'darwin')).toBe(false); + expect(isValidName('-hidden', 'win32')).toBe(false); + expect(isValidName(' Hidden', 'darwin')).toBe(false); + }); + + it('keeps Linux package names stricter than desktop app names', () => { + expect(isValidName('vectorizer.ai', 'linux')).toBe(false); + expect(isValidName('vectorizer-ai', 'linux')).toBe(true); + }); +}); + +describe('local app name resolution', () => { + it('preserves dots in local file names on macOS and Windows', () => { + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'darwin')).toBe( + 'Vectorizer.AI', + ); + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'win32')).toBe( + 'Vectorizer.AI', + ); + }); + + it('normalizes leading dots from local file names', () => { + expect(resolveLocalAppName('/tmp/.hidden.html', 'darwin')).toBe('hidden'); + }); + + it('normalizes dotted local names for Linux package names', () => { + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'linux')).toBe( + 'vectorizer-ai', + ); + }); +}); diff --git a/tests/unit/safe-domains.test.ts b/tests/unit/safe-domains.test.ts new file mode 100644 index 0000000000..580cc5514e --- /dev/null +++ b/tests/unit/safe-domains.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { safeDomainsToRegex } from '../../bin/utils/url.js'; + +describe('safeDomainsToRegex', () => { + it('builds a host-bound regex for a single domain', () => { + expect(safeDomainsToRegex('slack.com')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('joins multiple domains with alternation', () => { + expect(safeDomainsToRegex('slack.com,acme.com')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com|acme\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('trims whitespace and drops empty entries', () => { + expect(safeDomainsToRegex(' slack.com , , acme.com ')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com|acme\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('returns an empty string for blank input', () => { + expect(safeDomainsToRegex('')).toBe(''); + expect(safeDomainsToRegex(' , , ')).toBe(''); + }); + + it('escapes regex metacharacters in domains', () => { + expect(safeDomainsToRegex('a.b+c')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:a\\.b\\+c)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('compiles to a regex that matches allowed URL hosts', () => { + const pattern = new RegExp(safeDomainsToRegex('slack.com,okta.com')); + + expect(pattern.test('https://slack.com')).toBe(true); + expect(pattern.test('https://mycompany.okta.com/sso')).toBe(true); + expect(pattern.test('https://app.slack.com/client')).toBe(true); + expect(pattern.test('https://slack.com:443/client')).toBe(true); + expect(pattern.test('https://example.com/dashboard')).toBe(false); + }); + + it('does not match domains embedded in unrelated hosts or URL text', () => { + const pattern = new RegExp(safeDomainsToRegex('slack.com,okta.com')); + + expect(pattern.test('https://evilslack.com')).toBe(false); + expect(pattern.test('https://slack.com.evil.example')).toBe(false); + expect(pattern.test('https://okta.com.evil.example/sso')).toBe(false); + expect( + pattern.test('https://example.com/callback?next=https://okta.com'), + ).toBe(false); + expect(pattern.test('https://okta.com@evil.example/sso')).toBe(false); + }); +});