Repository: tw93/Pake Branch: main Commit: 1a34b5f912c2 Files: 123 Total size: 466.5 KB Directory structure: gitextract_0ul1epmu/ ├── .dockerignore ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yml │ │ ├── config.yml │ │ └── feature.yml │ ├── actions/ │ │ └── setup-env/ │ │ └── action.yml │ └── workflows/ │ ├── pake-cli.yaml │ ├── quality-and-test.yml │ ├── release.yml │ ├── single-app.yaml │ └── update-contributors.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── .pnpmrc ├── .prettierignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── README_CN.md ├── action.yml ├── bin/ │ ├── builders/ │ │ ├── BaseBuilder.ts │ │ ├── BuilderProvider.ts │ │ ├── LinuxBuilder.ts │ │ ├── MacBuilder.ts │ │ └── WinBuilder.ts │ ├── cli.ts │ ├── defaults.ts │ ├── dev.ts │ ├── helpers/ │ │ ├── cli-program.ts │ │ ├── merge.ts │ │ ├── rust.ts │ │ └── tauriConfig.ts │ ├── options/ │ │ ├── icon.ts │ │ ├── index.ts │ │ └── logger.ts │ ├── types.ts │ └── utils/ │ ├── combine.ts │ ├── dir.ts │ ├── ico.ts │ ├── info.ts │ ├── ip.ts │ ├── name.ts │ ├── platform.ts │ ├── shell.ts │ ├── url.ts │ └── validate.ts ├── default_app_list.json ├── docs/ │ ├── README.md │ ├── README_CN.md │ ├── advanced-usage.md │ ├── advanced-usage_CN.md │ ├── cli-usage.md │ ├── cli-usage_CN.md │ ├── faq.md │ ├── faq_CN.md │ ├── github-actions-usage.md │ ├── github-actions-usage_CN.md │ └── pake-action.md ├── icns2png.py ├── package.json ├── rollup.config.js ├── rust-toolchain.toml ├── src-tauri/ │ ├── .gitignore │ ├── Cargo.toml │ ├── Info.plist │ ├── assets/ │ │ └── main.wxs │ ├── build.rs │ ├── capabilities/ │ │ └── default.json │ ├── entitlements.plist │ ├── icons/ │ │ ├── chatgpt.icns │ │ ├── deepseek.icns │ │ ├── excalidraw.icns │ │ ├── flomo.icns │ │ ├── gemini.icns │ │ ├── grok.icns │ │ ├── icon.icns │ │ ├── lizhi.icns │ │ ├── programmusic.icns │ │ ├── qwerty.icns │ │ ├── twitter.icns │ │ ├── wechat.icns │ │ ├── weekly.icns │ │ ├── weread.icns │ │ ├── xiaohongshu.icns │ │ ├── youtube.icns │ │ └── youtubemusic.icns │ ├── pake.json │ ├── rust_proxy.toml │ ├── src/ │ │ ├── app/ │ │ │ ├── config.rs │ │ │ ├── invoke.rs │ │ │ ├── menu.rs │ │ │ ├── mod.rs │ │ │ ├── setup.rs │ │ │ └── window.rs │ │ ├── inject/ │ │ │ ├── auth.js │ │ │ ├── component.js │ │ │ ├── custom.js │ │ │ ├── event.js │ │ │ ├── style.js │ │ │ └── theme_refresh.js │ │ ├── lib.rs │ │ ├── main.rs │ │ └── util.rs │ ├── tauri.conf.json │ ├── tauri.linux.conf.json │ ├── tauri.macos.conf.json │ └── tauri.windows.conf.json ├── tests/ │ ├── config.js │ ├── index.js │ ├── integration/ │ │ └── workflow-paths.test.js │ ├── release.js │ └── unit/ │ ├── builders.test.ts │ ├── cli-options.test.ts │ ├── file-finding.test.js │ ├── identifier.test.ts │ └── name.test.ts ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .gitignore **/target **/node_modules **/*.log **/*.md **/tmp Dockerfile ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 2 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true # Use 4 spaces for Python, Rust and Bash files [*.{py,rs,sh}] indent_size = 4 # Makefiles always use tabs for indentation [Makefile] indent_style = tab [*.bat] indent_size = 2 [*.md] trim_trailing_whitespace = false [*.ts] quote_type= "single" ================================================ FILE: .gitattributes ================================================ # Exclude all non-source directories from language detection bin/**/* linguist-vendored dist/**/* linguist-vendored scripts/**/* linguist-vendored tests/**/* linguist-vendored docs/**/* linguist-vendored .github/**/* linguist-vendored node_modules/**/* linguist-vendored # Exclude build artifacts and config files /cli.js linguist-vendored /rollup.config.js linguist-vendored /icns2png.py linguist-vendored *.json linguist-vendored # Exclude Tauri generated and vendor code src-tauri/target/**/* linguist-vendored src-tauri/gen/**/* linguist-vendored src-tauri/capabilities/** linguist-vendored src-tauri/icons/**/* linguist-vendored src-tauri/assets/**/* linguist-vendored src-tauri/png/**/* linguist-vendored src-tauri/.pake/**/* linguist-vendored src-tauri/.cargo/**/* linguist-vendored # Exclude injection system (since it's mostly JS/CSS) src-tauri/src/inject/**/* linguist-vendored ================================================ FILE: .github/FUNDING.yml ================================================ github: ["tw93"] custom: ["https://miaoyan.app/cats.html?name=Pake"] ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yml ================================================ name: Bug report description: Problems with the software title: "[Bug] " labels: ["bug"] body: - type: markdown attributes: value: | Thank you very much for your feedback! For suggestions or help, please consider using [Github Discussion](https://github.com/tw93/Pake/discussions) instead. - type: checkboxes attributes: label: Search before asking description: > 🙊 Check out [Issues](https://github.com/tw93/Pake/issues?q=) before reporting. Please provide your system version, screencasts, screenshots, way to reproduce, and the expected result – helpful for me to understand and fix up this issue! Besides, for suggestions or something else, head to [Pake's Discussions Platform](https://github.com/tw93/Pake/discussions). options: - label: > I searched in the [issues](https://github.com/tw93/Pake/issues?q=) and found nothing similar. required: true - type: textarea attributes: label: Pake version description: > Please provide the version of Pake you are using (e.g., `pake --version`). If you are using the main/dev branch, please provide the commit id. validations: required: true - type: textarea attributes: label: Rust version description: > Please provide the Rust version (e.g., `rustc --version`). This is critical for build issues. validations: required: true - type: dropdown attributes: label: Package Manager description: Which package manager are you using? options: - npm - pnpm - yarn - bun - other validations: required: true - type: textarea attributes: label: System version description: > Please provide the version of System you are using (e.g., macOS 14.2, Windows 11, Ubuntu 24.04). validations: required: true - type: textarea attributes: label: Node.js version description: > Please provide the Node.js version. validations: required: true - type: textarea attributes: label: Build Command description: > Please provide the exact command you used to build the app (e.g., `pake https://github.com --name GitHub`). validations: required: true - type: textarea attributes: label: Minimal reproduce step description: Please try to give reproducing steps to facilitate quick location of the problem. validations: required: true - type: textarea attributes: label: What did you expect to see? validations: required: true - type: textarea attributes: label: What did you see instead? validations: required: true - type: textarea attributes: label: Anything else? - type: checkboxes attributes: label: Are you willing to submit a PR? description: > We look forward to the community of developers or users helping solve Pake problems together. If you are willing to submit a PR to fix this problem, please check the box. options: - label: I'm willing to submit a PR! - type: markdown attributes: value: "Thanks for completing our form!" ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Ask a question or get support url: https://github.com/tw93/Pake/discussions/categories/q-a about: Ask a question or request support for Pake ================================================ FILE: .github/ISSUE_TEMPLATE/feature.yml ================================================ name: Feature description: Add new feature, improve code, and more labels: ["enhancement"] body: - type: markdown attributes: value: | Thank you very much for your feature proposal! - type: checkboxes attributes: label: Search before asking description: > Please search [issues](https://github.com/tw93/Pake/issues?q=) to check if your issue has already been reported. options: - label: > I searched in the [issues](https://github.com/tw93/Pake/issues?q=) and found nothing similar. required: true - type: textarea attributes: label: Motivation description: Describe the motivations for this feature, like how it fixes the problem you meet. validations: required: true - type: textarea attributes: label: Solution description: Describe the proposed solution and add related materials like links if any. - type: textarea attributes: label: Alternatives description: Describe other alternative solutions or features you considered, but rejected. - type: textarea attributes: label: Anything else? - type: checkboxes attributes: label: Are you willing to submit a PR? description: > We look forward to the community of developers or users helping develop Pake features together. If you are willing to submit a PR to implement the feature, please check the box. options: - label: I'm willing to submit a PR! - type: markdown attributes: value: "Thanks for completing our form!" ================================================ FILE: .github/actions/setup-env/action.yml ================================================ name: Setup Development Environment description: Unified environment setup with Node.js, Rust, and system dependencies inputs: mode: description: | Setup mode: - build: Complete environment (Node + Rust + System deps + Cache) - node: Node.js only (pnpm + Node 22) - rust: Rust only (toolchain + targets) required: false default: "build" outputs: setup-complete: description: Setup completion status value: "true" runs: using: composite steps: # Parse mode and set environment flags - name: Setup environment flags shell: bash run: | MODE="${{ inputs.mode }}" # Validate and set flags in one pass case "$MODE" in build|full) echo "SETUP_NODE=true" >> $GITHUB_ENV echo "SETUP_RUST=true" >> $GITHUB_ENV echo "SETUP_SYSTEM=true" >> $GITHUB_ENV ;; node|node-only) echo "SETUP_NODE=true" >> $GITHUB_ENV echo "SETUP_RUST=false" >> $GITHUB_ENV echo "SETUP_SYSTEM=false" >> $GITHUB_ENV ;; rust|rust-only) echo "SETUP_NODE=false" >> $GITHUB_ENV echo "SETUP_RUST=true" >> $GITHUB_ENV echo "SETUP_SYSTEM=false" >> $GITHUB_ENV ;; *) echo "❌ Invalid mode: '$MODE'. Valid modes: build, node, rust" exit 1 ;; esac # Node.js Environment Setup - name: Install pnpm if: env.SETUP_NODE == 'true' uses: pnpm/action-setup@v4 with: version: "10.26.2" run_install: false - name: Setup Node.js if: env.SETUP_NODE == 'true' uses: actions/setup-node@v4 with: node-version: "22" cache: pnpm - name: Install dependencies if: env.SETUP_NODE == 'true' shell: bash run: pnpm install --frozen-lockfile # Rust Environment Setup - name: Setup Rust for Linux if: env.SETUP_RUST == 'true' && runner.os == 'Linux' uses: dtolnay/rust-toolchain@stable with: toolchain: stable target: x86_64-unknown-linux-gnu - name: Setup Rust for Windows if: env.SETUP_RUST == 'true' && runner.os == 'Windows' uses: dtolnay/rust-toolchain@stable with: toolchain: stable-x86_64-msvc target: x86_64-pc-windows-msvc - name: Setup Rust for macOS if: env.SETUP_RUST == 'true' && runner.os == 'macOS' uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Add macOS universal targets if: env.SETUP_RUST == 'true' && runner.os == 'macOS' shell: bash run: | rustup target add x86_64-apple-darwin rustup target add aarch64-apple-darwin # System Dependencies - name: Install Ubuntu dependencies if: env.SETUP_SYSTEM == 'true' && runner.os == 'Linux' uses: awalsh128/cache-apt-pkgs-action@v1.4.3 with: packages: > libdbus-1-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev gnome-video-effects libglib2.0-dev libgirepository1.0-dev pkg-config version: 1.1 - name: Set PKG_CONFIG_PATH for Linux if: env.SETUP_SYSTEM == 'true' && runner.os == 'Linux' shell: bash run: | echo "PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig" >> $GITHUB_ENV - name: Cache WIX Toolset if: env.SETUP_SYSTEM == 'true' && runner.os == 'Windows' uses: actions/cache@v4 id: wix-cache with: path: C:\Program Files (x86)\WiX Toolset v3.11 key: windows-wix-3.11.2 - name: Install WIX Toolset if: env.SETUP_SYSTEM == 'true' && runner.os == 'Windows' && steps.wix-cache.outputs.cache-hit != 'true' shell: powershell run: | try { # Download and install WIX Toolset v3.11 Invoke-WebRequest -Uri "https://github.com/wixtoolset/wix3/releases/download/wix3112rtm/wix311.exe" -OutFile "wix311.exe" Start-Process -FilePath "wix311.exe" -ArgumentList "/quiet" -Wait Write-Host "✅ WIX Toolset installed successfully" } catch { Write-Error "Failed to install WIX Toolset: $($_.Exception.Message)" exit 1 } - name: Add WIX to PATH if: env.SETUP_SYSTEM == 'true' && runner.os == 'Windows' shell: powershell run: | $wixPath = "${env:ProgramFiles(x86)}\WiX Toolset v3.11\bin" if (Test-Path $wixPath) { echo $wixPath >> $env:GITHUB_PATH } # Build optimizations (caching) - name: Setup sccache if: inputs.mode == 'build' uses: mozilla-actions/sccache-action@v0.0.9 - name: Enable sccache if: inputs.mode == 'build' shell: bash run: | echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - name: Setup Rust cache if: inputs.mode == 'build' uses: swatinem/rust-cache@v2 with: workspaces: "src-tauri -> target" shared-key: "pake-${{ runner.os }}" ================================================ FILE: .github/workflows/pake-cli.yaml ================================================ name: Build App With Pake CLI env: NODE_VERSION: "22" PNPM_VERSION: "10.26.2" on: workflow_dispatch: inputs: platform: description: "Platform" required: true default: "macos-latest" type: choice options: - "windows-latest" - "macos-latest" - "ubuntu-24.04" url: description: "Website URL" required: true name: description: "App name (lowercase for Linux)" required: true icon: description: "Icon URL, auto-fetch if empty" required: false width: description: "Window width (px)" required: false default: "1200" height: description: "Window height (px)" required: false default: "780" fullscreen: description: "Start in fullscreen mode" required: false type: boolean default: false hide_title_bar: description: "Hide title bar (macOS only)" required: false type: boolean default: false multi_arch: description: "Universal binary (macOS only)" required: false type: boolean default: false targets: description: "Package formats (comma-separated: deb,appimage,rpm)" required: false default: "deb" jobs: build: name: ${{ inputs.platform }} runs-on: ${{ inputs.platform }} strategy: fail-fast: false steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: stable - name: Setup Node.js Environment uses: ./.github/actions/setup-env with: mode: build - name: Build CLI run: pnpm run cli:build - name: Setup mold linker if: runner.os == 'Linux' uses: rui314/setup-mold@v1 - name: Rust cache restore uses: actions/cache/restore@v4.2.0 id: cache_store with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ src-tauri/target/ key: ${{ runner.os }}-cargo-pake-${{ hashFiles('**/Cargo.lock') }} - name: Build App (Linux/macOS) if: runner.os != 'Windows' timeout-minutes: 25 shell: bash run: | ARGS=("${{ inputs.url }}" "--name" "${{ inputs.name }}") if [ -n "${{ inputs.icon }}" ]; then ARGS+=("--icon" "${{ inputs.icon }}") fi if [ -n "${{ inputs.width }}" ]; then ARGS+=("--width" "${{ inputs.width }}") fi if [ -n "${{ inputs.height }}" ]; then ARGS+=("--height" "${{ inputs.height }}") fi if [ "${{ inputs.fullscreen }}" == "true" ]; then ARGS+=("--fullscreen") fi if [ "${{ inputs.hide_title_bar }}" == "true" ]; then ARGS+=("--hide-title-bar") fi if [ "${{ inputs.multi_arch }}" == "true" ]; then ARGS+=("--multi-arch") fi if [ -n "${{ inputs.targets }}" ] && [ "${{ runner.os }}" == "Linux" ]; then ARGS+=("--targets" "${{ inputs.targets }}") fi echo "Running: node dist/cli.js ${ARGS[@]}" node dist/cli.js "${ARGS[@]}" - name: Build App (Windows) if: runner.os == 'Windows' timeout-minutes: 25 shell: pwsh run: | $args = "${{ inputs.url }}", "--name", "${{ inputs.name }}" if ("${{ inputs.icon }}" -ne "") { $args += "--icon", "${{ inputs.icon }}" } if ("${{ inputs.width }}" -ne "") { $args += "--width", "${{ inputs.width }}" } if ("${{ inputs.height }}" -ne "") { $args += "--height", "${{ inputs.height }}" } if ("${{ inputs.fullscreen }}" -eq "true") { $args += "--fullscreen" } if ("${{ inputs.hide_title_bar }}" -eq "true") { $args += "--hide-title-bar" } Write-Host "Running: node dist/cli.js $($args -join ' ')" node dist/cli.js $args git checkout -- src-tauri/Cargo.lock - name: Upload DMG (macOS) if: runner.os == 'macOS' uses: actions/upload-artifact@v6 with: name: ${{ inputs.name }}-macOS path: ${{ inputs.name }}.dmg retention-days: 3 - name: Upload DEB (Linux) if: runner.os == 'Linux' uses: actions/upload-artifact@v6 with: name: ${{ inputs.name }}-Linux-deb path: ${{ inputs.name }}.deb retention-days: 3 if-no-files-found: ignore - name: Upload AppImage (Linux) if: runner.os == 'Linux' uses: actions/upload-artifact@v6 with: name: ${{ inputs.name }}-Linux-AppImage path: ${{ inputs.name }}.AppImage retention-days: 3 if-no-files-found: ignore - name: Upload MSI (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v6 with: name: ${{ inputs.name }}-Windows path: ${{ inputs.name }}.msi retention-days: 3 - name: Rust cache store uses: actions/cache/save@v4.2.0 if: steps.cache_store.outputs.cache-hit != 'true' with: path: | ~/.cargo/bin/ ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ src-tauri/target/ key: ${{ runner.os }}-cargo-pake-${{ hashFiles('**/Cargo.lock') }} ================================================ FILE: .github/workflows/quality-and-test.yml ================================================ name: Quality & Testing on: push: branches: [main, dev] pull_request: branches: [main, dev] workflow_dispatch: env: NODE_VERSION: "22" PNPM_VERSION: "10.26.2" permissions: actions: write contents: read concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: auto-format: name: Auto-fix Formatting runs-on: ubuntu-latest if: github.event_name == 'push' permissions: contents: write steps: - uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Setup Development Environment uses: ./.github/actions/setup-env with: mode: node - name: Auto-fix Prettier formatting run: npx prettier --write . --ignore-unknown --cache - name: Auto-fix Rust formatting run: cargo fmt --all --manifest-path src-tauri/Cargo.toml - name: Commit formatting fixes run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" git add . if ! git diff --staged --quiet; then git commit -m "Auto-fix formatting issues" git push else echo "No formatting changes to commit" fi rust-quality: name: Rust Code Quality runs-on: ubuntu-latest strategy: fail-fast: false defaults: run: shell: bash working-directory: src-tauri steps: - uses: actions/checkout@v6 - name: Setup Rust Environment uses: ./.github/actions/setup-env with: mode: build - name: Install Rust components shell: bash run: rustup component add rustfmt clippy - 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 - name: Check Rust formatting run: cargo fmt --all -- --color=always --check - 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 }}) 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: Build CLI run: pnpm run cli:build - name: Run CI Test Suite run: pnpm test timeout-minutes: 30 env: CI: true NODE_ENV: test - name: Test CLI Integration shell: bash run: | echo "Testing CLI integration..." if [[ "$RUNNER_OS" == "Windows" ]]; then timeout 60s PAKE_CREATE_APP=1 node dist/cli.js https://weekly.tw93.fun --name "CITest" --debug --iterative-build || true else timeout 30s PAKE_CREATE_APP=1 node dist/cli.js https://weekly.tw93.fun --name "CITest" --debug --iterative-build || true fi summary: name: Quality Summary runs-on: ubuntu-latest needs: [auto-format, rust-quality, validation] if: always() steps: - name: Generate Summary run: | { echo "# Quality & Testing Summary" echo "" echo "| Check | Status |" 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' }} |" } >> $GITHUB_STEP_SUMMARY ================================================ FILE: .github/workflows/release.yml ================================================ name: Release & Publish on: push: tags: - "V*" workflow_dispatch: inputs: release_apps: description: "Build popular apps" type: boolean default: false publish_docker: description: "Publish Docker image" type: boolean default: false env: NODE_VERSION: "22" PNPM_VERSION: "10.26.2" REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} jobs: # Build and release popular apps release-apps: if: | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && inputs.release_apps) runs-on: ubuntu-latest outputs: apps_name: ${{ steps.read-apps-config.outputs.apps_name }} apps_config: ${{ steps.read-apps-config.outputs.apps_config }} steps: - name: Checkout repository uses: actions/checkout@v6 - name: Get Apps Config id: read-apps-config run: | echo "apps_name=$(jq -c '[.[] | .name]' default_app_list.json)" >> $GITHUB_OUTPUT echo "apps_config=$(jq -c '.' default_app_list.json)" >> $GITHUB_OUTPUT create-release: name: Create GitHub Release runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') permissions: contents: write steps: - name: Create release placeholder uses: ncipollo/release-action@v1 with: token: ${{ secrets.GITHUB_TOKEN }} skipIfReleaseExists: true build-cli: name: Build CLI needs: release-apps if: needs.release-apps.result == 'success' runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v6 - name: Setup Node.js Environment uses: ./.github/actions/setup-env with: mode: 'node' - name: Build CLI run: pnpm run cli:build - name: Upload CLI Artifact uses: actions/upload-artifact@v6 with: name: pake-cli-dist path: dist/ retention-days: 1 build-popular-apps: name: ${{ matrix.config.title }} needs: [release-apps, build-cli, create-release] if: | needs.release-apps.result == 'success' && needs.build-cli.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') strategy: matrix: config: ${{ fromJSON(needs.release-apps.outputs.apps_config) }} uses: ./.github/workflows/single-app.yaml secrets: inherit with: name: ${{ matrix.config.name }} title: ${{ matrix.config.title }} name_zh: ${{ matrix.config.name_zh }} url: ${{ matrix.config.url }} icon: ${{ matrix.config.icon }} new_window: ${{ matrix.config.new_window || false }} # Publish Docker image (runs in parallel with app builds) publish-docker: if: | (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')) || (github.event_name == 'workflow_dispatch' && inputs.publish_docker) runs-on: ubuntu-22.04 permissions: contents: read packages: write steps: - name: Checkout repository uses: actions/checkout@v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to Container registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=tag type=sha - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} platforms: linux/amd64 cache-from: type=gha cache-to: type=gha,mode=max ================================================ FILE: .github/workflows/single-app.yaml ================================================ name: Build Single Popular App env: NODE_VERSION: "22" PNPM_VERSION: "10.26.2" on: workflow_dispatch: inputs: name: description: "App Name" required: true default: "twitter" title: description: "App Title" required: true default: "Twitter" name_zh: description: "App Name in Chinese" required: true default: "推特" url: description: "App URL" required: true default: "https://twitter.com/" icon: description: "App Icon" required: false new_window: description: "Allow sites to open new windows" required: false default: false workflow_call: inputs: name: description: "App Name" type: string required: true default: "twitter" title: description: "App Title" required: true type: string default: "Twitter" name_zh: description: "App Name in Chinese" required: true type: string default: "推特" url: description: "App URL" required: true type: string default: "https://twitter.com/" icon: description: "App Icon" type: string required: false new_window: description: "Allow sites to open new windows" type: boolean required: false default: false secrets: PAKE_SIGNING_IDENTITY: required: false PAKE_CERTIFICATE_P12: required: false PAKE_CERTIFICATE_PASSWORD: required: false PAKE_NOTARIZE_APPLE_ID: required: false PAKE_NOTARIZE_TEAM_ID: required: false PAKE_NOTARIZE_PASSWORD: required: false jobs: build: name: ${{ inputs.title }} (${{ matrix.build }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: build: [linux, macos, windows] include: - build: linux os: ubuntu-latest rust: stable - build: windows os: windows-latest rust: stable-x86_64-msvc - build: macos os: macos-latest rust: stable steps: - name: Checkout repository uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable with: toolchain: ${{ matrix.rust }} - name: Setup Node.js Environment uses: ./.github/actions/setup-env with: mode: build - name: Download CLI Artifact if: github.event_name != 'workflow_dispatch' uses: actions/download-artifact@v7 with: name: pake-cli-dist path: dist - name: Build CLI (workflow_dispatch) if: github.event_name == 'workflow_dispatch' run: pnpm run cli:build - name: Setup mold linker if: matrix.os == 'ubuntu-latest' uses: rui314/setup-mold@v1 - name: Prepare macOS signing if: matrix.os == 'macos-latest' env: PAKE_SIGNING_IDENTITY: ${{ secrets.PAKE_SIGNING_IDENTITY }} PAKE_CERTIFICATE_P12: ${{ secrets.PAKE_CERTIFICATE_P12 }} PAKE_CERTIFICATE_PASSWORD: ${{ secrets.PAKE_CERTIFICATE_PASSWORD }} run: | set -euo pipefail SIGNING_IDENTITY="${PAKE_SIGNING_IDENTITY:-}" if [ -n "${PAKE_CERTIFICATE_P12:-}" ] && [ -n "${PAKE_CERTIFICATE_PASSWORD:-}" ]; then echo "Importing macOS signing certificate..." KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db" KEYCHAIN_PASSWORD="$(openssl rand -base64 16)" security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" echo "$PAKE_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" security import "$RUNNER_TEMP/certificate.p12" -P "$PAKE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH" security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security list-keychain -d user -s "$KEYCHAIN_PATH" if [ -z "$SIGNING_IDENTITY" ]; then SIGNING_IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | sed -n 's/.*"\(Developer ID Application:.*\)".*/\1/p' | head -n 1)" fi else echo "No certificate secret configured, fallback to ad-hoc signing." fi if [ -z "$SIGNING_IDENTITY" ]; then SIGNING_IDENTITY="-" fi SIGNING_IDENTITY="$SIGNING_IDENTITY" node <<'NODE' const fs = require('fs'); const file = 'src-tauri/tauri.macos.conf.json'; const config = JSON.parse(fs.readFileSync(file, 'utf8')); config.bundle ??= {}; config.bundle.macOS ??= {}; config.bundle.macOS.signingIdentity = process.env.SIGNING_IDENTITY || '-'; fs.writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`); NODE echo "Using signing identity: $SIGNING_IDENTITY" - name: Build for Linux if: matrix.os == 'ubuntu-latest' timeout-minutes: 20 run: | ARGS=("${{ inputs.url }}" "--name" "${{ inputs.name }}") # Auto-detect local icon or use provided icon if [ -n "${{ inputs.icon }}" ]; then ARGS+=("--icon" "${{ inputs.icon }}") elif [ -f "src-tauri/png/${{ inputs.name }}_512.png" ]; then ARGS+=("--icon" "src-tauri/png/${{ inputs.name }}_512.png") fi if [ "${{ inputs.new_window }}" = "true" ]; then ARGS+=("--new-window") fi # Build once with multiple targets (faster than separate builds) node dist/cli.js "${ARGS[@]}" --targets deb,appimage mkdir -p output/linux # The CLI copies files to project root and removes them from bundle directory # Check project root first, then fallback to bundle directory if [ -f "${{ inputs.name }}.deb" ]; then mv "${{ inputs.name }}.deb" output/linux/${{inputs.title}}_`arch`.deb elif [ -n "$(ls src-tauri/target/release/bundle/deb/*.deb 2>/dev/null)" ]; then mv src-tauri/target/release/bundle/deb/*.deb output/linux/${{inputs.title}}_`arch`.deb else echo "Error: No DEB file found" find . -name "*.deb" -type f exit 1 fi if [ -f "${{ inputs.name }}.AppImage" ]; then mv "${{ inputs.name }}.AppImage" output/linux/"${{inputs.title}}"_`arch`.AppImage elif [ -n "$(ls src-tauri/target/release/bundle/appimage/*.AppImage 2>/dev/null)" ]; then mv src-tauri/target/release/bundle/appimage/*.AppImage output/linux/"${{inputs.title}}"_`arch`.AppImage else echo "Error: No AppImage file found" find . -name "*.AppImage" -type f exit 1 fi - name: Build for macOS if: matrix.os == 'macos-latest' timeout-minutes: 25 env: TAURI_BUNDLER_DMG_IGNORE_CI: "true" run: | # Use title as app product name on macOS to preserve display casing in .app ARGS=("${{ inputs.url }}" "--name" "${{ inputs.title }}" "--hide-title-bar") # Auto-detect local icon or use provided icon if [ -n "${{ inputs.icon }}" ]; then ARGS+=("--icon" "${{ inputs.icon }}") elif [ -f "src-tauri/icons/${{ inputs.name }}.icns" ]; then ARGS+=("--icon" "src-tauri/icons/${{ inputs.name }}.icns") fi if [ "${{ inputs.new_window }}" = "true" ]; then ARGS+=("--new-window") fi node dist/cli.js "${ARGS[@]}" --targets universal --multi-arch mkdir -p output/macos # The CLI copies the DMG to project root and removes it from bundle directory # Check project root first, then fallback to bundle directory if [ -f "${{ inputs.title }}.dmg" ]; then mv "${{ inputs.title }}.dmg" output/macos/"${{inputs.title}}".dmg elif [ -f "${{ inputs.name }}.dmg" ]; then mv "${{ inputs.name }}.dmg" output/macos/"${{inputs.title}}".dmg elif [ -n "$(ls src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg 2>/dev/null)" ]; then mv src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg output/macos/"${{inputs.title}}".dmg else echo "Error: No DMG file found" echo "Searched locations:" echo " - ${{ inputs.name }}.dmg (project root)" echo " - src-tauri/target/universal-apple-darwin/release/bundle/dmg/" find . -name "*.dmg" -type f exit 1 fi - name: Notarize macOS DMG if: matrix.os == 'macos-latest' env: PAKE_NOTARIZE_APPLE_ID: ${{ secrets.PAKE_NOTARIZE_APPLE_ID }} PAKE_NOTARIZE_TEAM_ID: ${{ secrets.PAKE_NOTARIZE_TEAM_ID }} PAKE_NOTARIZE_PASSWORD: ${{ secrets.PAKE_NOTARIZE_PASSWORD }} run: | set -euo pipefail if [ -z "${PAKE_NOTARIZE_APPLE_ID:-}" ] || [ -z "${PAKE_NOTARIZE_TEAM_ID:-}" ] || [ -z "${PAKE_NOTARIZE_PASSWORD:-}" ]; then echo "Notarization secrets not configured, skipping notarization." exit 0 fi DMG_PATH="output/macos/${{inputs.title}}.dmg" if [ ! -f "$DMG_PATH" ]; then echo "Error: DMG file not found at $DMG_PATH" exit 1 fi echo "Submitting DMG to Apple notarization service..." xcrun notarytool submit "$DMG_PATH" \ --apple-id "$PAKE_NOTARIZE_APPLE_ID" \ --team-id "$PAKE_NOTARIZE_TEAM_ID" \ --password "$PAKE_NOTARIZE_PASSWORD" \ --wait echo "Stapling notarization ticket..." xcrun stapler staple "$DMG_PATH" - name: Build for Windows if: matrix.os == 'windows-latest' timeout-minutes: 20 run: | # Use title as app product name on Windows to preserve display casing $args = "${{ inputs.url }}", "--name", "${{ inputs.title }}" # Auto-detect local icon or use provided icon if ("${{ inputs.icon }}" -ne "") { $args += "--icon", "${{ inputs.icon }}" } elseif (Test-Path "src-tauri\png\${{ inputs.name }}_256.ico") { $args += "--icon", "src-tauri\png\${{ inputs.name }}_256.ico" } if ("${{ inputs.new_window }}" -eq "true") { $args += "--new-window" } $args += "--targets", "x64" node dist/cli.js $args New-Item -Path "output\windows" -ItemType Directory -Force # The CLI copies the MSI to project root and removes it from bundle directory # Check project root first, then fallback to bundle directories $projectRootMsi = "${{ inputs.title }}.msi" $legacyProjectRootMsi = "${{ inputs.name }}.msi" if (Test-Path $projectRootMsi) { Move-Item -Path $projectRootMsi -Destination "output\windows\${{inputs.title}}_x64.msi" } elseif (Test-Path $legacyProjectRootMsi) { Move-Item -Path $legacyProjectRootMsi -Destination "output\windows\${{inputs.title}}_x64.msi" } else { # Check architecture-specific path (x64 builds) $msiFiles = Get-ChildItem -Path "src-tauri\target\x86_64-pc-windows-msvc\release\bundle\msi\*.msi" -ErrorAction SilentlyContinue # Fallback to generic path if (-not $msiFiles) { $msiFiles = Get-ChildItem -Path "src-tauri\target\release\bundle\msi\*.msi" -ErrorAction SilentlyContinue } if ($msiFiles) { Move-Item -Path $msiFiles[0].FullName -Destination "output\windows\${{inputs.title}}_x64.msi" } else { Write-Error "No MSI files found in expected locations" Write-Host "Searched paths:" Write-Host " - $projectRootMsi (project root)" Write-Host " - $legacyProjectRootMsi (project root)" Write-Host " - src-tauri\target\x86_64-pc-windows-msvc\release\bundle\msi\" Write-Host " - src-tauri\target\release\bundle\msi\" Write-Host "`nAll MSI files in target directory:" Get-ChildItem -Path "src-tauri\target\" -Recurse -Name "*.msi" | Write-Host exit 1 } } git checkout -- src-tauri/Cargo.lock - name: Upload artifacts uses: actions/upload-artifact@v6 with: name: ${{ inputs.title }}-${{ matrix.build }} path: output/*/*.* retention-days: 3 - name: Upload to release (Linux) uses: ncipollo/release-action@v1 # cspell:disable-line if: matrix.os == 'ubuntu-latest' && startsWith(github.ref, 'refs/tags/') with: allowUpdates: true omitBody: true omitName: true artifacts: "output/linux/*.deb,output/linux/*.AppImage" token: ${{ secrets.GITHUB_TOKEN }} - name: Upload to release (macOS) uses: ncipollo/release-action@v1 # cspell:disable-line if: matrix.os == 'macos-latest' && startsWith(github.ref, 'refs/tags/') with: allowUpdates: true omitBody: true omitName: true artifacts: "output/macos/*.dmg" token: ${{ secrets.GITHUB_TOKEN }} - name: Upload to release (Windows) uses: ncipollo/release-action@v1 # cspell:disable-line if: matrix.os == 'windows-latest' && startsWith(github.ref, 'refs/tags/') with: allowUpdates: true omitBody: true omitName: true artifacts: "output/windows/*.msi" token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/update-contributors.yml ================================================ name: Update Contributors on: push: branches: [main, dev] workflow_dispatch: schedule: - cron: "0 0 * * 0" # Every Sunday at midnight UTC concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: update-contributors: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - name: Checkout uses: actions/checkout@v6 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Generate contributors SVG uses: tw93/contributors-list@master with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} svgPath: CONTRIBUTORS.svg svgWidth: 1000 avatarSize: 72 avatarMargin: 45 userNameHeight: 20 noFetch: false noCommit: true truncate: 0 includeBots: false excludeUsers: "github-actions web-flow dependabot claude" itemTemplate: | {{{ name }}} - name: Commit & Push uses: stefanzweifel/git-auto-commit-action@v7 with: commit_message: "chore: update contributors [skip ci]" file_pattern: CONTRIBUTORS.svg commit_user_name: github-actions[bot] commit_user_email: 41898282+github-actions[bot]@users.noreply.github.com commit_author: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> push_options: '--force' ================================================ FILE: .gitignore ================================================ !dist/.gitkeep !dist/about_pake.html !dist/cli.js .DS_Store .idea .next .vscode *.app *.AppImage *.deb *.desktop *.dmg *.local *.log *.msi *.njsproj *.ntvs* *.sln *.suo *.sw? *.tmp # AI assistant docs (do not commit) # AI Assistant files # Editor directories and files # Logs .claude/ AGENT.md AGENTS.md CLAUDE.md dist dist-ssr journal/ lerna-debug.log* logs node_modules npm-debug.log* output pnpm-debug.log* src-tauri/.cargo/ src-tauri/.cargo/config.toml src-tauri/.pake/ src-tauri/gen yarn-debug.log* yarn-error.log* ================================================ FILE: .npmignore ================================================ # Development files pnpm-lock.yaml package-lock.json yarn.lock # Development directories node_modules/ .vscode/ .idea/ # Build artifacts dist-ssr/ *.local # Development configs .env .env.local .env.development .env.test .env.production # Logs logs/ *.log npm-debug.log* yarn-debug.log* pnpm-debug.log* # OS files .DS_Store Thumbs.db # Testing coverage/ .nyc_output/ # Documentation source docs/ *.md !README.md # Development scripts script/ rollup.config.js tsconfig.json .prettierrc* .eslintrc* # Tauri development files src-tauri/target/ src-tauri/.cargo/config.toml src-tauri/.pake/ src-tauri/gen/ output/ ================================================ FILE: .npmrc ================================================ # Suppress npm funding and audit messages during installation fund=false audit=false # Resolve sharp version conflicts prefer-dedupe=true ================================================ FILE: .pnpmrc ================================================ strict-peer-dependencies=false node-linker=hoisted auto-install-peers=true ================================================ FILE: .prettierignore ================================================ src-tauri/target node_modules dist/**/* *.ico *.icns *.png *.jpg *.jpeg *.gif *.svg *.bin *.exe *.dll *.so *.dylib Cargo.lock src-tauri/Cargo.lock pnpm-lock.yaml cli.js *.desktop *.wxs *.plist *.toml .github/workflows/ ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of erotic language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at tw93@qq.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: CONTRIBUTING.md ================================================ ## How to contribute to Pake **Welcome to create [pull requests](https://github.com/tw93/Pake/compare/) for bugfix, new component, doc, example, suggestion and anything.** ## Branch Management All development happens directly on `main`. Submit pull requests to `main`. ## Development Setup ### Prerequisites - Node.js ≥22.0.0 (recommended LTS, older versions ≥18.0.0 may work) - Rust ≥1.85.0 (required for edition2024 support in dependencies) - Platform-specific build tools: - **macOS**: Xcode Command Line Tools (`xcode-select --install`) - **Windows**: Visual Studio Build Tools with MSVC - **Linux**: `build-essential`, `libwebkit2gtk`, system dependencies ### Installation ```bash # Clone the repository git clone https://github.com/tw93/Pake.git cd Pake # Install dependencies pnpm install # Start development (Tauri only) pnpm run dev # Start development (CLI Wrapper + Tauri) - Recommended for CLI changes pnpm run cli:dev -- https://web.telegram.org/k/ ``` ### Testing ```bash # Run all tests (unit + integration + builder) pnpm test # Build CLI for testing pnpm run cli:build ``` ### Tips - Use `--iterative-build` flag during development to skip some hefty checks and use app bundle format for faster debugging: ```bash pnpm run cli:dev --iterative-build ``` ## Continuous Integration The project uses streamlined GitHub Actions workflows: - **Quality & Testing**: Automatic code quality checks and comprehensive testing on all platforms - **Claude AI Integration**: Automated code review and interactive assistance - **Release Management**: Coordinated releases with app building and Docker publishing ## Troubleshooting ### macOS 26 Beta Compilation Issues If you're running macOS 26 Beta and encounter compilation errors related to `mac-notification-sys` or system frameworks (errors about `CoreFoundation`, `_Builtin_float` modules), create a `src-tauri/.cargo/config.toml` file with: ```toml [env] # Fix for macOS 26 Beta compatibility issues # Forces use of compatible SDK when building on macOS 26 Beta MACOSX_DEPLOYMENT_TARGET = "15.0" SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" ``` This file is already in `.gitignore` and should not be committed to the repository. **Root Cause**: macOS 26 Beta uses newer system frameworks that aren't yet fully compatible with Tauri's dependencies. This configuration uses the universal SDK symlink which automatically points to your system's available SDK version. ### Common Build Issues - **Rust compilation errors**: Run `cargo clean` in `src-tauri/` directory - **`cargo` command not found after installation**: Pake CLI now reloads the Rust environment automatically, but if the issue persists reopen your terminal or run `source ~/.cargo/env` (macOS/Linux) / `call %USERPROFILE%\.cargo\env` (Windows) before retrying - **Node dependency issues**: Delete `node_modules` and run `pnpm install` - **Permission errors on macOS**: Run `sudo xcode-select --reset` See the [Advanced Usage Guide](docs/advanced-usage.md) for project structure and customization techniques. ## More It is a good habit to create a feature request issue to discuss whether the feature is necessary before you implement it. However, it's unnecessary to create an issue to claim that you found a typo or improved the readability of documentation, just create a pull request. ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1.4 # Cargo build stage - Updated to latest Rust for edition2024 support FROM rust:latest AS cargo-builder # Update Rust to ensure we have the latest version with edition2024 support RUN rustup update stable && rustup default stable # Verify Rust version supports edition2024 RUN rustc --version && cargo --version # Install Rust dependencies RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock && \ apt-get update && apt-get install -y --no-install-recommends \ libdbus-1-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev \ libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev \ gnome-video-effects && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Set PKG_CONFIG_PATH for GLib detection ENV PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig # Verify Rust version RUN rustc --version && echo "Rust version verified" COPY . /pake WORKDIR /pake/src-tauri # Build cargo packages and store cache RUN --mount=type=cache,target=/usr/local/cargo/registry \ cargo fetch && \ cargo build --release && \ mkdir -p /cargo-cache && \ cp -R /usr/local/cargo/registry /cargo-cache/ && \ ([ -d "/usr/local/cargo/git" ] && cp -R /usr/local/cargo/git /cargo-cache/ || mkdir -p /usr/local/cargo/git) && \ cp -R /usr/local/cargo/git /cargo-cache/ # Verify the content of /cargo-cache && clean unnecessary files RUN ls -la /cargo-cache/registry && ls -la /cargo-cache/git && rm -rfd /cargo-cache/registry/src # Main build stage FROM rust:latest AS builder # Update Rust to ensure we have the latest version with edition2024 support RUN rustup update stable && rustup default stable # Install Rust dependencies RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock && \ apt-get update && apt-get install -y --no-install-recommends \ libdbus-1-dev libsoup-3.0-dev libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev \ libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev \ gnome-video-effects && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Set PKG_CONFIG_PATH for GLib detection ENV PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig # Verify Rust version in builder stage RUN rustc --version && echo "Builder stage Rust version verified" # Install Node.js 22.x and pnpm RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ rm -f /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/cache/apt/archives/lock && \ curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ apt-get update && apt-get install -y nodejs && \ npm install -g pnpm && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Copy project files COPY . /pake WORKDIR /pake # Copy Rust build artifacts COPY --from=cargo-builder /pake/src-tauri /pake/src-tauri COPY --from=cargo-builder /cargo-cache/git /usr/local/cargo/git COPY --from=cargo-builder /cargo-cache/registry /usr/local/cargo/registry # Install dependencies and build pake-cli RUN --mount=type=cache,target=/root/.local/share/pnpm \ pnpm install --frozen-lockfile && \ pnpm run cli:build # Set up the entrypoint WORKDIR /output ENTRYPOINT ["node", "/pake/dist/cli.js"] ================================================ FILE: LICENSE ================================================ 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. ================================================ FILE: README.md ================================================

English | 简体中文

Pake

Turn any webpage into a desktop app with one command, supports macOS, Windows, and Linux

twitter telegram GitHub downloads GitHub commit GitHub closed issues
## Features - 🎐 **Lightweight**: Nearly 20 times smaller than Electron packages, typically around 5M - 🚀 **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 ## Getting Started - **Beginners**: Download ready-made [Popular Packages](#popular-packages) or use [Online Building](docs/github-actions-usage.md) with no environment setup required - **Developers**: Install [CLI Tool](docs/cli-usage.md) for one-command packaging of any website with customizable icons, window settings, and more - **Advanced Users**: Clone the project locally for [Custom Development](#development), or check [Advanced Usage](docs/advanced-usage.md) for style customization and feature enhancement - **Troubleshooting**: Check [FAQ](docs/faq.md) for common issues and solutions ## Popular Packages
WeRead Mac Windows Linux Twitter Mac Windows Linux
Grok Mac Windows Linux DeepSeek Mac Windows Linux
ChatGPT Mac Windows Linux Gemini Mac Windows Linux
YouTube Music Mac Windows Linux YouTube Mac Windows Linux
LiZhi Mac Windows Linux ProgramMusic Mac Windows Linux
Excalidraw Mac Windows Linux XiaoHongShu Mac Windows Linux
🏂 You can download more applications from Releases. Click here to expand the shortcuts reference!
| Mac | Windows/Linux | Function | | --------------------------------------------------------- | --------------------------------------------------- | ----------------------------------- | | + [ | Ctrl + | Return to the previous page | | + ] | Ctrl + | Go to the next page | | + | Ctrl + | Auto scroll to top of page | | + | Ctrl + | Auto scroll to bottom of page | | + r | Ctrl + r | Refresh Page | | + w | Ctrl + w | Hide window, not quit | | + - | Ctrl + - | Zoom out the page | | + = | Ctrl + = | Zoom in the Page | | + 0 | Ctrl + 0 | Reset the page zoom | | + L | Ctrl + L | Copy Current Page URL | | + + + V | Ctrl + Shift + V | Paste and Match Style | | + + H | Ctrl + Shift + H | Go to Home Page | | + + I | Ctrl + Shift + I | Toggle Developer Tools (Debug Only) | | + + | Ctrl + Shift + Del | Clear Cache & Restart | In addition, double-click the title bar to switch to full-screen mode. For Mac users, you can also use the gesture to go to the previous or next page and drag the title bar to move the window. The new menu also offers options for navigation, zoom, and window controls.
## Command-Line Packaging ![Pake](https://raw.githubusercontent.com/tw93/static/main/pake/pake1.gif) ```bash # Install Pake CLI pnpm install -g pake-cli # Basic usage - automatically fetches website icon pake https://github.com --name GitHub # Advanced usage with custom options pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/weekly.icns --width 1200 --height 800 --hide-title-bar ``` First-time packaging requires environment setup and may be slower, subsequent builds are fast. For complete parameter documentation, see [CLI Usage Guide](docs/cli-usage.md). Don't want to use CLI? Try [GitHub Actions Online Building](docs/github-actions-usage.md). ## 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. ```bash # Install dependencies pnpm i # Local development [right-click to open debug mode] pnpm run dev # Build application pnpm run build ``` For style customization, feature enhancement, container communication and other advanced features, see [Advanced Usage Documentation](docs/advanced-usage.md). ## Developers Pake's development can not be without these Hackers. They contributed a lot of capabilities for Pake. Also, welcome to follow them! ❤️ Contributors ## Support 1. I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them food 🥩. 2. If you like Pake, you can star it on GitHub. Also, welcome to [recommend Pake](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) to your friends. 3. You can follow my [Twitter](https://twitter.com/HiTw93) to get the latest news of Pake or join our [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) chat group. 4. I hope that you enjoy playing with it. Let us know if you find a website that would be great for a Mac App! ================================================ FILE: README_CN.md ================================================

English | 简体中文

Pake

一键打包网页生成轻量桌面应用,支持 macOS、Windows 和 Linux

twitter telegram GitHub downloads GitHub commit GitHub closed issues
## 特征 - 🎐 **体积小巧**:相比 Electron 应用小近 20 倍,通常只有 5M 左右 - 🚀 **性能优异**:基于 Rust Tauri,比传统 JS 框架更快,内存占用更少 - ⚡ **使用简单**:命令行一键打包,或在线构建,无需复杂配置 - 📦 **功能丰富**:支持快捷键透传、沉浸式窗口、拖拽、样式定制、去广告 ## 快速开始 - **新手用户**:直接下载现成的 [常用包](#常用包下载),或通过 [在线构建](docs/github-actions-usage_CN.md) 无需环境配置即可打包 - **开发者**:安装 [CLI 工具](docs/cli-usage_CN.md) 后一行命令打包任意网站,支持自定义图标、窗口等参数 - **高级用户**:本地克隆项目进行 [定制开发](#定制开发),或查看 [高级用法](docs/advanced-usage_CN.md) 实现样式定制、功能增强 - **遇到问题**:查看 [常见问题](docs/faq_CN.md) 获取常见问题的解决方案 ## 常用包下载
WeRead Mac Windows Linux Twitter Mac Windows Linux
Grok Mac Windows Linux DeepSeek Mac Windows Linux
ChatGPT Mac Windows Linux Gemini Mac Windows Linux
YouTube Music Mac Windows Linux YouTube Mac Windows Linux
LiZhi Mac Windows Linux ProgramMusic Mac Windows Linux
Excalidraw Mac Windows Linux XiaoHongShu Mac Windows Linux
🏂 更多应用可去 Release下载,此外点击可展开快捷键说明
| Mac | Windows/Linux | 功能 | | --------------------------------------------------------- | --------------------------------------------------- | ------------------- | | + [ | Ctrl + | 返回上一个页面 | | + ] | Ctrl + | 去下一个页面 | | + | Ctrl + | 自动滚动到页面顶部 | | + | Ctrl + | 自动滚动到页面底部 | | + r | Ctrl + r | 刷新页面 | | + w | Ctrl + w | 隐藏窗口,非退出 | | + - | Ctrl + - | 缩小页面 | | + = | Ctrl + = | 放大页面 | | + 0 | Ctrl + 0 | 重置页面缩放 | | + L | Ctrl + L | 复制当前页面网址 | | + + + V | Ctrl + Shift + V | 粘贴并匹配样式 | | + + H | Ctrl + Shift + H | 回到首页 | | + + I | Ctrl + Shift + I | 开启调试 (仅开发版) | | + + | Ctrl + Shift + Del | 清除缓存并重启 | 此外还支持双击头部全屏切换,拖拽头部移动窗口,Mac 用户支持手势返回和前进,新菜单也提供了导航、缩放和窗口控制等选项。
## 命令行一键打包 ![Pake](https://raw.githubusercontent.com/tw93/static/main/pake/pake1.gif) ```bash # 安装 Pake CLI pnpm install -g pake-cli # 基础用法 - 自动获取网站图标 pake https://github.com --name GitHub # 高级用法:自定义选项 pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/weekly.icns --width 1200 --height 800 --hide-title-bar ``` 首次打包需要安装环境会比较慢,后续很快。完整参数说明查看 [CLI 使用指南](docs/cli-usage_CN.md),不想用命令行可以试试 [GitHub Actions 在线构建](docs/github-actions-usage_CN.md)。 ## 定制开发 需要 Rust `>=1.85` 和 Node `>=22`,详细安装指南参考 [Tauri 文档](https://tauri.app/start/prerequisites/)。不熟悉开发环境建议直接使用命令行工具。 ```bash # 安装依赖 pnpm i # 本地开发[右键可打开调试模式] pnpm run dev # 打包应用 pnpm run build ``` 想要样式定制、功能增强、容器通信等高级玩法,查看 [高级用法文档](docs/advanced-usage_CN.md)。 ## 开发者 Pake 的发展离不开这些优秀的贡献者 ❤️ Contributors ## 支持 1. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 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) 聊天群。 4. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 ================================================ FILE: action.yml ================================================ name: "Pake Web App Builder" description: "Transform any webpage into a lightweight desktop app using Rust and Tauri" author: "tw93" branding: icon: "package" color: "blue" inputs: url: description: "Target URL to package" required: true name: description: "Application name" required: true output-dir: description: "Output directory for packages" required: false default: "dist" icon: description: "Custom app icon URL or path" required: false width: description: "Window width" required: false default: "1200" height: description: "Window height" required: false default: "780" debug: description: "Enable debug mode" required: false default: "false" outputs: package-path: description: "Path to the generated package" runs: using: "composite" steps: - name: Setup Environment shell: bash run: | # Install Node.js dependencies npm install # Build Pake CLI if not exists if [ ! -f "dist/cli.js" ]; then npm run cli:build fi # Ensure node is accessible in subsequent steps echo "$(npm bin)" >> $GITHUB_PATH # Install Rust/Cargo if needed if ! command -v cargo &> /dev/null; then curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source ~/.cargo/env echo "$HOME/.cargo/bin" >> $GITHUB_PATH fi - name: Build Pake App shell: bash run: | # Build arguments ARGS=("${{ inputs.url }}") ARGS+=("--name" "${{ inputs.name }}") if [ -n "${{ inputs.icon }}" ]; then ARGS+=("--icon" "${{ inputs.icon }}") fi ARGS+=("--width" "${{ inputs.width }}") ARGS+=("--height" "${{ inputs.height }}") if [ "${{ inputs.debug }}" == "true" ]; then ARGS+=("--debug") fi # Create output directory mkdir -p "${{ inputs.output-dir }}" export PAKE_CREATE_APP=1 # Run Pake CLI echo "🔧 Running: node dist/cli.js ${ARGS[*]}" node dist/cli.js "${ARGS[@]}" # Find generated package and set output PACKAGE=$(find src-tauri/target -type f \( -name "*.deb" -o -name "*.exe" -o -name "*.msi" -o -name "*.dmg" \) 2>/dev/null | head -1) # If no file packages found, look for .app directory (macOS) if [ -z "$PACKAGE" ]; then PACKAGE=$(find src-tauri/target -type d -name "*.app" 2>/dev/null | head -1) fi if [ -n "$PACKAGE" ]; then # Move to output directory BASENAME=$(basename "$PACKAGE") mv "$PACKAGE" "${{ inputs.output-dir }}/$BASENAME" 2>/dev/null || cp -r "$PACKAGE" "${{ inputs.output-dir }}/$BASENAME" echo "package-path=${{ inputs.output-dir }}/$BASENAME" >> $GITHUB_OUTPUT echo "✅ Package created: ${{ inputs.output-dir }}/$BASENAME" else echo "❌ No package found" exit 1 fi ================================================ FILE: bin/builders/BaseBuilder.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; import chalk from 'chalk'; import prompts from 'prompts'; import { PakeAppOptions } from '@/types'; import { checkRustInstalled, ensureRustEnv, installRust } from '@/helpers/rust'; import { mergeConfig } from '@/helpers/merge'; import tauriConfig from '@/helpers/tauriConfig'; import { generateIdentifierSafeName, generateLinuxPackageName, } from '@/utils/name'; import { npmDirectory } from '@/utils/dir'; import { getSpinner } from '@/utils/info'; import { shellExec } from '@/utils/shell'; import { isChinaDomain } from '@/utils/ip'; import { IS_MAC } from '@/utils/platform'; import logger from '@/options/logger'; export default abstract class BaseBuilder { protected options: PakeAppOptions; private static packageManagerCache: string | null = null; protected constructor(options: PakeAppOptions) { this.options = options; } private getBuildEnvironment() { return IS_MAC ? { CFLAGS: '-fno-modules', CXXFLAGS: '-fno-modules', MACOSX_DEPLOYMENT_TARGET: '14.0', } : undefined; } 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.', ); } } } async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); const tauriTargetPath = path.join(tauriSrcPath, 'target'); const tauriTargetPathExists = await fsExtra.pathExists(tauriTargetPath); if (!IS_MAC && !tauriTargetPathExists) { logger.warn('✼ The first use requires installing system dependencies.'); logger.warn('✼ See more in https://tauri.app/start/prerequisites/.'); } ensureRustEnv(); if (!checkRustInstalled()) { const res = await prompts({ type: 'confirm', message: 'Rust not detected. Install now?', name: 'value', }); if (res.value) { await installRust(); } else { logger.error('✕ Rust required to package your webapp.'); process.exit(0); } } 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(); // 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; try { if (isChina) { logger.info( `✺ Located in China, using ${packageManager}/rsProxy CN mirror.`, ); const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); await fsExtra.copy(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' }, ); } 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...'), ); 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 fsExtra.copy(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; } } if (!tauriTargetPathExists) { logger.warn( '✼ The first packaging may be slow, please be patient and wait, it will be faster afterwards.', ); } } async build(url: string) { await this.buildAndCopy(url, this.options.targets); } async start(url: string) { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); const packageManager = await this.detectPackageManager(); const configPath = path.join( npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json', ); const features = this.getBuildFeatures(); const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : ''; const argSeparator = packageManager === 'npm' ? ' --' : ''; const command = `cd "${npmDirectory}" && ${packageManager} run tauri${argSeparator} dev --config "${configPath}" ${featureArgs}`; await shellExec(command); } async buildAndCopy(url: string, target: string) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); // Detect available package manager const packageManager = await this.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 await new Promise((resolve) => setTimeout(resolve, 500)); buildSpinner.stop(); // Show static message to keep the status visible logger.warn('✸ Building app...'); const baseEnv = this.getBuildEnvironment(); let buildEnv: Record = { ...(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 buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; const buildTimeout = this.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()); } else { throw error; } } // Copy app const fileName = this.getFileName(); const fileType = this.getFileType(target); const appPath = this.getBuildAppPath(npmDirectory, fileName, fileType); const distPath = path.resolve(`${name}.${fileType}`); await fsExtra.copy(appPath, distPath); // Copy raw binary if requested if (this.options.keepBinary) { await this.copyRawBinary(npmDirectory, name); } await fsExtra.remove(appPath); 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); logger.success('✔ Raw binary located in', path.resolve(binaryPath)); } if (IS_MAC && fileType === 'app' && this.options.install) { await this.installAppToApplications(distPath, name); } } private async installAppToApplications( appBundlePath: string, appName: string, ): Promise { try { logger.info(`- Installing ${appName} to /Applications...`); const appBundleName = path.basename(appBundlePath); const appDest = path.join('/Applications', appBundleName); // fsExtra.move uses fs.rename (atomic on same filesystem) and falls back // to copy+remove only when moving across volumes. await fsExtra.move(appBundlePath, appDest, { overwrite: true }); logger.success( `✔ ${appBundleName.replace(/\.app$/, '')} installed to /Applications`, ); } catch (error) { logger.error(`✕ Failed to install ${appName}: ${error}`); logger.info(` App bundle still available at: ${appBundlePath}`); } } protected getFileType(target: string): string { return target; } 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 > = { darwin: { arm64: 'aarch64-apple-darwin', x64: 'x86_64-apple-darwin', universal: 'universal-apple-darwin', }, win32: { arm64: 'aarch64-pc-windows-msvc', x64: 'x86_64-pc-windows-msvc', }, linux: { arm64: 'aarch64-unknown-linux-gnu', x64: 'x86_64-unknown-linux-gnu', }, }; // 架构名称映射(用于文件名生成) protected static readonly ARCH_DISPLAY_NAMES: Record = { arm64: 'aarch64', x64: 'x64', universal: 'universal', }; /** * 解析目标架构 */ protected resolveTargetArch(requestedArch?: string): string { if (requestedArch === 'auto' || !requestedArch) { return process.arch; } return requestedArch; } /** * 获取Tauri构建目标 */ protected getTauriTarget( arch: string, platform: NodeJS.Platform = process.platform, ): string | null { const platformMappings = BaseBuilder.ARCH_MAPPINGS[platform]; if (!platformMappings) return null; return platformMappings[arch] || null; } /** * 获取架构显示名称(用于文件名) */ protected getArchDisplayName(arch: string): string { return BaseBuilder.ARCH_DISPLAY_NAMES[arch] || arch; } /** * 构建基础构建命令 */ protected buildBaseCommand( packageManager: string, configPath: string, target?: string, ): string { const baseCommand = this.options.debug ? `${packageManager} run build:debug` : `${packageManager} run build`; const argSeparator = packageManager === 'npm' ? ' --' : ''; let fullCommand = `${baseCommand}${argSeparator} -c "${configPath}"`; if (target) { fullCommand += ` --target ${target}`; } // Enable verbose output in debug mode to help diagnose build issues. // This provides detailed logs from Tauri CLI and bundler tools. if (this.options.debug) { fullCommand += ' --verbose'; } return fullCommand; } /** * 获取构建特性列表 */ protected getBuildFeatures(): string[] { const features = ['cli-build']; // Add macos-proxy feature for modern macOS (Darwin 23+ = macOS 14+) if (IS_MAC) { const macOSVersion = this.getMacOSMajorVersion(); if (macOSVersion >= 23) { features.push('macos-proxy'); } } return features; } protected getBuildCommand(packageManager: string = 'pnpm'): string { // Use temporary config directory to avoid modifying source files const configPath = path.join( npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json', ); let fullCommand = this.buildBaseCommand(packageManager, configPath); // For macOS, use app bundles by default unless DMG is explicitly requested if (IS_MAC && this.options.targets === 'app') { fullCommand += ' --bundles app'; } // Add features const features = this.getBuildFeatures(); if (features.length > 0) { fullCommand += ` --features ${features.join(',')}`; } return fullCommand; } protected getMacOSMajorVersion(): number { try { const os = require('os'); const release = os.release(); const majorVersion = parseInt(release.split('.')[0], 10); return majorVersion; } catch (error) { return 0; // Disable proxy feature if version detection fails } } protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; return `src-tauri/target/${basePath}/bundle/`; } protected getBuildAppPath( npmDirectory: string, fileName: string, fileType: string, ): string { // 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}`, ); } /** * Copy raw binary file to output directory */ protected async copyRawBinary( npmDirectory: string, appName: string, ): Promise { const binaryPath = this.getRawBinarySourcePath(npmDirectory, appName); const outputPath = this.getRawBinaryPath(appName); if (await fsExtra.pathExists(binaryPath)) { await fsExtra.copy(binaryPath, outputPath); // Make binary executable on Unix-like systems if (process.platform !== 'win32') { await fsExtra.chmod(outputPath, 0o755); } } else { logger.warn(`✼ Raw binary not found at ${binaryPath}, skipping...`); } } /** * Get the source path of the raw binary file in the build directory */ protected getRawBinarySourcePath( npmDirectory: string, appName: string, ): string { const basePath = this.options.debug ? 'debug' : 'release'; 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(npmDirectory, 'src-tauri/target', basePath, binaryName); } /** * Get the output path for the raw binary file */ protected getRawBinaryPath(appName: string): string { const extension = process.platform === 'win32' ? '.exe' : ''; const suffix = process.platform === 'win32' ? '' : '-binary'; return `${appName}${suffix}${extension}`; } /** * Get the binary name based on app name and platform */ protected getBinaryName(appName: string): string { const extension = process.platform === 'win32' ? '.exe' : ''; // Use unique binary name for all platforms to avoid conflicts const nameToUse = process.platform === 'linux' ? generateLinuxPackageName(appName) : generateIdentifierSafeName(appName); return `pake-${nameToUse}${extension}`; } /** * Check if this build has architecture-specific target */ protected hasArchSpecificTarget(): boolean { return false; // Override in subclasses if needed } /** * Get architecture-specific path for binary */ protected getArchSpecificPath(): string { return 'src-tauri/target'; // Override in subclasses if needed } } ================================================ FILE: bin/builders/BuilderProvider.ts ================================================ import BaseBuilder from './BaseBuilder'; import MacBuilder from './MacBuilder'; import WinBuilder from './WinBuilder'; import LinuxBuilder from './LinuxBuilder'; import { PakeAppOptions } from '@/types'; const { platform } = process; const buildersMap: Record< string, new (options: PakeAppOptions) => BaseBuilder > = { darwin: MacBuilder, win32: WinBuilder, linux: LinuxBuilder, }; export default class BuilderProvider { static create(options: PakeAppOptions): BaseBuilder { const Builder = buildersMap[platform]; if (!Builder) { throw new Error('The current system is not supported!'); } return new Builder(options); } } ================================================ FILE: bin/builders/LinuxBuilder.ts ================================================ import path from 'path'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; export default class LinuxBuilder extends BaseBuilder { private buildFormat: string; private buildArch: string; private currentBuildType: string = ''; constructor(options: PakeAppOptions) { super(options); const target = options.targets || 'deb'; if (target.includes('-arm64')) { this.buildFormat = target.replace('-arm64', ''); this.buildArch = 'arm64'; } else { this.buildFormat = target; this.buildArch = this.resolveTargetArch('auto'); } this.options.targets = this.buildFormat; } getFileName() { const { name = 'pake-app', targets } = this.options; const version = tauriConfig.version; const buildType = this.currentBuildType || targets.split(',').map((t) => t.trim())[0]; let arch: string; if (this.buildArch === 'arm64') { arch = buildType === 'rpm' || buildType === 'appimage' ? 'aarch64' : 'arm64'; } else { if (this.buildArch === 'x64') { arch = buildType === 'rpm' ? 'x86_64' : 'amd64'; } else { arch = this.buildArch; if ( this.buildArch === 'arm64' && (buildType === 'rpm' || buildType === 'appimage') ) { arch = 'aarch64'; } } } if (this.currentBuildType === 'rpm') { return `${name}-${version}-1.${arch}`; } return `${name}_${version}_${arch}`; } 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); } } } // 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) { this.currentBuildType = target; await super.buildAndCopy(url, target); } protected getBuildCommand(packageManager: string = 'pnpm'): string { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); const buildTarget = this.buildArch === 'arm64' ? (this.getTauriTarget(this.buildArch, 'linux') ?? undefined) : undefined; let fullCommand = this.buildBaseCommand( packageManager, configPath, buildTarget, ); const features = this.getBuildFeatures(); if (features.length > 0) { fullCommand += ` --features ${features.join(',')}`; } if (this.currentBuildType) { fullCommand += ` --bundles ${this.currentBuildType}`; } // Enable verbose output for AppImage builds when debugging or PAKE_VERBOSE is set. // AppImage builds often fail with minimal error messages from linuxdeploy, // so verbose mode helps diagnose issues like strip failures and missing dependencies. if ( this.currentBuildType === 'appimage' && (this.options.targets.includes('appimage') || this.options.debug || process.env.PAKE_VERBOSE) ) { fullCommand += ' --verbose'; } return fullCommand; } protected getBasePath(): string { 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/`; } return super.getBasePath(); } protected getFileType(target: string): string { if (target === 'appimage') { return 'AppImage'; } return super.getFileType(target); } protected hasArchSpecificTarget(): boolean { return this.buildArch === 'arm64'; } protected getArchSpecificPath(): string { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); return `src-tauri/target/${target}`; } return super.getArchSpecificPath(); } } ================================================ FILE: bin/builders/MacBuilder.ts ================================================ import path from 'path'; import tauriConfig from '@/helpers/tauriConfig'; import { PakeAppOptions } from '@/types'; import BaseBuilder from './BaseBuilder'; export default class MacBuilder extends BaseBuilder { private buildFormat: string; private buildArch: string; constructor(options: PakeAppOptions) { super(options); const validArchs = ['intel', 'apple', 'universal', 'auto', 'x64', 'arm64']; this.buildArch = validArchs.includes(options.targets || '') ? options.targets : 'auto'; if ( options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1' ) { this.buildFormat = 'app'; } else { this.buildFormat = 'dmg'; } this.options.targets = this.buildFormat; } getFileName(): string { const { name = 'pake-app' } = this.options; if (this.buildFormat === 'app') { return name; } let arch: string; if (this.buildArch === 'universal' || this.options.multiArch) { arch = 'universal'; } else if (this.buildArch === 'apple') { arch = 'aarch64'; } else if (this.buildArch === 'intel') { arch = 'x64'; } else { arch = this.getArchDisplayName(this.resolveTargetArch(this.buildArch)); } return `${name}_${tauriConfig.version}_${arch}`; } private getActualArch(): string { if (this.buildArch === 'universal' || this.options.multiArch) { return 'universal'; } else if (this.buildArch === 'apple') { return 'arm64'; } else if (this.buildArch === 'intel') { return 'x64'; } return this.resolveTargetArch(this.buildArch); } protected getBuildCommand(packageManager: string = 'pnpm'): string { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); const actualArch = this.getActualArch(); const buildTarget = this.getTauriTarget(actualArch, 'darwin'); if (!buildTarget) { throw new Error(`Unsupported architecture: ${actualArch} for macOS`); } let fullCommand = this.buildBaseCommand( packageManager, configPath, buildTarget, ); const features = this.getBuildFeatures(); if (features.length > 0) { fullCommand += ` --features ${features.join(',')}`; } return fullCommand; } protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); return `src-tauri/target/${target}/${basePath}/bundle`; } protected hasArchSpecificTarget(): boolean { return true; } protected getArchSpecificPath(): string { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); return `src-tauri/target/${target}`; } } ================================================ FILE: bin/builders/WinBuilder.ts ================================================ import path from 'path'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; export default class WinBuilder extends BaseBuilder { private buildFormat: string = 'msi'; private buildArch: string; constructor(options: PakeAppOptions) { super(options); const validArchs = ['x64', 'arm64', 'auto']; this.buildArch = validArchs.includes(options.targets || '') ? this.resolveTargetArch(options.targets) : this.resolveTargetArch('auto'); this.options.targets = this.buildFormat; } getFileName(): string { const { name } = this.options; const language = tauriConfig.bundle.windows.wix.language[0]; const targetArch = this.getArchDisplayName(this.buildArch); return `${name}_${tauriConfig.version}_${targetArch}_${language}`; } protected getBuildCommand(packageManager: string = 'pnpm'): string { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); const buildTarget = this.getTauriTarget(this.buildArch, 'win32'); if (!buildTarget) { throw new Error( `Unsupported architecture: ${this.buildArch} for Windows`, ); } let fullCommand = this.buildBaseCommand( packageManager, configPath, buildTarget, ); const features = this.getBuildFeatures(); if (features.length > 0) { fullCommand += ` --features ${features.join(',')}`; } return fullCommand; } protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; const target = this.getTauriTarget(this.buildArch, 'win32'); return `src-tauri/target/${target}/${basePath}/bundle/`; } protected hasArchSpecificTarget(): boolean { return true; } protected getArchSpecificPath(): string { const target = this.getTauriTarget(this.buildArch, 'win32'); return `src-tauri/target/${target}`; } } ================================================ FILE: bin/cli.ts ================================================ import log from 'loglevel'; 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 { PakeCliOptions } from './types'; const program = getCliProgram(); async function checkUpdateTips() { updateNotifier({ pkg: packageJson, updateCheckInterval: 1000 * 60 }).notify({ isGlobal: true, }); } program.action(async (url: string, options: PakeCliOptions) => { 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); }); program.parse(); ================================================ FILE: bin/defaults.ts ================================================ import { PakeCliOptions } from './types.js'; export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { icon: '', height: 780, width: 1200, fullscreen: false, maximize: false, resizable: true, hideTitleBar: false, alwaysOnTop: false, appVersion: '1.0.0', darkMode: false, disabledWebShortcuts: false, activationShortcut: '', userAgent: '', showSystemTray: false, multiArch: false, targets: (() => { switch (process.platform) { case 'linux': return 'deb,appimage'; case 'darwin': return 'dmg'; case 'win32': return 'msi'; default: return 'deb'; } })(), useLocalFile: false, systemTrayIcon: '', proxyUrl: '', debug: false, inject: [], installerLanguage: 'en-US', hideOnClose: undefined, // Platform-specific: true for macOS, false for others incognito: false, wasm: false, enableDragDrop: false, keepBinary: false, multiInstance: false, multiWindow: false, startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', iterativeBuild: false, zoom: 100, minWidth: 0, minHeight: 0, ignoreCertificateErrors: false, newWindow: false, install: false, 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, }; ================================================ FILE: bin/dev.ts ================================================ import log from 'loglevel'; import { PakeCliOptions } from './types'; import handleInputOptions from './options/index'; import BuilderProvider from './builders/BuilderProvider'; import { getCliProgram } from './helpers/cli-program'; const program = getCliProgram(); program.action(async (url: string, options: PakeCliOptions) => { log.setDefaultLevel('debug'); const appOptions = await handleInputOptions(options, url); log.debug('PakeAppOptions', appOptions); const builder = BuilderProvider.create(appOptions); await builder.prepare(); await builder.start(url); }); program.parse(); ================================================ FILE: bin/helpers/cli-program.ts ================================================ import chalk from 'chalk'; import { program, Option } from 'commander'; import packageJson from '../../package.json'; import { DEFAULT_PAKE_OPTIONS as DEFAULT, DEFAULT_PAKE_OPTIONS, } from '../defaults'; import { validateNumberInput, validateUrlInput } from '../utils/validate'; export function getCliProgram() { const { green, yellow } = chalk; const logo = `${chalk.green(' ____ _')} ${green('| _ \\ __ _| | _____')} ${green('| |_) / _` | |/ / _ \\')} ${green('| __/ (_| | < __/')} ${yellow('https://github.com/tw93/pake')} ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with Rust.')} `; return program .addHelpText('beforeAll', logo) .usage(`[url] [options]`) .showHelpAfterError() .argument('[url]', 'The web URL you want to package', validateUrlInput) .option('--name ', 'Application name') .addOption( new Option( '--identifier ', 'Application identifier / bundle ID', ).hideHelp(), ) .option('--icon ', 'Application icon', DEFAULT.icon) .option( '--width ', 'Window width', validateNumberInput, DEFAULT.width, ) .option( '--height ', 'Window height', validateNumberInput, DEFAULT.height, ) .option( '--use-local-file', 'Use local file packaging', DEFAULT.useLocalFile, ) .option('--fullscreen', 'Start in full screen', DEFAULT.fullscreen) .option('--hide-title-bar', 'For Mac, hide title bar', DEFAULT.hideTitleBar) .option('--multi-arch', 'For Mac, both Intel and M1', DEFAULT.multiArch) .option( '--inject ', 'Inject local CSS/JS files into the page', (val, previous) => { if (!val) return DEFAULT.inject; // Split by comma and trim whitespace, filter out empty strings const files = val .split(',') .map((item) => item.trim()) .filter((item) => item.length > 0); // If previous values exist (from multiple --inject options), merge them return previous ? [...previous, ...files] : files; }, DEFAULT.inject, ) .option('--debug', 'Debug build and more output', DEFAULT.debug) .addOption( new Option( '--proxy-url ', 'Proxy URL for all network requests (http://, https://, socks5://)', ) .default(DEFAULT_PAKE_OPTIONS.proxyUrl) .hideHelp(), ) .addOption( new Option('--user-agent ', 'Custom user agent') .default(DEFAULT.userAgent) .hideHelp(), ) .addOption( new Option( '--targets ', 'Build target format for your system', ).default(DEFAULT.targets), ) .addOption( new Option( '--app-version ', 'App version, the same as package.json version', ) .default(DEFAULT.appVersion) .hideHelp(), ) .addOption( new Option('--always-on-top', 'Always on the top level') .default(DEFAULT.alwaysOnTop) .hideHelp(), ) .addOption( new Option('--maximize', 'Start window maximized') .default(DEFAULT.maximize) .hideHelp(), ) .addOption( new Option('--dark-mode', 'Force Mac app to use dark mode') .default(DEFAULT.darkMode) .hideHelp(), ) .addOption( new Option('--disabled-web-shortcuts', 'Disabled webPage shortcuts') .default(DEFAULT.disabledWebShortcuts) .hideHelp(), ) .addOption( new Option('--activation-shortcut ', 'Shortcut key to active App') .default(DEFAULT_PAKE_OPTIONS.activationShortcut) .hideHelp(), ) .addOption( new Option('--show-system-tray', 'Show system tray in app') .default(DEFAULT.showSystemTray) .hideHelp(), ) .addOption( new Option('--system-tray-icon ', 'Custom system tray icon') .default(DEFAULT.systemTrayIcon) .hideHelp(), ) .addOption( new Option( '--hide-on-close [boolean]', 'Hide window on close instead of exiting (default: true for macOS, false for others)', ) .default(DEFAULT.hideOnClose) .argParser((value) => { if (value === undefined) return true; // --hide-on-close without value if (value === 'true') return true; if (value === 'false') return false; throw new Error('--hide-on-close must be true or false'); }) .hideHelp(), ) .addOption(new Option('--title ', 'Window title').hideHelp()) .addOption( new Option('--incognito', 'Launch app in incognito/private mode') .default(DEFAULT.incognito) .hideHelp(), ) .addOption( new Option('--wasm', 'Enable WebAssembly support (Flutter Web, etc.)') .default(DEFAULT.wasm) .hideHelp(), ) .addOption( new Option('--enable-drag-drop', 'Enable drag and drop functionality') .default(DEFAULT.enableDragDrop) .hideHelp(), ) .addOption( new Option('--keep-binary', 'Keep raw binary file alongside installer') .default(DEFAULT.keepBinary) .hideHelp(), ) .addOption( new Option('--multi-instance', 'Allow multiple app instances') .default(DEFAULT.multiInstance) .hideHelp(), ) .addOption( new Option( '--multi-window', 'Allow opening multiple windows within one app instance', ) .default(DEFAULT.multiWindow) .hideHelp(), ) .addOption( new Option('--start-to-tray', 'Start app minimized to tray') .default(DEFAULT.startToTray) .hideHelp(), ) .addOption( new Option( '--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers', ) .default(DEFAULT.forceInternalNavigation) .hideHelp(), ) .addOption( new Option( '--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal', ) .default(DEFAULT.internalUrlRegex) .hideHelp(), ) .addOption( new Option('--installer-language ', 'Installer language') .default(DEFAULT.installerLanguage) .hideHelp(), ) .addOption( 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) { throw new Error('--zoom must be a number between 50 and 200'); } return zoom; }) .hideHelp(), ) .addOption( new Option('--min-width ', 'Minimum window width') .default(DEFAULT.minWidth) .argParser(validateNumberInput) .hideHelp(), ) .addOption( new Option('--min-height ', 'Minimum window height') .default(DEFAULT.minHeight) .argParser(validateNumberInput) .hideHelp(), ) .addOption( new Option( '--ignore-certificate-errors', 'Ignore certificate errors (for self-signed certificates)', ) .default(DEFAULT.ignoreCertificateErrors) .hideHelp(), ) .addOption( new Option( '--iterative-build', 'Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging', ) .default(DEFAULT.iterativeBuild) .hideHelp(), ) .addOption( new Option( '--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)', ) .default(DEFAULT.newWindow) .hideHelp(), ) .option( '--install', 'Auto-install app to /Applications (macOS) after build and remove local bundle', DEFAULT.install, ) .addOption( new Option('--camera', 'Request camera permission on macOS') .default(DEFAULT.camera) .hideHelp(), ) .addOption( new Option('--microphone', 'Request microphone permission on macOS') .default(DEFAULT.microphone) .hideHelp(), ) .version(packageJson.version, '-v, --version') .configureHelp({ sortSubcommands: true, 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; }, }); } ================================================ FILE: bin/helpers/merge.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; import combineFiles from '@/utils/combine'; import logger from '@/options/logger'; import { generateSafeFilename, generateIdentifierSafeName, getSafeAppName, generateLinuxPackageName, } from '@/utils/name'; import { PakeAppOptions, PlatformMap, WindowConfig } from '@/types'; import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; export async function mergeConfig( url: string, options: PakeAppOptions, tauriConf: any, ) { // Ensure .pake directory exists and copy source templates if needed const srcTauriDir = path.join(npmDirectory, 'src-tauri'); await fsExtra.ensureDir(tauriConfigDirectory); // Copy source config files to .pake directory (as templates) const sourceFiles = [ 'tauri.conf.json', 'tauri.macos.conf.json', 'tauri.windows.conf.json', 'tauri.linux.conf.json', 'pake.json', ]; await Promise.all( sourceFiles.map(async (file) => { const sourcePath = path.join(srcTauriDir, file); const destPath = path.join(tauriConfigDirectory, file); if ( (await fsExtra.pathExists(sourcePath)) && !(await fsExtra.pathExists(destPath)) ) { await fsExtra.copy(sourcePath, destPath); } }), ); const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, systemTrayIcon, useLocalFile, identifier, name = 'pake-app', resizable = true, inject, proxyUrl, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, multiInstance, multiWindow, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, camera, microphone, } = options; const { platform } = process; 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: incognito, title: 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, }; Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; tauriConf.version = appVersion; // Always set mainBinaryName to ensure binary uniqueness const linuxBinaryName = `pake-${generateLinuxPackageName(name)}`; tauriConf.mainBinaryName = platform === 'linux' ? linuxBinaryName : `pake-${generateIdentifierSafeName(name)}`; if (platform == 'win32') { tauriConf.bundle.windows.wix.language[0] = installerLanguage; } const pathExists = await fsExtra.pathExists(url); if (pathExists) { logger.warn('✼ Your input might be a local file.'); tauriConf.pake.windows[0].url_type = 'local'; const fileName = path.basename(url); const dirName = path.dirname(url); const distDir = path.join(npmDirectory, 'dist'); const distBakDir = path.join(npmDirectory, 'dist_bak'); if (!useLocalFile) { const urlPath = path.join(distDir, fileName); await fsExtra.copy(url, urlPath); } else { fsExtra.moveSync(distDir, distBakDir, { overwrite: true }); fsExtra.copySync(dirName, distDir, { overwrite: true }); // ignore it, because about_pake.html have be erased. // const filesToCopyBack = ['cli.js', 'about_pake.html']; const filesToCopyBack = ['cli.js']; await Promise.all( filesToCopyBack.map((file) => fsExtra.copy(path.join(distBakDir, file), path.join(distDir, file)), ), ); } tauriConf.pake.windows[0].url = fileName; tauriConf.pake.windows[0].url_type = 'local'; } else { tauriConf.pake.windows[0].url_type = 'web'; } const platformMap: PlatformMap = { win32: 'windows', linux: 'linux', darwin: 'macos', }; const currentPlatform = platformMap[platform]; if (userAgent.length > 0) { tauriConf.pake.user_agent[currentPlatform] = userAgent; } tauriConf.pake.system_tray[currentPlatform] = showSystemTray; // Processing targets are currently only open to Linux. if (platform === 'linux') { // Remove hardcoded desktop files and regenerate with correct app name delete tauriConf.bundle.linux.deb.files; // Generate correct desktop file configuration const linuxName = generateLinuxPackageName(name); const desktopFileName = `com.pake.${linuxName}.desktop`; const iconName = `${linuxName}_512`; // Create desktop file content // Determine if title contains Chinese characters for Name[zh_CN] const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application Name=${name} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} Comment=${name} Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; StartupNotify=true Terminal=false `; // Write desktop file to src-tauri/assets directory where Tauri expects it const srcAssetsDir = path.join(npmDirectory, 'src-tauri/assets'); const srcDesktopFilePath = path.join(srcAssetsDir, desktopFileName); await fsExtra.ensureDir(srcAssetsDir); await fsExtra.writeFile(srcDesktopFilePath, desktopContent); // Set up desktop file in bundle configuration // Use absolute path from src-tauri directory to assets const desktopInstallPath = `/usr/share/applications/${desktopFileName}`; tauriConf.bundle.linux.deb.files = { [desktopInstallPath]: `assets/${desktopFileName}`, }; // Add desktop file support for RPM if (!tauriConf.bundle.linux.rpm) { tauriConf.bundle.linux.rpm = {}; } tauriConf.bundle.linux.rpm.files = { [desktopInstallPath]: `assets/${desktopFileName}`, }; const validTargets = [ 'deb', 'appimage', 'rpm', 'deb-arm64', 'appimage-arm64', 'rpm-arm64', ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { tauriConf.bundle.targets = [baseTarget]; } else { logger.warn( `✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`, ); } } // Set macOS bundle targets (for app vs dmg) if (platform === 'darwin') { const validMacTargets = ['app', 'dmg']; if (validMacTargets.includes(options.targets)) { tauriConf.bundle.targets = [options.targets]; } } // Set icon. const safeAppName = getSafeAppName(name); const platformIconMap: PlatformMap = { win32: { fileExt: '.ico', path: `png/${safeAppName}_256.ico`, defaultIcon: 'png/icon_256.ico', message: 'Windows icon must be .ico and 256x256px.', }, linux: { fileExt: '.png', path: `png/${generateLinuxPackageName(name)}_512.png`, defaultIcon: 'png/icon_512.png', message: 'Linux icon must be .png and 512x512px.', }, darwin: { fileExt: '.icns', path: `icons/${safeAppName}.icns`, defaultIcon: 'icons/icon.icns', message: 'macOS icon must be .icns type.', }, }; const iconInfo = platformIconMap[platform]; const resolvedIconPath = options.icon ? path.resolve(options.icon) : null; const exists = resolvedIconPath && (await fsExtra.pathExists(resolvedIconPath)); if (exists) { let updateIconPath = true; let customIconExt = path.extname(resolvedIconPath).toLowerCase(); if (customIconExt !== iconInfo.fileExt) { updateIconPath = false; logger.warn(`✼ ${iconInfo.message}, but you give ${customIconExt}`); tauriConf.bundle.icon = [iconInfo.defaultIcon]; } else { const iconPath = path.join(npmDirectory, 'src-tauri/', iconInfo.path); tauriConf.bundle.resources = [iconInfo.path]; // Avoid copying if source and destination are the same const absoluteDestPath = path.resolve(iconPath); if (resolvedIconPath !== absoluteDestPath) { await fsExtra.copy(resolvedIconPath, iconPath); } } if (updateIconPath) { tauriConf.bundle.icon = [iconInfo.path]; } else { logger.warn(`✼ Icon will remain as default.`); } } else { logger.warn( '✼ Custom icon path may be invalid, default icon will be used instead.', ); tauriConf.bundle.icon = [iconInfo.defaultIcon]; } // Set tray icon path. let trayIconPath = platform === 'darwin' ? 'png/icon_512.png' : tauriConf.bundle.icon[0]; if (systemTrayIcon.length > 0) { try { await fsExtra.pathExists(systemTrayIcon); // 需要判断图标格式,默认只支持ico和png两种 let iconExt = path.extname(systemTrayIcon).toLowerCase(); if (iconExt == '.png' || iconExt == '.ico') { const trayIcoPath = path.join( npmDirectory, `src-tauri/png/${safeAppName}${iconExt}`, ); trayIconPath = `png/${safeAppName}${iconExt}`; await fsExtra.copy(systemTrayIcon, trayIcoPath); } else { logger.warn( `✼ System tray icon must be .ico or .png, but you provided ${iconExt}.`, ); logger.warn(`✼ Default system tray icon will be used.`); } } catch { logger.warn(`✼ ${systemTrayIcon} not exists!`); logger.warn(`✼ Default system tray icon will remain unchanged.`); } } // Ensure trayIcon object exists before setting iconPath if (!tauriConf.app.trayIcon) { tauriConf.app.trayIcon = {}; } tauriConf.app.trayIcon.iconPath = trayIconPath; tauriConf.pake.system_tray_path = trayIconPath; delete tauriConf.app.trayIcon; const injectFilePath = path.join( npmDirectory, `src-tauri/src/inject/custom.js`, ); // inject js or css files if (inject?.length > 0) { // Ensure inject is an array before calling .every() const injectArray = Array.isArray(inject) ? inject : [inject]; if ( !injectArray.every( (item) => item.endsWith('.css') || item.endsWith('.js'), ) ) { logger.error('The injected file must be in either CSS or JS format.'); return; } const files = injectArray.map((filepath) => path.isAbsolute(filepath) ? filepath : path.join(process.cwd(), filepath), ); tauriConf.pake.inject = files; await combineFiles(files, injectFilePath); } else { tauriConf.pake.inject = []; await fsExtra.writeFile(injectFilePath, ''); } tauriConf.pake.proxy_url = proxyUrl || ''; tauriConf.pake.multi_instance = multiInstance; tauriConf.pake.multi_window = multiWindow; // Configure WASM support with required HTTP headers if (wasm) { tauriConf.app.security = { headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }; } // Write entitlements dynamically on macOS so camera/microphone are opt-in if (platform === 'darwin') { const entitlementEntries: string[] = []; if (camera) { entitlementEntries.push( ' com.apple.security.device.camera\n ', ); } if (microphone) { entitlementEntries.push( ' com.apple.security.device.audio-input\n ', ); } const entitlementsContent = ` ${entitlementEntries.join('\n')} `; const entitlementsPath = path.join( npmDirectory, 'src-tauri', 'entitlements.plist', ); await fsExtra.writeFile(entitlementsPath, entitlementsContent); } // Save config file. const platformConfigPaths: PlatformMap = { win32: 'tauri.windows.conf.json', darwin: 'tauri.macos.conf.json', linux: 'tauri.linux.conf.json', }; const configPath = path.join( tauriConfigDirectory, platformConfigPaths[platform], ); const bundleConf = { bundle: tauriConf.bundle }; await fsExtra.outputJSON(configPath, bundleConf, { spaces: 4 }); const pakeConfigPath = path.join(tauriConfigDirectory, 'pake.json'); await fsExtra.outputJSON(pakeConfigPath, tauriConf.pake, { spaces: 4 }); let tauriConf2 = JSON.parse(JSON.stringify(tauriConf)); delete tauriConf2.pake; // delete tauriConf2.bundle; if (process.env.NODE_ENV === 'development') { tauriConf2.bundle = bundleConf.bundle; } const configJsonPath = path.join(tauriConfigDirectory, 'tauri.conf.json'); await fsExtra.outputJSON(configJsonPath, tauriConf2, { spaces: 4 }); } ================================================ FILE: bin/helpers/rust.ts ================================================ import os from 'os'; import path from 'path'; import fsExtra from 'fs-extra'; import chalk from 'chalk'; import { execaSync } from 'execa'; import { getSpinner } from '@/utils/info'; 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); return IS_WIN ? normalized.toLowerCase() : normalized; } function getCargoHomeCandidates(): string[] { const candidates = new Set(); if (process.env.CARGO_HOME) { candidates.add(process.env.CARGO_HOME); } const homeDir = os.homedir(); if (homeDir) { candidates.add(path.join(homeDir, '.cargo')); } if (IS_WIN && process.env.USERPROFILE) { candidates.add(path.join(process.env.USERPROFILE, '.cargo')); } return Array.from(candidates).filter(Boolean); } function ensureCargoBinOnPath() { const currentPath = process.env.PATH || ''; const segments = currentPath.split(path.delimiter).filter(Boolean); const normalizedSegments = new Set( segments.map((segment) => normalizePathForComparison(segment)), ); const additions: string[] = []; let cargoHomeSet = Boolean(process.env.CARGO_HOME); for (const cargoHome of getCargoHomeCandidates()) { const binDir = path.join(cargoHome, 'bin'); if ( fsExtra.pathExistsSync(binDir) && !normalizedSegments.has(normalizePathForComparison(binDir)) ) { additions.push(binDir); normalizedSegments.add(normalizePathForComparison(binDir)); } if (!cargoHomeSet && fsExtra.pathExistsSync(cargoHome)) { process.env.CARGO_HOME = cargoHome; cargoHomeSet = true; } } if (additions.length) { const prefix = additions.join(path.delimiter); process.env.PATH = segments.length ? `${prefix}${path.delimiter}${segments.join(path.delimiter)}` : prefix; } } export function ensureRustEnv() { ensureCargoBinOnPath(); } 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 rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { await shellExec( IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, 300000, undefined, ); spinner.succeed(chalk.green('✔ Rust installed successfully!')); ensureRustEnv(); } catch (error) { spinner.fail(chalk.red('✕ Rust installation failed!')); if (error instanceof Error) { console.error(error.message); } else { console.error(error); } process.exit(1); } } export function checkRustInstalled() { ensureCargoBinOnPath(); try { execaSync('rustc', ['--version']); return true; } catch { return false; } } ================================================ FILE: bin/helpers/tauriConfig.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; import { npmDirectory } from '@/utils/dir'; // Load configs from npm package directory, not from project source const tauriSrcDir = path.join(npmDirectory, 'src-tauri'); const pakeConf = fsExtra.readJSONSync(path.join(tauriSrcDir, 'pake.json')); const CommonConf = fsExtra.readJSONSync( path.join(tauriSrcDir, 'tauri.conf.json'), ); const WinConf = fsExtra.readJSONSync( path.join(tauriSrcDir, 'tauri.windows.conf.json'), ); const MacConf = fsExtra.readJSONSync( path.join(tauriSrcDir, 'tauri.macos.conf.json'), ); const LinuxConf = fsExtra.readJSONSync( path.join(tauriSrcDir, 'tauri.linux.conf.json'), ); const platformConfigs = { win32: WinConf, darwin: MacConf, linux: LinuxConf, }; const { platform } = process; // @ts-ignore const platformConfig = platformConfigs[platform]; let tauriConfig = { ...CommonConf, bundle: platformConfig.bundle, app: { ...CommonConf.app, trayIcon: { ...(platformConfig?.app?.trayIcon ?? {}), }, }, build: CommonConf.build, pake: pakeConf, }; export default tauriConfig; ================================================ FILE: bin/options/icon.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; import chalk from 'chalk'; import { dir } from 'tmp-promise'; import { fileTypeFromBuffer } from 'file-type'; import icongen from 'icon-gen'; import sharp from 'sharp'; import logger from './logger'; import { getSpinner } from '@/utils/info'; import { npmDirectory } from '@/utils/dir'; import { IS_LINUX, IS_WIN, IS_MAC } from '@/utils/platform'; import { PakeAppOptions } from '@/types'; import { writeIcoWithPreferredSize } from '@/utils/ico'; type PlatformIconConfig = { format: string; sizes?: number[]; size?: number; }; const ICON_CONFIG = { minFileSize: 100, supportedFormats: ['png', 'ico', 'jpeg', 'jpg', 'webp', 'icns'] as const, whiteBackground: { r: 255, g: 255, b: 255 }, transparentBackground: { r: 255, g: 255, b: 255, alpha: 0 }, downloadTimeout: { ci: 5000, default: 15000, }, } as const; const PLATFORM_CONFIG: Record<'win' | 'linux' | 'macos', PlatformIconConfig> = { win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] }, linux: { format: '.png', size: 512 }, macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] }, }; const API_KEYS = { logoDev: ['pk_JLLMUKGZRpaG5YclhXaTkg', 'pk_Ph745P8mQSeYFfW2Wk039A'], brandfetch: ['1idqvJC0CeFSeyp3Yf7', '1idej-yhU_ThggIHFyG'], }; /** * Generates platform-specific icon paths and handles copying for Windows */ import { generateLinuxPackageName, generateSafeFilename } from '@/utils/name'; function generateIconPath(appName: string, isDefault = false): string { const safeName = isDefault ? 'icon' : getIconBaseName(appName); const baseName = safeName; if (IS_WIN) { return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_256.ico`); } if (IS_LINUX) { return path.join(npmDirectory, 'src-tauri', 'png', `${baseName}_512.png`); } return path.join(npmDirectory, 'src-tauri', 'icons', `${baseName}.icns`); } function getIconBaseName(appName: string): string { const baseName = IS_LINUX ? generateLinuxPackageName(appName) : generateSafeFilename(appName).toLowerCase(); return baseName || 'pake-app'; } async function copyWindowsIconIfNeeded( convertedPath: string, appName: string, ): Promise { if (!IS_WIN || !convertedPath.endsWith('.ico')) { return convertedPath; } 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); } return finalIconPath; } catch (error) { logger.warn( `Failed to copy Windows icon: ${error instanceof Error ? error.message : 'Unknown error'}`, ); return convertedPath; } } /** * Adds white background to transparent icons only */ async function preprocessIcon(inputPath: string): Promise { try { const metadata = await sharp(inputPath).metadata(); if (metadata.channels !== 4) return inputPath; // No transparency const { path: tempDir } = await dir(); const outputPath = path.join(tempDir, 'icon-with-background.png'); await sharp({ create: { width: metadata.width || 512, height: metadata.height || 512, channels: 4, background: { ...ICON_CONFIG.whiteBackground, alpha: 1 }, }, }) .composite([{ input: inputPath }]) .png() .toFile(outputPath); return outputPath; } catch (error) { if (error instanceof Error) { logger.warn(`Failed to add background to icon: ${error.message}`); } return inputPath; } } /** * Applies macOS squircle mask to icon */ async function applyMacOSMask(inputPath: string): Promise { try { const { path: tempDir } = await dir(); const outputPath = path.join(tempDir, 'icon-macos-rounded.png'); // 1. Create a 1024x1024 rounded rect mask // rx="224" is closer to the smooth Apple squircle look for 1024px const mask = Buffer.from( '', ); // 2. Load input, resize to 1024, apply mask const maskedBuffer = await sharp(inputPath) .resize(1024, 1024, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .composite([ { input: mask, blend: 'dest-in', }, ]) .png() .toBuffer(); // 3. Resize to 840x840 (~18% padding) to solve "too big" visual issue // Native MacOS icons often leave some breathing room await sharp(maskedBuffer) .resize(840, 840, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .extend({ top: 92, bottom: 92, left: 92, right: 92, background: { r: 0, g: 0, b: 0, alpha: 0 }, }) .toFile(outputPath); return outputPath; } catch (error) { if (error instanceof Error) { logger.warn(`Failed to apply macOS mask: ${error.message}`); } return inputPath; } } /** * Converts icon to platform-specific format */ async function convertIconFormat( inputPath: string, appName: string, ): Promise { try { if (!(await fsExtra.pathExists(inputPath))) return null; const { path: outputDir } = await dir(); const platformOutputDir = path.join(outputDir, 'converted-icons'); await fsExtra.ensureDir(platformOutputDir); const processedInputPath = await preprocessIcon(inputPath); const iconName = getIconBaseName(appName); // Generate platform-specific format if (IS_WIN) { // Support multiple sizes for better Windows compatibility await icongen(processedInputPath, platformOutputDir, { report: false, ico: { name: `${iconName}_256`, sizes: PLATFORM_CONFIG.win.sizes, }, }); return path.join( platformOutputDir, `${iconName}_256${PLATFORM_CONFIG.win.format}`, ); } if (IS_LINUX) { const outputPath = path.join( platformOutputDir, `${iconName}_${PLATFORM_CONFIG.linux.size}${PLATFORM_CONFIG.linux.format}`, ); // Ensure we convert to proper PNG format with correct size await sharp(processedInputPath) .resize(PLATFORM_CONFIG.linux.size, PLATFORM_CONFIG.linux.size, { fit: 'contain', background: ICON_CONFIG.transparentBackground, }) .ensureAlpha() .png() .toFile(outputPath); return outputPath; } // macOS const macIconPath = await applyMacOSMask(processedInputPath); await icongen(macIconPath, platformOutputDir, { report: false, icns: { name: iconName, sizes: PLATFORM_CONFIG.macos.sizes }, }); const outputPath = path.join( platformOutputDir, `${iconName}${PLATFORM_CONFIG.macos.format}`, ); return (await fsExtra.pathExists(outputPath)) ? outputPath : null; } catch (error) { if (error instanceof Error) { logger.warn(`Icon format conversion failed: ${error.message}`); } return null; } } /** * Processes downloaded or local icon for platform-specific format */ async function processIcon( iconPath: string, appName: string, ): Promise { if (!iconPath || !appName) return iconPath; // Check if already in correct platform format const ext = path.extname(iconPath).toLowerCase(); const isCorrectFormat = (IS_WIN && ext === '.ico') || (IS_LINUX && ext === '.png') || (!IS_WIN && !IS_LINUX && ext === '.icns'); if (isCorrectFormat) { return await copyWindowsIconIfNeeded(iconPath, appName); } // Convert to platform format const convertedPath = await convertIconFormat(iconPath, appName); if (convertedPath) { return await copyWindowsIconIfNeeded(convertedPath, appName); } return iconPath; } /** * Gets default icon with platform-specific fallback logic */ async function getDefaultIcon(): Promise { logger.info('✼ No icon provided, using default icon.'); if (IS_WIN) { const defaultIcoPath = generateIconPath('icon', true); const defaultPngPath = path.join( npmDirectory, 'src-tauri/png/icon_512.png', ); // Try default ico first if (await fsExtra.pathExists(defaultIcoPath)) { return defaultIcoPath; } // Convert from png if ico doesn't exist if (await fsExtra.pathExists(defaultPngPath)) { logger.info('✼ Default ico not found, converting from png...'); try { const convertedPath = await convertIconFormat(defaultPngPath, 'icon'); if (convertedPath && (await fsExtra.pathExists(convertedPath))) { return await copyWindowsIconIfNeeded(convertedPath, 'icon'); } } catch (error) { logger.warn( `Failed to convert default png to ico: ${error instanceof Error ? error.message : 'Unknown error'}`, ); } } // Fallback to png or empty if (await fsExtra.pathExists(defaultPngPath)) { logger.warn('✼ Using png as fallback for Windows (may cause issues).'); return defaultPngPath; } logger.warn('✼ No default icon found, will use pake default.'); return ''; } // Linux and macOS defaults const iconPath = IS_LINUX ? 'src-tauri/png/icon_512.png' : 'src-tauri/icons/icon.icns'; return path.join(npmDirectory, iconPath); } /** * Main icon handling function with simplified logic flow */ export async function handleIcon( options: PakeAppOptions, url?: string, ): Promise { // Handle custom icon (local file or remote URL) if (options.icon) { if (options.icon.startsWith('http')) { const downloadedPath = await downloadIcon(options.icon); if (downloadedPath) { const result = await processIcon(downloadedPath, options.name || ''); if (result) return result; } return ''; } // Local file path const resolvedPath = path.resolve(options.icon); const result = await processIcon(resolvedPath, options.name || ''); return result || resolvedPath; } // Check for existing local icon before downloading if (options.name) { const localIconPath = generateIconPath(options.name); if (await fsExtra.pathExists(localIconPath)) { logger.info(`✼ Using existing local icon: ${localIconPath}`); return localIconPath; } } // Try favicon from website if (url && options.name) { const faviconPath = await tryGetFavicon(url, options.name); if (faviconPath) return faviconPath; } // Use default icon return await getDefaultIcon(); } /** * Generates icon service URLs for a domain */ function generateIconServiceUrls(domain: string): string[] { const logoDevUrls = API_KEYS.logoDev .sort(() => Math.random() - 0.5) .map( (token) => `https://img.logo.dev/${domain}?token=${token}&format=png&size=256`, ); const brandfetchUrls = API_KEYS.brandfetch .sort(() => Math.random() - 0.5) .map((key) => `https://cdn.brandfetch.io/${domain}/w/400/h/400?c=${key}`); return [ ...logoDevUrls, ...brandfetchUrls, `https://logo.clearbit.com/${domain}?size=256`, `https://www.google.com/s2/favicons?domain=${domain}&sz=256`, `https://favicon.is/${domain}`, `https://${domain}/favicon.ico`, `https://www.${domain}/favicon.ico`, ]; } /** * Attempts to fetch favicon from website */ async function tryGetFavicon( url: string, appName: string, ): Promise { try { const domain = new URL(url).hostname; const spinner = getSpinner(`Fetching icon from ${domain}...`); const serviceUrls = generateIconServiceUrls(domain); const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; const downloadTimeout = isCI ? ICON_CONFIG.downloadTimeout.ci : ICON_CONFIG.downloadTimeout.default; for (const serviceUrl of serviceUrls) { try { const faviconPath = await downloadIcon( serviceUrl, false, downloadTimeout, ); if (!faviconPath) continue; const convertedPath = await convertIconFormat(faviconPath, appName); if (convertedPath) { const finalPath = await copyWindowsIconIfNeeded( convertedPath, appName, ); spinner.succeed( chalk.green('Icon fetched and converted successfully!'), ); return finalPath; } } catch (error: unknown) { if (error instanceof Error) { logger.debug(`Icon service ${serviceUrl} failed: ${error.message}`); } continue; } } spinner.warn(`No favicon found for ${domain}. Using default.`); return null; } catch (error) { if (error instanceof Error) { logger.warn(`Failed to fetch favicon: ${error.message}`); } return null; } } /** * Downloads icon from URL */ export async function downloadIcon( iconUrl: string, showSpinner = true, customTimeout?: number, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, customTimeout || 10000); try { const response = await fetch(iconUrl, { signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { if (response.status === 404 && !showSpinner) { return null; } throw new Error(`HTTP ${response.status} ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); if (!arrayBuffer || arrayBuffer.byteLength < ICON_CONFIG.minFileSize) return null; const fileDetails = await fileTypeFromBuffer(arrayBuffer); if ( !fileDetails || !ICON_CONFIG.supportedFormats.includes(fileDetails.ext as any) ) { return null; } return await saveIconFile(arrayBuffer, fileDetails.ext); } catch (error: unknown) { clearTimeout(timeoutId); if (showSpinner) { if (error instanceof Error && error.name === 'AbortError') { logger.error('Icon download timed out!'); } else { logger.error( 'Icon download failed!', error instanceof Error ? error.message : String(error), ); } } return null; } } /** * Saves icon file to temporary location */ async function saveIconFile( iconData: ArrayBuffer, extension: string, ): Promise { const buffer = Buffer.from(iconData); const { path: tempPath } = await dir(); // Always save with the original extension first const originalIconPath = path.join(tempPath, `icon.${extension}`); await fsExtra.outputFile(originalIconPath, buffer); return originalIconPath; } ================================================ FILE: bin/options/index.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; import logger from '@/options/logger'; import { handleIcon } from './icon'; import { getDomain } from '@/utils/url'; import { promptText, capitalizeFirstLetter, resolveIdentifier, } from '@/utils/info'; import { generateLinuxPackageName } from '@/utils/name'; import { PakeAppOptions, PakeCliOptions, PlatformMap } from '@/types'; function resolveAppName(name: string, platform: NodeJS.Platform): string { const domain = getDomain(name) || 'pake'; return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain; } function resolveLocalAppName( filePath: string, platform: NodeJS.Platform, ): string { const baseName = path.parse(filePath).name || 'pake-app'; if (platform === 'linux') { return generateLinuxPackageName(baseName) || 'pake-app'; } const normalized = baseName .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 { const platformRegexMapping: PlatformMap = { linux: /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/, default: /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/, }; const reg = platformRegexMapping[platform] || platformRegexMapping.default; return !!name && reg.test(name); } export default async function handleOptions( options: PakeCliOptions, url: string, ): Promise { const { platform } = process; const isActions = process.env.GITHUB_ACTIONS; let name = options.name; const pathExists = await fsExtra.pathExists(url); if (!options.name) { const defaultName = pathExists ? resolveLocalAppName(url, platform) : resolveAppName(url, platform); const promptMessage = 'Enter your application name'; const namePrompt = await promptText(promptMessage, defaultName); name = namePrompt?.trim() || defaultName; } if (name && platform === 'linux') { name = generateLinuxPackageName(name); } 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 errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; logger.error(errorMsg); if (isActions) { name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); } else { process.exit(1); } } const resolvedName = name || 'pake-app'; const appOptions: PakeAppOptions = { ...options, name: resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; const iconPath = await handleIcon(appOptions, url); appOptions.icon = iconPath || ''; return appOptions; } ================================================ FILE: bin/options/logger.ts ================================================ import chalk from 'chalk'; import log from 'loglevel'; const logger = { info(...msg: any[]) { log.info(...msg.map((m) => chalk.white(m))); }, debug(...msg: any[]) { log.debug(...msg); }, error(...msg: any[]) { log.error(...msg.map((m) => chalk.red(m))); }, warn(...msg: any[]) { log.info(...msg.map((m) => chalk.yellow(m))); }, success(...msg: any[]) { log.info(...msg.map((m) => chalk.green(m))); }, }; export default logger; ================================================ FILE: bin/types.ts ================================================ export interface PlatformMap { [key: string]: any; } export interface PakeCliOptions { // Application name name?: string; // Explicit app identifier / bundle id identifier?: string; // Window title (supports Chinese characters) title?: string; // Application icon icon: string; // Application window width, default 1200px width: number; // Application window height, default 780px height: number; // Whether the window is resizable, default true resizable: boolean; // Whether the window can be fullscreen, default false fullscreen: boolean; // Start window maximized, default false maximize: boolean; // Enable immersive header, default false. hideTitleBar: boolean; // Enable windows always on top, default false alwaysOnTop: boolean; // App version, the same as package.json version, default 1.0.0 appVersion: string; // Force Mac to use dark mode, default false darkMode: boolean; // Disable web shortcuts, default false disabledWebShortcuts: boolean; // Set a shortcut key to wake up the app, default empty activationShortcut: string; // Custom User-Agent, default off userAgent: string; // Enable system tray, default off for macOS, on for Windows and Linux showSystemTray: boolean; // Tray icon, default same as app icon for Windows and Linux, macOS requires separate png or ico systemTrayIcon: string; // Recursive copy, when url is a local file path, if this option is enabled, the url path file and all its subFiles will be copied to the pake static file folder, default off useLocalFile: false; // Multi arch, supports both Intel and M1 chips, only for Mac multiArch: boolean; // Build target architecture/format: // Linux: "deb", "appimage", "deb-arm64", "appimage-arm64"; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal" targets: string; // Debug mode, outputs more logs debug: boolean; /** External scripts that need to be injected into the page. */ inject: string[]; // Set Api Proxy proxyUrl: string; // Installer language, valid for Windows users, default is en-US installerLanguage: string; // Hide window on close instead of exiting, platform-specific: true for macOS, false for others hideOnClose: boolean | undefined; // Launch app in incognito/private mode, default false incognito: boolean; // Enable WebAssembly support (Flutter Web, etc.), default false wasm: boolean; // Enable drag and drop functionality, default false enableDragDrop: boolean; // Keep raw binary file alongside installer, default false keepBinary: boolean; // Allow multiple instances, default false (single instance) multiInstance: boolean; // Allow opening multiple windows in one app instance, default false multiWindow: boolean; // Start app minimized to tray, default false startToTray: boolean; // Force navigation to stay inside the Pake window even for external links forceInternalNavigation: boolean; // Regex pattern to match URLs that should be considered internal internalUrlRegex: string; // Initial page zoom level (50-200), default 100 zoom: number; // Minimum window width, default 0 (no limit) minWidth: number; // Minimum window height, default 0 (no limit) minHeight: number; // Ignore certificate errors (for self-signed certs), default false ignoreCertificateErrors: boolean; // Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging iterativeBuild: boolean; // Allow sites to open new windows, default false newWindow: boolean; // Auto-install app to /Applications (macOS) after build, default false install: boolean; // Request camera entitlement on macOS, default false camera: boolean; // Request microphone entitlement on macOS, default false microphone: boolean; } export interface PakeAppOptions extends PakeCliOptions { identifier: string; } export interface PlatformSpecific { macos: T; linux: T; windows: T; } export interface WindowConfig { url: string; hide_title_bar: boolean; fullscreen: boolean; maximize: boolean; width: number; height: number; resizable: boolean; url_type: string; always_on_top: boolean; dark_mode: boolean; disabled_web_shortcuts: boolean; activation_shortcut: string; hide_on_close: boolean; incognito: boolean; title?: string; enable_wasm: boolean; enable_drag_drop: boolean; start_to_tray: boolean; force_internal_navigation: boolean; internal_url_regex: string; zoom: number; min_width: number; min_height: number; ignore_certificate_errors: boolean; new_window: boolean; } export interface PakeConfig { windows: WindowConfig[]; user_agent: PlatformSpecific; system_tray: PlatformSpecific; system_tray_path: string; proxy_url: string; multi_instance: boolean; multi_window: boolean; } ================================================ FILE: bin/utils/combine.ts ================================================ import fs from 'fs'; export default async function combineFiles(files: string[], output: string) { const contents = files.map((file) => { if (file.endsWith('.css')) { const fileContent = fs.readFileSync(file, 'utf-8'); return `window.addEventListener('DOMContentLoaded', (_event) => { const css = ${JSON.stringify(fileContent)}; const style = document.createElement('style'); style.innerHTML = css; document.head.appendChild(style); });`; } const fileContent = fs.readFileSync(file); return ( "window.addEventListener('DOMContentLoaded', (_event) => { " + fileContent + ' });' ); }); fs.writeFileSync(output, contents.join('\n')); return files; } ================================================ FILE: bin/utils/dir.ts ================================================ import path from 'path'; import { fileURLToPath } from 'url'; // Convert the current module URL to a file path const currentModulePath = fileURLToPath(import.meta.url); // Resolve the parent directory of the current module export const npmDirectory = path.join(path.dirname(currentModulePath), '..'); export const tauriConfigDirectory = path.join( npmDirectory, 'src-tauri', '.pake', ); ================================================ FILE: bin/utils/ico.ts ================================================ import path from 'path'; import fsExtra from 'fs-extra'; const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; const ICO_TYPE_ICON = 1; export type IcoEntry = { index: number; width: number; height: number; bitCount: number; bytesInRes: number; imageOffset: number; directory: Buffer; data: Buffer; }; function decodeDimension(value: number): number { return value === 0 ? 256 : value; } function compareByPreferredSize( preferredSize: number, ): (a: IcoEntry, b: IcoEntry) => number { return (a, b) => { const aSize = Math.max(a.width, a.height); const bSize = Math.max(b.width, b.height); const aExact = aSize === preferredSize ? 0 : 1; const bExact = bSize === preferredSize ? 0 : 1; if (aExact !== bExact) return aExact - bExact; const aDistance = Math.abs(aSize - preferredSize); const bDistance = Math.abs(bSize - preferredSize); if (aDistance !== bDistance) return aDistance - bDistance; const aSmaller = aSize < preferredSize ? 1 : 0; const bSmaller = bSize < preferredSize ? 1 : 0; if (aSmaller !== bSmaller) return aSmaller - bSmaller; if (a.bitCount !== b.bitCount) return b.bitCount - a.bitCount; if (aSize !== bSize) return bSize - aSize; return a.index - b.index; }; } export function parseIcoBuffer(buffer: Buffer): IcoEntry[] { if (buffer.length < ICO_HEADER_SIZE) { throw new Error('Invalid ICO: header too short.'); } const reserved = buffer.readUInt16LE(0); const type = buffer.readUInt16LE(2); const count = buffer.readUInt16LE(4); if (reserved !== 0 || type !== ICO_TYPE_ICON || count < 1) { throw new Error('Invalid ICO: invalid header.'); } const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE; if (buffer.length < tableSize) { throw new Error('Invalid ICO: directory table too short.'); } const entries: IcoEntry[] = []; for (let i = 0; i < count; i++) { const offset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE; const widthByte = buffer.readUInt8(offset); const heightByte = buffer.readUInt8(offset + 1); const bitCount = buffer.readUInt16LE(offset + 6); const bytesInRes = buffer.readUInt32LE(offset + 8); const imageOffset = buffer.readUInt32LE(offset + 12); if (bytesInRes < 1 || imageOffset + bytesInRes > buffer.length) { throw new Error('Invalid ICO: frame out of bounds.'); } entries.push({ index: i, width: decodeDimension(widthByte), height: decodeDimension(heightByte), bitCount, bytesInRes, imageOffset, directory: buffer.subarray(offset, offset + ICO_DIR_ENTRY_SIZE), data: buffer.subarray(imageOffset, imageOffset + bytesInRes), }); } return entries; } export function buildReorderedIcoBuffer( buffer: Buffer, preferredSize: number, ): Buffer { const entries = parseIcoBuffer(buffer); const ordered = [...entries].sort(compareByPreferredSize(preferredSize)); const count = ordered.length; const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE; const payloadSize = ordered.reduce( (acc, entry) => acc + entry.data.length, 0, ); const output = Buffer.alloc(tableSize + payloadSize); output.writeUInt16LE(0, 0); output.writeUInt16LE(ICO_TYPE_ICON, 2); output.writeUInt16LE(count, 4); let currentOffset = tableSize; for (let i = 0; i < count; i++) { const entry = ordered[i]; const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE; entry.directory.copy(output, entryOffset, 0, 8); output.writeUInt32LE(entry.data.length, entryOffset + 8); output.writeUInt32LE(currentOffset, entryOffset + 12); entry.data.copy(output, currentOffset); currentOffset += entry.data.length; } return output; } export async function writeIcoWithPreferredSize( sourcePath: string, outputPath: string, preferredSize: number, ): Promise { try { const sourceBuffer = await fsExtra.readFile(sourcePath); const reordered = buildReorderedIcoBuffer(sourceBuffer, preferredSize); await fsExtra.ensureDir(path.dirname(outputPath)); await fsExtra.outputFile(outputPath, reordered); return true; } catch { return false; } } ================================================ FILE: bin/utils/info.ts ================================================ import crypto from 'crypto'; import prompts from 'prompts'; import ora from 'ora'; import chalk from 'chalk'; // Generates a stable identifier based on the app URL (and optionally name). // When name is provided it is included in the hash so two apps wrapping // the same URL can coexist. Omitting name preserves backward compatibility // with identifiers generated before V3.10.1. export function getIdentifier(url: string, name?: string) { const hashInput = name ? `${url}::${name}` : url; const postFixHash = crypto .createHash('md5') .update(hashInput) .digest('hex') .substring(0, 6); return `com.pake.${postFixHash}`; } export function resolveIdentifier( url: string, explicitName: string | undefined, customIdentifier?: string, ) { const trimmedIdentifier = customIdentifier?.trim(); if (trimmedIdentifier) { return trimmedIdentifier; } return getIdentifier(url, explicitName); } export async function promptText( message: string, initial?: string, ): Promise { const response = await prompts({ type: 'text', name: 'content', message, initial, }); return response.content; } export function capitalizeFirstLetter(string: string) { return string.charAt(0).toUpperCase() + string.slice(1); } export function getSpinner(text: string) { const loadingType = { interval: 80, frames: ['✦', '✶', '✺', '✵', '✸', '✹', '✺'], }; return ora({ text: `${chalk.cyan(text)}\n`, spinner: loadingType, color: 'cyan', }).start(); } ================================================ FILE: bin/utils/ip.ts ================================================ 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 }; ================================================ FILE: bin/utils/name.ts ================================================ export function generateSafeFilename(name: string): string { return name .replace(/[<>:"/\\|?*]/g, '_') .replace(/\s+/g, '_') .replace(/\.+$/g, '') .slice(0, 255); } export function getSafeAppName(name: string): string { return generateSafeFilename(name).toLowerCase(); } export function generateLinuxPackageName(name: string): string { return name .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') .replace(/^-+|-+$/g, '') .replace(/-+/g, '-'); } export function generateIdentifierSafeName(name: string): string { const cleaned = name.replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, '').toLowerCase(); if (cleaned === '') { const fallback = Array.from(name) .map((char) => { const code = char.charCodeAt(0); if ( (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) ) { return char.toLowerCase(); } return code.toString(16); }) .join('') .slice(0, 50); return fallback || 'pake-app'; } 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); } ================================================ FILE: bin/utils/platform.ts ================================================ const { platform } = process; export const IS_MAC = platform === 'darwin'; export const IS_WIN = platform === 'win32'; export const IS_LINUX = platform === 'linux'; ================================================ FILE: bin/utils/shell.ts ================================================ import { execa } from 'execa'; import { npmDirectory } from './dir'; export async function shellExec( command: string, timeout: number = 300000, env?: Record, ) { try { const { exitCode } = await execa(command, { cwd: npmDirectory, // Use 'inherit' to show all output directly to user in real-time. // This ensures linuxdeploy and other tool outputs are visible during builds. stdio: 'inherit', shell: true, timeout, env: env ? { ...process.env, ...env } : process.env, }); return exitCode; } catch (error: any) { const exitCode = error.exitCode ?? 'unknown'; const errorMessage = error.message || 'Unknown error occurred'; 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); } } ================================================ FILE: bin/utils/url.ts ================================================ import * as psl from 'psl'; // Extracts the domain from a given URL. export function getDomain(inputUrl: string): string | null { try { const url = new URL(inputUrl); // Use PSL to parse domain names. const parsed = psl.parse(url.hostname); // If domain is available, split it and return the SLD. if ('domain' in parsed && parsed.domain) { return parsed.domain.split('.')[0]; } else { return null; } } catch (error) { return null; } } // Appends 'https://' protocol to the URL if not present. export function appendProtocol(inputUrl: string): string { try { new URL(inputUrl); return inputUrl; } catch { return `https://${inputUrl}`; } } // Normalizes the URL by ensuring it has a protocol and is valid. export function normalizeUrl(urlToNormalize: string): string { const urlWithProtocol = appendProtocol(urlToNormalize); try { new URL(urlWithProtocol); return urlWithProtocol; } catch (err) { throw new Error( `Your url "${urlWithProtocol}" is invalid: ${(err as Error).message}`, ); } } ================================================ FILE: bin/utils/validate.ts ================================================ import fs from 'fs'; import { InvalidArgumentError } from 'commander'; import { normalizeUrl } from './url'; export function validateNumberInput(value: string) { const parsedValue = Number(value); if (isNaN(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } return parsedValue; } export function validateUrlInput(url: string) { const isFile = fs.existsSync(url); if (!isFile) { try { return normalizeUrl(url); } catch (error) { if (error instanceof Error) { throw new InvalidArgumentError(error.message); } throw error; } } return url; } ================================================ FILE: default_app_list.json ================================================ [ { "name": "deepseek", "title": "DeepSeek", "name_zh": "DeepSeek", "url": "https://chat.deepseek.com/" }, { "name": "grok", "title": "Grok", "name_zh": "Grok", "url": "https://grok.com/" }, { "name": "gemini", "title": "Gemini", "name_zh": "Gemini", "url": "https://gemini.google.com/" }, { "name": "excalidraw", "title": "Excalidraw", "name_zh": "Excalidraw", "url": "https://excalidraw.com/" }, { "name": "programmusic", "title": "ProgramMusic", "name_zh": "ProgramMusic", "url": "https://musicforprogramming.net/" }, { "name": "twitter", "title": "Twitter", "name_zh": "推特", "url": "https://twitter.com/" }, { "name": "youtube", "title": "YouTube", "name_zh": "YouTube", "url": "https://www.youtube.com" }, { "name": "chatgpt", "title": "ChatGPT", "name_zh": "ChatGPT", "url": "https://chatgpt.com/", "new_window": true }, { "name": "flomo", "title": "Flomo", "name_zh": "浮墨", "url": "https://v.flomoapp.com/mine" }, { "name": "qwerty", "title": "Qwerty", "name_zh": "Qwerty", "url": "https://qwerty.kaiyi.cool/" }, { "name": "lizhi", "title": "LiZhi", "name_zh": "李志", "url": "https://lizhi.dengdengju.com/?from=pake" }, { "name": "xiaohongshu", "title": "XiaoHongShu", "name_zh": "小红书", "url": "https://www.xiaohongshu.com/explore" }, { "name": "youtubemusic", "title": "YouTubeMusic", "name_zh": "YouTubeMusic", "url": "https://music.youtube.com/" }, { "name": "weread", "title": "WeRead", "name_zh": "微信阅读", "url": "https://weread.qq.com/" } ] ================================================ FILE: docs/README.md ================================================ # Pake Documentation

English | 简体中文

Welcome to Pake documentation! Here you'll find comprehensive guides and documentation to help you start working with Pake as quickly as possible. ## User Guides - **[CLI Command Reference](cli-usage.md)** - Complete command-line parameters and basic usage - **[GitHub Actions Online Build](github-actions-usage.md)** - Online build without local environment setup - **[Pake Action Integration](pake-action.md)** - Use Pake as a GitHub Action in your projects ## Developer Guides - **[Advanced Usage & Development](advanced-usage.md)** - Code customization, project structure, development environment setup and testing guides - **[Contributing Guide](../CONTRIBUTING.md)** - How to contribute to Pake development ## Quick Links - [Main Repository](https://github.com/tw93/Pake) - [Releases](https://github.com/tw93/Pake/releases) - [Discussions](https://github.com/tw93/Pake/discussions) - [Issues](https://github.com/tw93/Pake/issues) ================================================ FILE: docs/README_CN.md ================================================ # Pake 文档

English | 简体中文

欢迎使用 Pake 文档!在这里您可以找到全面的指南和文档,帮助您快速开始使用 Pake。 ## 使用指南 - **[CLI命令参考](cli-usage_CN.md)** - 完整的命令行参数说明和基础用法 - **[GitHub Actions在线构建](github-actions-usage_CN.md)** - 无需本地环境的在线构建方式 - **[Pake Action集成](pake-action.md)** - 在你的项目中使用 Pake 作为 GitHub Action ## 开发指南 - **[高级用法与开发](advanced-usage_CN.md)** - 代码自定义、项目结构、开发环境配置和测试指南 - **[贡献指南](../CONTRIBUTING.md)** - 如何为 Pake 开发做贡献 ## 快捷链接 - [主仓库](https://github.com/tw93/Pake) - [发布页面](https://github.com/tw93/Pake/releases) - [讨论区](https://github.com/tw93/Pake/discussions) - [问题反馈](https://github.com/tw93/Pake/issues) ================================================ FILE: docs/advanced-usage.md ================================================ # Advanced Usage

English | 简体中文

Customize Pake apps with style modifications, JavaScript injection, and container communication. ## Style Customization Remove ads or customize appearance by modifying CSS. **Quick Process:** 1. Run `pnpm run dev` for development 2. Use DevTools to find elements to modify 3. Edit `src-tauri/src/inject/style.js`: ```javascript const css = ` .ads-banner { display: none !important; } .header { background: #1a1a1a !important; } `; ``` ## JavaScript Injection Add custom functionality like keyboard shortcuts. **Implementation:** 1. Edit `src-tauri/src/inject/event.js` 2. Add event listeners: ```javascript document.addEventListener("keydown", (e) => { if (e.ctrlKey && e.key === "k") { // Custom action } }); ``` ## Built-in Features ### Download Error Notifications Pake automatically provides user-friendly download error notifications: **Features:** - **Bilingual Support**: Automatically detects browser language (Chinese/English) - **System Notifications**: Uses native OS notifications when permission is granted - **Graceful Fallback**: Falls back to console logging if notifications are unavailable - **Comprehensive Coverage**: Handles all download types (HTTP, Data URI, Blob) **User Experience:** When a download fails, users will see a notification: - English: "Download Error - Download failed: filename.pdf" - Chinese: "下载错误 - 下载失败: filename.pdf" **Requesting Notification Permission:** To enable notifications, add this to your injected JavaScript: ```javascript // Request notification permission on app start if (window.Notification && Notification.permission === "default") { Notification.requestPermission(); } ``` The download system automatically handles: - Regular HTTP(S) downloads - Data URI downloads (base64 encoded files) - Blob URL downloads (dynamically generated files) - Context menu initiated downloads ## Container Communication Send messages between web content and Pake container. **Web Side (JavaScript):** ```javascript window.__TAURI__.invoke("handle_scroll", { scrollY: window.scrollY, scrollX: window.scrollX, }); ``` **Container Side (Rust):** ```rust #[tauri::command] fn handle_scroll(scroll_y: f64, scroll_x: f64) { println!("Scroll: {}, {}", scroll_x, scroll_y); } ``` ## Window Configuration Configure window properties in `pake.json`: ```json { "windows": { "width": 1200, "height": 780, "fullscreen": false, "resizable": true }, "hideTitleBar": true } ``` ## Static File Packaging Package local HTML/CSS/JS files: ```bash pake ./my-app/index.html --name my-static-app --use-local-file ``` Requirements: Pake CLI >= 3.0.0 ## macOS Media Permissions By default, apps built with Pake do not request camera or microphone access. For sites that require these (for example, video conferencing or voice input), pass the relevant flags at build time: ```bash pake https://chatgpt.com --name ChatGPT --microphone pake https://meet.google.com --name GoogleMeet --camera --microphone ``` - `--microphone` — grants microphone access (`com.apple.security.device.audio-input`) - `--camera` — grants camera access (`com.apple.security.device.camera`) macOS will prompt the user for permission on first use. Only add these flags for sites that actually need them. ## Multiple Apps For The Same Site If you need separate apps for the same site, for example two Gmail accounts with different login state, build them with different app names: ```bash pake https://gmail.com --name "Gmail Work" pake https://gmail.com --name "Gmail Personal" ``` Pake now generates a different app identifier for each `URL + name` pair, so these apps can be installed as separate desktop apps instead of resolving to the same app. For advanced cases, Pake also supports a hidden `--identifier` option if you need to pin the bundle identifier explicitly: ```bash pake https://gmail.com --name "Gmail Work" --identifier com.example.gmail.work ``` `--multi-instance` is different. It only allows multiple processes for the same packaged app, it does not create separate app identities. ## Project Structure Understanding Pake's codebase structure will help you navigate and contribute effectively: ```tree ├── bin/ # CLI source code (TypeScript) │ ├── builders/ # Platform-specific builders │ ├── helpers/ # Utility functions │ └── options/ # CLI option processing ├── docs/ # Project documentation ├── src-tauri/ # Tauri application core │ ├── src/ │ │ ├── app/ # Core modules (window, tray, shortcuts) │ │ ├── inject/ # Web page injection logic │ │ └── lib.rs # Application entry point │ ├── icons/ # macOS icons (.icns) │ ├── png/ # Windows/Linux icons (.ico, .png) │ ├── pake.json # App configuration │ └── tauri.*.conf.json # Platform-specific configs ├── scripts/ # Build and utility scripts └── tests/ # Test suites ``` ### Key Components - **CLI Tool** (`bin/`): TypeScript-based command interface for packaging apps - **Tauri App** (`src-tauri/`): Rust-based desktop framework - **Injection System** (`src-tauri/src/inject/`): Custom CSS/JS injection for webpages - **Configuration**: Multi-platform app settings and build configurations ## Development Workflow ### Prerequisites - Node.js ≥22.0.0 (recommended LTS, older versions ≥18.0.0 may work) - Rust ≥1.85.0 (recommended stable) #### Platform-Specific Requirements **macOS:** - Xcode Command Line Tools: `xcode-select --install` **Windows:** - **CRITICAL**: Consult [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) before proceeding - Windows 10 SDK (10.0.19041.0) and Visual Studio Build Tools 2022 (≥17.2) - Required redistributables: 1. Microsoft Visual C++ 2015-2022 Redistributable (x64) 2. Microsoft Visual C++ 2015-2022 Redistributable (x86) 3. Microsoft Visual C++ 2012 Redistributable (x86) (optional) 4. Microsoft Visual C++ 2013 Redistributable (x86) (optional) 5. Microsoft Visual C++ 2008 Redistributable (x86) (optional) - **Windows ARM (ARM64) support**: Install C++ ARM64 build tools in Visual Studio Installer under "Individual Components" → "MSVC v143 - VS 2022 C++ ARM64 build tools" **Linux (Ubuntu):** ```bash sudo apt install libdbus-1-dev \ libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev \ build-essential \ curl \ wget \ file \ libxdo-dev \ libssl-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ gnome-video-effects \ gnome-video-effects-extra \ libglib2.0-dev \ pkg-config ``` ### Installation ```bash # Clone the repository git clone https://github.com/tw93/Pake.git cd Pake # Install dependencies pnpm install # Start development pnpm run dev ``` ### Development Commands 1. **CLI Changes**: Edit files in `bin/`, then run `pnpm run cli:build` 2. **Core App Changes**: Edit files in `src-tauri/src/`, then run `pnpm run dev` 3. **Injection Logic**: Modify files in `src-tauri/src/inject/` for web customizations 4. **Testing**: Run `pnpm test` for comprehensive validation #### Command Reference - **Dev mode**: `pnpm run dev` (hot reload) - **Build**: `pnpm run build` - **Debug build**: `pnpm run build:debug` - **CLI build**: `pnpm run cli:build` #### 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: ```bash pnpm run cli:dev ``` This script reads the configuration and packages the specified app in watch mode, with hot updates for `pake-cli` code changes. ### Testing Guide Comprehensive CLI build and release validation guidance for multi-platform packaging. #### Running Tests ```bash # Complete test suite (recommended) pnpm test # Build the CLI, run the Vitest suite, then run real build + release workflow smoke tests # Skip the real build and release workflow smoke tests pnpm test -- --no-build # Run the fast Vitest suite only npx vitest run # Build the CLI explicitly pnpm run cli:build # Run the release workflow smoke test directly node ./tests/release.js ``` #### 🚀 Complete Test Suite Includes - ✅ **Vitest suite**: unit, integration, builder, and CLI option coverage - ✅ **Real build smoke test**: platform-aware packaging validation - ✅ **Release workflow smoke test**: verifies the release build path used for popular apps #### Test Details - `pnpm test` runs the main CLI test runner in [`tests/index.js`](../tests/index.js), which: - builds the CLI, - runs the Vitest suite, - runs the real build smoke test unless `--no-build` is passed, - and then runs the release workflow smoke test when the real build phase succeeds. Useful optional flags: - `--no-unit`: skip unit tests - `--no-integration`: skip integration tests - `--no-builder`: skip builder tests - `--no-build`: skip the real build smoke test and the follow-up release workflow smoke test - `--e2e`: add end-to-end configuration tests - `--pake-cli`: add GitHub Actions related checks If you only want the release workflow smoke test, run `node ./tests/release.js` directly. #### Troubleshooting - **CLI file not found**: Run `pnpm run cli:build` - **Test timeout**: Build tests require extended time to complete - **Build failures**: Check Rust toolchain with `rustup update` - **Permission errors**: Ensure write permissions are available ### Common Build Issues - **Rust compilation errors**: Run `cargo clean` in `src-tauri/` directory - **Node dependency issues**: Delete `node_modules` and run `pnpm install` - **Permission errors on macOS**: Run `sudo xcode-select --reset` ## Links - [CLI Documentation](cli-usage.md) - [GitHub Discussions](https://github.com/tw93/Pake/discussions) ================================================ FILE: docs/advanced-usage_CN.md ================================================ # 高级用法

English | 简体中文

通过样式修改、JavaScript 注入和容器通信等方式自定义 Pake 应用。 ## 样式自定义 通过修改 CSS 移除广告或自定义外观。 **快速流程:** 1. 运行 `pnpm run dev` 进入开发模式 2. 使用开发者工具找到要修改的元素 3. 编辑 `src-tauri/src/inject/style.js`: ```javascript const css = ` .ads-banner { display: none !important; } .header { background: #1a1a1a !important; } `; ``` ## JavaScript 注入 添加自定义功能,如键盘快捷键。 **实现方式:** 1. 编辑 `src-tauri/src/inject/event.js` 2. 添加事件监听器: ```javascript document.addEventListener("keydown", (e) => { if (e.ctrlKey && e.key === "k") { // 自定义操作 } }); ``` ## 内置功能 ### 下载错误通知 Pake 自动提供用户友好的下载错误通知: **功能特性:** - **双语支持**:自动检测浏览器语言(中文/英文) - **系统通知**:在授予权限后使用原生操作系统通知 - **优雅降级**:如果通知不可用则降级到控制台日志 - **全面覆盖**:处理所有下载类型(HTTP、Data URI、Blob) **用户体验:** 当下载失败时,用户将看到通知: - 英文:"Download Error - Download failed: filename.pdf" - 中文:"下载错误 - 下载失败: filename.pdf" **请求通知权限:** 要启用通知,请在注入的 JavaScript 中添加: ```javascript // 在应用启动时请求通知权限 if (window.Notification && Notification.permission === "default") { Notification.requestPermission(); } ``` 下载系统自动处理: - 常规 HTTP(S) 下载 - Data URI 下载(base64 编码文件) - Blob URL 下载(动态生成的文件) - 右键菜单发起的下载 ## 容器通信 在网页内容和 Pake 容器之间发送消息。 **网页端(JavaScript):** ```javascript window.__TAURI__.invoke("handle_scroll", { scrollY: window.scrollY, scrollX: window.scrollX, }); ``` **容器端(Rust):** ```rust #[tauri::command] fn handle_scroll(scroll_y: f64, scroll_x: f64) { println!("滚动位置: {}, {}", scroll_x, scroll_y); } ``` ## 窗口配置 在 `pake.json` 中配置窗口属性: ```json { "windows": { "width": 1200, "height": 780, "fullscreen": false, "resizable": true }, "hideTitleBar": true } ``` ## 静态文件打包 打包本地 HTML/CSS/JS 文件: ```bash pake ./my-app/index.html --name my-static-app --use-local-file ``` 要求:Pake CLI >= 3.0.0 ## macOS 摄像头与麦克风权限 Pake 构建的应用默认不申请摄像头或麦克风权限。对于需要这些权限的站点(例如视频会议或语音输入),在构建时传入对应的标志: ```bash pake https://chatgpt.com --name ChatGPT --microphone pake https://meet.google.com --name GoogleMeet --camera --microphone ``` - `--microphone` — 申请麦克风权限(`com.apple.security.device.audio-input`) - `--camera` — 申请摄像头权限(`com.apple.security.device.camera`) macOS 会在首次使用时向用户弹出权限确认对话框。请仅在确实需要的站点上添加这些标志。 ## 同一站点生成多个独立应用 如果你需要为同一个站点生成多个彼此独立的应用,例如两个不同登录态的 Gmail,可以直接使用不同的应用名称进行构建: ```bash pake https://gmail.com --name "Gmail Work" pake https://gmail.com --name "Gmail Personal" ``` Pake 现在会基于 `URL + name` 生成不同的应用标识,因此这两个应用会被当作两个独立桌面应用安装,而不是落到同一个应用上。 对于需要固定 bundle identifier 的高级场景,Pake 也支持一个隐藏参数 `--identifier`: ```bash pake https://gmail.com --name "Gmail Work" --identifier com.example.gmail.work ``` `--multi-instance` 和这个能力不同,它只是允许同一个已打包应用启动多个进程,并不会创建多个独立应用身份。 ## 项目结构 了解 Pake 的代码库结构将帮助您有效地进行导航和贡献: ```tree ├── bin/ # CLI 源代码 (TypeScript) │ ├── builders/ # 平台特定的构建器 │ ├── helpers/ # 实用函数 │ └── options/ # CLI 选项处理 ├── docs/ # 项目文档 ├── src-tauri/ # Tauri 应用核心 │ ├── src/ │ │ ├── app/ # 核心模块(窗口、托盘、快捷键) │ │ ├── inject/ # 网页注入逻辑 │ │ └── lib.rs # 应用程序入口点 │ ├── icons/ # macOS 图标 (.icns) │ ├── png/ # Windows/Linux 图标 (.ico, .png) │ ├── pake.json # 应用配置 │ └── tauri.*.conf.json # 平台特定配置 ├── scripts/ # 构建和实用脚本 └── tests/ # 测试套件 ``` ### 关键组件 - **CLI 工具** (`bin/`): 基于 TypeScript 的命令接口,用于打包应用 - **Tauri 应用** (`src-tauri/`): 基于 Rust 的桌面框架 - **注入系统** (`src-tauri/src/inject/`): 用于网页的自定义 CSS/JS 注入 - **配置**: 多平台应用设置和构建配置 ## 开发工作流 ### 前置条件 - Node.js ≥22.0.0 (推荐 LTS,较旧版本 ≥18.0.0 可能可用) - Rust ≥1.85.0 (推荐稳定版) #### 平台特定要求 **macOS:** - Xcode 命令行工具:`xcode-select --install` **Windows:** - **重要**:请先参阅 [Tauri 依赖项指南](https://v2.tauri.app/start/prerequisites/) - Windows 10 SDK (10.0.19041.0) 和 Visual Studio Build Tools 2022 (≥17.2) - 必需的运行库: 1. Microsoft Visual C++ 2015-2022 Redistributable (x64) 2. Microsoft Visual C++ 2015-2022 Redistributable (x86) 3. Microsoft Visual C++ 2012 Redistributable (x86)(可选) 4. Microsoft Visual C++ 2013 Redistributable (x86)(可选) 5. Microsoft Visual C++ 2008 Redistributable (x86)(可选) - **Windows ARM (ARM64) 支持**:在 Visual Studio Installer 中的"单个组件"下安装"MSVC v143 - VS 2022 C++ ARM64 构建工具" **Linux (Ubuntu):** ```bash sudo apt install libdbus-1-dev \ libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev \ build-essential \ curl \ wget \ file \ libxdo-dev \ libssl-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ gnome-video-effects \ gnome-video-effects-extra \ libglib2.0-dev \ pkg-config ``` ### 安装 ```bash # 克隆仓库 git clone https://github.com/tw93/Pake.git cd Pake # 安装依赖 pnpm install # 开始开发 pnpm run dev ``` ### 开发命令 1. **CLI 更改**: 编辑 `bin/` 中的文件,然后运行 `pnpm run cli:build` 2. **核心应用更改**: 编辑 `src-tauri/src/` 中的文件,然后运行 `pnpm run dev` 3. **注入逻辑**: 修改 `src-tauri/src/inject/` 中的文件以进行网页自定义 4. **测试**: 运行 `pnpm test` 进行综合验证 #### 命令参考 - **开发模式**:`pnpm run dev`(热重载) - **构建**:`pnpm run build` - **调试构建**:`pnpm run build:debug` - **CLI 构建**:`pnpm run cli:build` #### 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", }; ``` 然后运行: ```bash pnpm run cli:dev ``` 此脚本会读取上述配置并使用 watch 模式打包指定的应用,对 `pake-cli` 代码修改可实时热更新。 ### 测试指南 统一的 CLI 构建与发布验证指南,用于验证多平台打包功能。 #### 运行测试 ```bash # 完整测试套件(推荐) pnpm test # 构建 CLI,运行 Vitest 套件,再执行真实构建和发布流程 smoke test # 跳过真实构建和发布流程 smoke test pnpm test -- --no-build # 仅运行快速 Vitest 套件 npx vitest run # 构建 CLI 以供测试 pnpm run cli:build # 单独运行发布流程 smoke test node ./tests/release.js ``` #### 🚀 完整测试套件包含 - ✅ **Vitest 套件**:单元、集成、构建器和 CLI 选项覆盖 - ✅ **真实构建 smoke test**:按平台验证实际打包流程 - ✅ **发布流程 smoke test**:验证 popular apps 的发布构建路径 #### 测试内容详情 - `pnpm test` 会运行 [`tests/index.js`](../tests/index.js) 这个主测试入口,它会: - 先构建 CLI, - 再运行 Vitest 套件, - 如果没有传 `--no-build`,继续执行真实构建 smoke test, - 然后在真实构建成功后继续执行发布流程 smoke test。 常用可选参数: - `--no-unit`:跳过单元测试 - `--no-integration`:跳过集成测试 - `--no-builder`:跳过构建器测试 - `--no-build`:跳过真实构建 smoke test 以及后续的发布流程 smoke test - `--e2e`:增加端到端配置测试 - `--pake-cli`:增加 GitHub Actions 相关检查 如果只想单独验证发布流程,可以直接运行 `node ./tests/release.js`。 #### 故障排除 - **CLI 文件不存在**:运行 `pnpm run cli:build` - **测试超时**:构建测试需要较长时间完成 - **构建失败**:检查 Rust 工具链 `rustup update` - **权限错误**:确保有写入权限 ### 常见构建问题 - **Rust 编译错误**: 在 `src-tauri/` 目录中运行 `cargo clean` - **Node 依赖问题**: 删除 `node_modules` 并运行 `pnpm install` - **macOS 权限错误**: 运行 `sudo xcode-select --reset` ## 链接 - [CLI 文档](cli-usage_CN.md) - [GitHub 讨论区](https://github.com/tw93/Pake/discussions) ================================================ FILE: docs/cli-usage.md ================================================ # CLI Usage Guide

English | 简体中文

Complete command-line reference and basic usage for Pake CLI. ## Installation Ensure that your Node.js version is 22.0 or higher (e.g., 22.11.0). _Note: Older versions ≥18.0.0 may also work._ **Recommended (pnpm):** ```bash pnpm install -g pake-cli ``` **Alternative (npm):** ```bash npm install -g pake-cli ``` **If you encounter permission issues:** ```bash # Use npx to run without global installation npx pake-cli [url] [options] # Or fix npm permissions permanently npm config set prefix ~/.npm-global echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc ``` **Prerequisites:** - Node.js ≥18.0.0 - Rust ≥1.85.0 (installed automatically if missing) - **macOS/Linux**: `curl`, `wget`, `file` and `tar` used for dependency management ## Quick Start ```bash # Basic usage - automatically fetches website icon pake https://github.com --name "GitHub" # Advanced usage with custom options pake https://weekly.tw93.fun --name "Weekly" --icon https://cdn.tw93.fun/pake/weekly.icns --width 1200 --height 800 --hide-title-bar # Complete example with multiple options pake https://github.com --name "GitHub Desktop" --width 1400 --height 900 --show-system-tray --debug ``` ## CLI Usage ```bash pake [url] [options] ``` The packaged application will be located in the current working directory by default. The first packaging might take some time due to environment configuration. Please be patient. > **macOS Output**: On macOS, Pake creates DMG installers by default. To create `.app` bundles for testing (to avoid user interaction), set the environment variable `PAKE_CREATE_APP=1`. If you want Pake to install the app directly into `/Applications`, use `--install`, which builds an `.app`, copies it into `/Applications`, and removes the local bundle after a successful install. > > **Note**: Packaging requires the Rust environment. If Rust is not installed, you will be prompted for installation confirmation. In case of installation failure or timeout, you can [install it manually](https://www.rust-lang.org/tools/install). ### [url] The URL is the link to the web page you want to package or the path to a local HTML file. This is mandatory. ### [options] Various options are available for customization. Here are the most commonly used ones: | Option | Description | Example | | ------------------ | ----------------------------------------------- | ---------------------------------------------- | | `--name` | Application name | `--name "Weekly"` | | `--icon` | Custom icon (optional, auto-fetch website icon) | `--icon https://cdn.tw93.fun/pake/weekly.icns` | | `--width` | Window width (default: 1200px) | `--width 1400` | | `--height` | Window height (default: 780px) | `--height 900` | | `--hide-title-bar` | Immersive header (macOS only) | `--hide-title-bar` | | `--debug` | Enable development tools | `--debug` | For complete options, see detailed sections below. #### [name] Specify the application name. If not provided, you will be prompted to enter it. It is recommended to use English. **Note**: Also supports multiple words with automatic platform-specific handling: - **Windows/macOS**: Preserves spaces and case (e.g., `"Google Translate"`) - **Linux**: Converts to lowercase with hyphens (e.g., `"google-translate"`) ```shell --name --name MyApp # Multiple words (if needed): --name "Google Translate" ``` #### [icon] **Optional parameter**: If not provided, Pake will automatically fetch the website's icon and convert to the appropriate format. For custom icons, visit [icon-icons](https://icon-icons.com) or [macOSicons](https://macosicons.com/#/). Supports both local and remote files, automatically converts to platform-specific formats: - macOS: `.icns` format - Windows: `.ico` format - Linux: `.png` format ```shell --icon # Examples: # Without --icon parameter, auto-fetch website icon pake https://github.com --name GitHub # With custom icons --icon ./my-icon.png --icon https://cdn.tw93.fun/pake/weekly.icns # Remote icon (.icns for macOS) ``` #### [height] Set the height of the application window. Default is `780px`. ```shell --height ``` #### [width] Set the width of the application window. Default is `1200px`. ```shell --width ``` #### [min-width] Set the minimum width that the window can be resized to. Keeps layouts usable when the window is dragged small. ```shell --min-width ``` #### [min-height] Set the minimum height that the window can be resized to. Prevents UI breakage caused by very short windows. ```shell --min-height ``` #### [zoom] Set initial page zoom level (50-200). Default is `100`. Users can still adjust with `Cmd/Ctrl +/-/0` shortcuts. ```shell --zoom --zoom 80 # 80% --zoom 120 # 120% ``` #### [hide-title-bar] Enable or disable immersive header. Default is `false`. Use the following command to enable this feature, macOS only. ```shell --hide-title-bar ``` #### [fullscreen] Determine whether the application launches in full screen. Default is `false`. Use the following command to enable full screen. ```shell --fullscreen ``` #### [maximize] Determine whether the application launches with a maximized window. Default is `false`. Use the following command to enable maximize. ```shell --maximize ``` #### [activation-shortcut] Set the activation shortcut for the application. Default is empty, so it does not take effect. You can customize the activation shortcut with the following commands, e.g. `CmdOrControl+Shift+P`. Usage can refer to [available-modifiers](https://www.electronjs.org/docs/latest/api/accelerator#available-modifiers). ```shell --activation-shortcut ``` #### [always-on-top] Sets whether the window is always at the top level, defaults to `false`. ```shell --always-on-top ``` #### [app-version] Set the version number of the packaged application to be consistent with the naming format of version in package.json, defaulting to `1.0.0`. ```shell --app-version ``` #### [dark-mode] Force Mac to package applications using dark mode, default is `false`. ```shell --dark-mode ``` #### [disabled-web-shortcuts] Sets whether to disable web shortcuts in the original Pake container, defaults to `false`. ```shell --disabled-web-shortcuts ``` #### [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`. ```shell --force-internal-navigation ``` #### [internal-url-regex] Set a regex pattern to determine which URLs should be considered internal (opened within the app). When set, this pattern takes precedence over the default domain-based matching. Useful when you want to limit internal navigation to specific paths on a domain. ```shell --internal-url-regex # Example: Only treat facebook.com/messages paths as internal --internal-url-regex "^https://www\\.facebook\\.com/messages(/.*)?$" # Example: Only treat specific subdomains as internal --internal-url-regex "^https://(app|api)\\.example\\.com" ``` #### [multi-arch] Package the application to support both Intel and M1 chips, exclusively for macOS. Default is `false`. ##### Prerequisites - Note: After enabling this option, Rust must be installed using rustup from the official Rust website. Installation via brew is not supported. - For Intel chip users, install the arm64 cross-platform package to support M1 chips using the following command: ```shell rustup target add aarch64-apple-darwin ``` - For M1 chip users, install the x86 cross-platform package to support Intel chips using the following command: ```shell rustup target add x86_64-apple-darwin ``` ##### Usage ```shell --multi-arch ``` #### [targets] Specify the build target architecture or format: - **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64` (default: `deb`, `appimage`) - **Windows**: `x64`, `arm64` (auto-detects if not specified) - **macOS**: `intel`, `apple`, `universal` (auto-detects if not specified) ```shell --targets # Examples: --targets arm64 # Windows ARM64 --targets x64 # Windows x64 --targets universal # macOS Universal (Intel + Apple Silicon) --targets apple # macOS Apple Silicon only --targets intel # macOS Intel only --targets deb # Linux DEB package (x64) --targets rpm # Linux RPM package (x64) --targets appimage # Linux AppImage (x64) --targets deb-arm64 # Linux DEB package (ARM64) --targets rpm-arm64 # Linux RPM package (ARM64) --targets appimage-arm64 # Linux AppImage (ARM64) ``` **Note for Linux ARM64**: - 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. #### [user-agent] Customize the browser user agent. Default is empty. ```shell --user-agent ``` #### [show-system-tray] Display the application in system tray. Default is `false`. ```shell --show-system-tray ``` #### [system-tray-icon] Specify the system tray icon. This is only effective when the system tray is enabled. The icon must be in `.ico` or `.png` format and should be an image with dimensions ranging from 32x32 to 256x256 pixels. ```shell --system-tray-icon ``` #### [hide-on-close] Hide window instead of closing the application when clicking close button. Platform-specific default: `true` for macOS, `false` for Windows/Linux. ```shell # Hide on close (default behavior on macOS) --hide-on-close --hide-on-close true # Close application immediately (default behavior on Windows/Linux) --hide-on-close false ``` #### [start-to-tray] Start the application minimized to system tray instead of showing the window. Must be used with `--show-system-tray`. Default is `false`. ```shell --start-to-tray # Example: Start hidden to tray (must use with --show-system-tray) pake https://github.com --name GitHub --show-system-tray --start-to-tray ``` **Note**: Double-click the tray icon to show/hide the window. If used without `--show-system-tray`, this option is ignored. #### [title] Set the window title bar text. macOS shows no title if not specified; Windows/Linux fallback to app name. ```shell --title # Examples: --title "My Application" --title "Google Translate" ``` #### [incognito] Launch the application in incognito/private browsing mode. Default is `false`. When enabled, the webview will run in private mode, which means it won't store cookies, local storage, or browsing history. This is useful for privacy-sensitive applications. ```shell --incognito ``` #### [wasm] Enable WebAssembly support with cross-origin isolation headers. Required for Flutter Web applications and other web applications that use WebAssembly modules like `sqlite3.wasm`, `canvaskit.wasm`. Default is `false`. This option adds necessary HTTP headers (`Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`) and browser flags to enable SharedArrayBuffer and WebAssembly features. ```shell --wasm # Example: Package a Flutter Web app with WASM support pake https://flutter.dev --name FlutterApp --wasm ``` #### [enable-drag-drop] Enable native drag and drop functionality within the application. Default is `false`. When enabled, allows drag and drop operations like reordering items, file uploads, and other interactive drag behaviors that work in regular browsers. ```shell --enable-drag-drop # Example: Package an app that requires drag-drop functionality pake https://planka.example.com --name PlankApp --enable-drag-drop ``` #### [keep-binary] Keep the raw binary file alongside the installer. Default is `false`. When enabled, also outputs a standalone executable that can run without installation. ```shell --keep-binary # Example: Package app with both installer and standalone binary pake https://github.com --name GitHub --keep-binary ``` **Output**: Creates both installer and standalone executable (`AppName-binary` on Unix, `AppName.exe` on Windows). #### [iterative-build] Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging. Default is `false`. ```shell --iterative-build ``` #### [install] Install the built macOS app directly into `/Applications`. Default is `false`. This option is macOS-only and is intended for local development or quick testing. When enabled, Pake builds an `.app` bundle, copies it into `/Applications`, replaces any existing app with the same name, and removes the local bundle after a successful install. If the install fails, the local `.app` is kept in the current working directory. ```shell --install # Example: Build and install directly to /Applications pake https://github.com --name GitHub --install ``` #### [multi-instance] Allow the packaged app to run more than one instance at the same time. Default is `false`, which means launching a second instance simply focuses the existing window. Enable this when you need to open several windows of the same app simultaneously. ```shell --multi-instance # Example: Allow multiple chat windows pake https://chat.example.com --name ChatApp --multi-instance ``` #### [multi-window] Allow opening multiple windows within a single running app instance. Default is `false`. This is different from `--multi-instance`: - `--multi-instance`: starts multiple app processes. - `--multi-window`: keeps one process and opens extra windows from that process. When enabled, relaunching an already running app opens a new window instead of only focusing the existing one. This can improve popup-based authentication flows, but it cannot bypass provider policy. Some providers, especially Google, may still reject sign-in inside embedded webviews. ```shell --multi-window # Example: Keep one process but open multiple windows pake https://chat.example.com --name ChatApp --multi-window ``` #### [installer-language] Set the Windows Installer language. Options include `zh-CN`, `ja-JP`, More at [Tauri docs](https://v2.tauri.app/distribute/windows-installer/#internationalization). Default is `en-US`. ```shell --installer-language ``` #### [use-local-file] Enable recursive copying. When the URL is a local file path, enabling this option will copy the folder containing the file specified in the URL, as well as all sub-files, to the Pake static folder. This is disabled by default. ```shell --use-local-file # Basic static file packaging pake ./my-app/index.html --name "my-app" --use-local-file ``` #### [inject] Using `inject`, you can inject local absolute and relative path `css` and `js` files into the page you specify the `url` to customize it. For example, an adblock script that can be applied to any web page, or a `css` that optimizes the `UI` of a page, you can write it once to customize it. would only need to write the `app` once to generalize it to any other page. Supports both comma-separated and multiple option formats: ```shell # Comma-separated (recommended) --inject ./tools/style.css,./tools/hotkey.js # Multiple options --inject ./tools/style.css --inject ./tools/hotkey.js # Single file --inject ./tools/style.css ``` #### [proxy-url] Set proxy server for all network requests. Supports HTTP, HTTPS, and SOCKS5. Available on Windows and Linux. On macOS, requires macOS 14+. ```shell --proxy-url http://127.0.0.1:7890 --proxy-url socks5://127.0.0.1:7891 ``` #### [debug] Enable developer tools and detailed logging for debugging. ```shell --debug ``` #### [ignore-certificate-errors] Ignore TLS certificate validation errors when loading the target URL. Useful for intranet apps, dev servers, or self-signed certificates. ```shell --ignore-certificate-errors ``` #### [new-window] Allow sites to open new windows, such as authentication popups, extra tabs, or branch views. This can help sites that rely on popup auth windows, but it does not guarantee in-app sign-in. Some providers, especially Google, may block authentication inside embedded webviews regardless of this option. ```shell --new-window ``` ### Packaging Complete After completing the above steps, your application should be successfully packaged. Please note that the packaging process may take some time depending on your system configuration and network conditions. Be patient, and once the packaging is complete, you can find the application installer in the specified directory. ## Docker ```shell # Run the Pake CLI via Docker (AppImage builds need FUSE access) docker run --rm --privileged \ --device /dev/fuse \ --security-opt apparmor=unconfined \ -v YOUR_DIR:/output \ ghcr.io/tw93/pake \ # For example: docker run --rm --privileged \ --device /dev/fuse \ --security-opt apparmor=unconfined \ -v ./packages:/output \ ghcr.io/tw93/pake \ https://example.com --name myapp --icon ./icon.png --targets appimage ``` ================================================ FILE: docs/cli-usage_CN.md ================================================ # CLI 使用指南

English | 简体中文

完整的命令行参数说明和基础用法指南。 ## 安装 请确保您的 Node.js 版本为 22 或更高版本(例如 22.11.0)。_注意:较旧的版本 ≥18.0.0 也可能可以工作。_ **推荐方式 (pnpm):** ```bash pnpm install -g pake-cli ``` **备选方式 (npm):** ```bash npm install -g pake-cli ``` **如果遇到权限问题:** ```bash # 使用 npx 运行,无需全局安装 npx pake-cli [url] [选项] # 或者永久修复 npm 权限 npm config set prefix ~/.npm-global echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc ``` **前置条件:** - Node.js ≥18.0.0 - Rust ≥1.85.0(如缺失将自动安装) - **macOS/Linux**:`curl`、`wget`、`file` 和 `tar`(用于依赖管理) ## 快速开始 ```bash # 基础用法 - 自动获取网站图标 pake https://github.com --name "GitHub" # 高级用法:自定义选项 pake https://weekly.tw93.fun --name "Weekly" --icon https://cdn.tw93.fun/pake/weekly.icns --width 1200 --height 800 --hide-title-bar # 完整示例:多个选项组合使用 pake https://github.com --name "GitHub Desktop" --width 1400 --height 900 --show-system-tray --debug ``` ## 命令行使用 ```bash pake [url] [options] ``` 应用程序的打包结果将默认保存在当前工作目录。由于首次打包需要配置环境,这可能需要一些时间,请耐心等待。 > **macOS 输出**:在 macOS 上,Pake 默认创建 DMG 安装程序。如需创建 `.app` 包进行测试(避免用户交互),请设置环境变量 `PAKE_CREATE_APP=1`。如果希望 Pake 直接将应用安装到 `/Applications`,可以使用 `--install`;该选项会构建 `.app`、复制到 `/Applications`,并在安装成功后删除当前目录中的本地 `.app`。 > > **注意**:打包过程需要使用 `Rust` 环境。如果您没有安装 `Rust`,系统会提示您是否要安装。如果遇到安装失败或超时的问题,您可以 [手动安装](https://www.rust-lang.org/tools/install)。 ### [url] `url` 是您需要打包的网页链接 🔗 或本地 HTML 文件的路径,此参数为必填。 ### [options] 您可以通过传递以下选项来定制打包过程。以下是最常用的选项: | 选项 | 描述 | 示例 | | ------------------ | ------------------------------------ | ---------------------------------------------- | | `--name` | 应用程序名称 | `--name "Weekly"` | | `--icon` | 自定义图标(可选,自动获取网站图标) | `--icon https://cdn.tw93.fun/pake/weekly.icns` | | `--width` | 窗口宽度(默认:1200px) | `--width 1400` | | `--height` | 窗口高度(默认:780px) | `--height 900` | | `--hide-title-bar` | 沉浸式标题栏(仅macOS) | `--hide-title-bar` | | `--debug` | 启用开发者工具 | `--debug` | 完整选项请参见下面的详细说明: #### [name] 指定应用程序的名称,如果未指定,系统会提示您输入,建议使用英文单词。 **注意**: 支持带空格的名称,会自动处理不同平台的命名规范: - **Windows/macOS**: 保持空格和大小写(如 `"Google Translate"`) - **Linux**: 自动转换为小写并用连字符连接(如 `"google-translate"`) ```shell --name --name MyApp # 带空格的名称: --name "Google Translate" ``` #### [icon] **可选参数**:不传此参数时,Pake 会自动获取网站图标并转换为对应格式。如需自定义图标,可访问 [icon-icons](https://icon-icons.com) 或 [macOSicons](https://macosicons.com/#/) 下载。 支持本地或远程文件,自动转换为平台所需格式: - macOS:`.icns` 格式 - Windows:`.ico` 格式 - Linux:`.png` 格式 ```shell --icon # 示例: # 不传 --icon 参数,自动获取网站图标 pake https://github.com --name GitHub # 使用自定义图标 --icon ./my-icon.png --icon https://cdn.tw93.fun/pake/weekly.icns # 远程图标(.icns适用于macOS) ``` #### [height] 设置应用窗口的高度,默认为 `780px`。 ```shell --height ``` #### [width] 设置应用窗口的宽度,默认为 `1200px`。 ```shell --width ``` #### [min-width] 设置窗口可以缩放到的最小宽度,防止窗口被拖得过小导致控件错位。 ```shell --min-width ``` #### [min-height] 设置窗口可以缩放到的最小高度,避免界面内容因高度过小而错乱。 ```shell --min-height ``` #### [zoom] 设置初始页面缩放级别(50-200),默认为 `100`。用户仍可通过快捷键(`Cmd/Ctrl +/-/0`)调整。 ```shell --zoom --zoom 80 # 80% --zoom 120 # 120% ``` #### [hide-title-bar] 设置是否启用沉浸式头部,默认为 `false`(不启用)。当前只对 macOS 上有效。 ```shell --hide-title-bar ``` #### [fullscreen] 设置应用程序是否在启动时自动全屏,默认为 `false`。使用以下命令可以设置应用程序启动时自动全屏。 ```shell --fullscreen ``` #### [maximize] 设置应用程序是否在启动时最大化窗口,默认为 `false`。使用以下命令可以设置应用程序启动时窗口最大化。 ```shell --maximize ``` #### [activation-shortcut] 设置应用程序的激活快捷键。默认为空,不生效,可以使用以下命令自定义激活快捷键,例如 `CmdOrControl+Shift+P`,使用可参考 [available-modifiers](https://www.electronjs.org/docs/latest/api/accelerator#available-modifiers)。 ```shell --activation-shortcut ``` #### [always-on-top] 设置是否窗口一直在最顶层,默认为 `false`。 ```shell --always-on-top ``` #### [app-version] 设置打包应用的版本号,和 package.json 里面 version 命名格式一致,默认为 `1.0.0`。 ```shell --app-version ``` #### [dark-mode] 强制 Mac 打包应用使用黑暗模式,默认为 `false`。 ```shell --dark-mode ``` #### [disabled-web-shortcuts] 设置是否禁用原有 Pake 容器里面的网页操作快捷键,默认为 `false`。 ```shell --disabled-web-shortcuts ``` #### [force-internal-navigation] 启用后所有点击的链接(即使是跨域)都会在 Pake 窗口内打开,不会再调用外部浏览器或辅助程序。默认 `false`。 ```shell --force-internal-navigation ``` #### [internal-url-regex] 设置一个正则表达式来判断哪些 URL 应被视为内部链接(在应用内打开)。设置后,此正则表达式将优先于默认的域名匹配逻辑。适用于只想让特定路径在应用内打开的场景。 ```shell --internal-url-regex # 示例:只把 facebook.com/messages 路径视为内部链接 --internal-url-regex "^https://www\\.facebook\\.com/messages(/.*)?$" # 示例:只把特定子域名视为内部链接 --internal-url-regex "^https://(app|api)\\.example\\.com" ``` #### [multi-arch] 设置打包结果同时支持 Intel 和 M1 芯片,仅适用于 macOS,默认为 `false`。 ##### 准备工作 - 注意:启用此选项后,需要使用 rust 官网的 rustup 安装 rust,不支持通过 brew 安装。 - 对于 Intel 芯片用户,需要安装 arm64 跨平台包,以使安装包支持 M1 芯片。使用以下命令安装: ```shell rustup target add aarch64-apple-darwin ``` - 对于 M1 芯片用户,需要安装 x86 跨平台包,以使安装包支持 Intel 芯片。使用以下命令安装: ```shell rustup target add x86_64-apple-darwin ``` ##### 使用方法 ```shell --multi-arch ``` #### [targets] 指定构建目标架构或格式: - **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`(默认:`deb`, `appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) - **macOS**: `intel`, `apple`, `universal`(未指定时自动检测) ```shell --targets # 示例: --targets arm64 # Windows ARM64 --targets x64 # Windows x64 --targets universal # macOS 通用版本(Intel + Apple Silicon) --targets apple # 仅 macOS Apple Silicon --targets intel # 仅 macOS Intel --targets deb # Linux DEB 包(x64) --targets rpm # Linux RPM 包(x64) --targets appimage # Linux AppImage(x64) --targets deb-arm64 # Linux DEB 包(ARM64) --targets rpm-arm64 # Linux RPM 包(ARM64) --targets appimage-arm64 # Linux AppImage(ARM64) ``` **Linux ARM64 注意事项**: - 交叉编译需要额外设置。需要安装 `gcc-aarch64-linux-gnu` 并配置交叉编译环境变量。 - ARM64 支持让 Pake 应用可以在基于 ARM 的 Linux 设备上运行,包括 Linux 手机(postmarketOS、Ubuntu Touch)、树莓派和其他 ARM64 Linux 系统。 - 使用 `--target appimage-arm64` 可以创建便携式 ARM64 应用,在不同的 ARM64 Linux 发行版上运行。 #### [user-agent] 自定义浏览器的用户代理请求头,默认为空。 ```shell --user-agent ``` #### [show-system-tray] 设置应用程序显示在系统托盘,默认为 `false`。 ```shell --show-system-tray ``` #### [system-tray-icon] 设置通知栏托盘图标,仅在启用通知栏托盘时有效。图标必须为 `.ico` 或 `.png` 格式,分辨率为 32x32 到 256x256 像素。 ```shell --system-tray-icon ``` #### [hide-on-close] 点击关闭按钮时隐藏窗口而不是退出应用程序。平台特定默认值:macOS 为 `true`,Windows/Linux 为 `false`。 ```shell # 关闭时隐藏(macOS 默认行为) --hide-on-close --hide-on-close true # 立即关闭应用程序(Windows/Linux 默认行为) --hide-on-close false ``` #### [start-to-tray] 启动时将应用程序最小化到系统托盘而不是显示窗口。必须与 `--show-system-tray` 一起使用。默认为 `false`。 ```shell --start-to-tray # 示例:启动时隐藏到托盘(必须与 --show-system-tray 一起使用) pake https://github.com --name GitHub --show-system-tray --start-to-tray ``` **注意**:双击托盘图标可以显示/隐藏窗口。如果不与 `--show-system-tray` 一起使用,此选项将被忽略。 #### [title] 设置窗口标题栏文本,macOS 未指定时不显示标题,Windows/Linux 回退使用应用名称。 ```shell --title # 示例: --title "我的应用" --title "音乐播放器" ``` #### [incognito] 以隐私/隐身浏览模式启动应用程序。默认为 `false`。启用后,webview 将在隐私模式下运行,这意味着它不会存储 cookie、本地存储或浏览历史记录。这对于注重隐私的应用程序很有用。 ```shell --incognito ``` #### [wasm] 启用 WebAssembly 支持,添加跨域隔离头部,适用于 Flutter Web 应用以及其他使用 WebAssembly 模块(如 `sqlite3.wasm`、`canvaskit.wasm`)的 Web 应用,默认为 `false`。 此选项会添加必要的 HTTP 头部(`Cross-Origin-Opener-Policy: same-origin` 和 `Cross-Origin-Embedder-Policy: require-corp`)以及浏览器标志,以启用 SharedArrayBuffer 和 WebAssembly 功能。 ```shell --wasm # 示例:打包支持 WASM 的 Flutter Web 应用 pake https://flutter.dev --name FlutterApp --wasm ``` #### [enable-drag-drop] 启用原生拖拽功能。默认为 `false`。启用后,允许在应用中进行拖拽操作,如重新排序项目、文件上传以及其他在常规浏览器中有效的交互式拖拽行为。 ```shell --enable-drag-drop # 示例:打包需要拖拽功能的应用 pake https://planka.example.com --name PlankApp --enable-drag-drop ``` #### [keep-binary] 保留原始二进制文件与安装包一起。默认为 `false`。启用后,除了平台特定的安装包外,还会输出一个可独立运行的可执行文件。 ```shell --keep-binary # 示例:同时生成安装包和独立可执行文件 pake https://github.com --name GitHub --keep-binary ``` **输出结果**:同时创建安装包和独立可执行文件(Unix 系统为 `AppName-binary`,Windows 为 `AppName.exe`)。 #### [iterative-build] 开启快速构建模式(仅生成 app,不生成 dmg/deb/msi),适用于调试。默认为 `false`。 ```shell --iterative-build ``` #### [install] 将构建出的 macOS 应用直接安装到 `/Applications`。默认为 `false`。 该选项仅适用于 macOS,适合本地开发和快速验证。启用后,Pake 会构建 `.app` 包,将其复制到 `/Applications`,如果已存在同名应用则先替换,并在安装成功后删除当前工作目录中的本地 `.app`。如果安装失败,当前目录中的 `.app` 会被保留。 ```shell --install # 示例:构建后直接安装到 /Applications pake https://github.com --name GitHub --install ``` #### [multi-instance] 允许打包后的应用同时运行多个实例。默认为 `false`,此时再次启动只会聚焦已有窗口。启用该选项后,可以同时打开同一个应用的多个窗口。 ```shell --multi-instance # 示例:允许聊天应用同时开多个窗口 pake https://chat.example.com --name ChatApp --multi-instance ``` #### [multi-window] 允许在单个运行中的应用实例内打开多个窗口,默认值为 `false`。 它和 `--multi-instance` 的区别: - `--multi-instance`:启动多个应用进程。 - `--multi-window`:保持单进程,在该进程内打开多个窗口。 启用后,如果应用已在运行,再次启动会新开一个窗口,而不是仅聚焦已有窗口。 这个选项可以改善基于弹窗的认证流程,但不能绕过认证提供方的策略限制。某些提供方,尤其是 Google,仍然可能拒绝在嵌入式 WebView 中完成登录。 ```shell --multi-window # 示例:单进程多窗口 pake https://chat.example.com --name ChatApp --multi-window ``` #### [installer-language] 设置 Windows 安装包语言。支持 `zh-CN`、`ja-JP`,更多在 [Tauri 文档](https://v2.tauri.app/distribute/windows-installer/#internationalization)。默认为 `en-US`。 ```shell --installer-language ``` #### [use-local-file] 当 `url` 为本地文件路径时,如果启用此选项,则会递归地将 `url` 路径文件所在的文件夹及其所有子文件复制到 Pake 的静态文件夹。默认不启用。 ```shell --use-local-file # 基础静态文件打包 pake ./my-app/index.html --name "my-app" --use-local-file ``` #### [inject] 使用 `inject` 可以通过本地的绝对、相对路径的 `css` `js` 文件注入到你所指定 `url` 的页面中,从而为其做定制化改造。举个例子:一段可以通用到任何网页的广告屏蔽脚本,或者是优化页面 `UI` 展示的 `css`,你只需要书写一次可以将其通用到任何其他网页打包的 `app`。 支持逗号分隔和多个选项两种格式: ```shell # 逗号分隔(推荐) --inject ./tools/style.css,./tools/hotkey.js # 多个选项 --inject ./tools/style.css --inject ./tools/hotkey.js # 单个文件 --inject ./tools/style.css ``` #### [proxy-url] 为所有网络请求设置代理服务器。支持 HTTP、HTTPS 和 SOCKS5。在 Windows 和 Linux 上可用。在 macOS 上需要 macOS 14+。 ```shell --proxy-url http://127.0.0.1:7890 --proxy-url socks5://127.0.0.1:7891 ``` #### [debug] 启用开发者工具和详细日志输出,用于调试。 ```shell --debug ``` #### [ignore-certificate-errors] 忽略目标 URL 的 TLS 证书校验错误,适用于内网应用、开发环境、自签名证书。 ```shell --ignore-certificate-errors ``` #### [new-window] 允许网站打开新窗口,例如登录授权弹窗、额外标签页或分支会话页面。 这个选项可以帮助依赖弹窗授权窗口的网站,但不能保证一定能在应用内完成登录。某些提供方,尤其是 Google,可能仍然会阻止在嵌入式 WebView 中进行认证。 ```shell --new-window ``` ### 打包完成 完成上述步骤后,您的应用程序应该已经成功打包。请注意,根据您的系统配置和网络状况,打包过程可能需要一些时间。请耐心等待,一旦打包完成,您就可以在指定的目录中找到应用程序安装包。 ## Docker 使用 ```shell # 在 Linux 上通过 Docker 运行 Pake CLI(AppImage 构建需要 FUSE 权限) docker run --rm --privileged \ --device /dev/fuse \ --security-opt apparmor=unconfined \ -v YOUR_DIR:/output \ ghcr.io/tw93/pake \ # 例如: docker run --rm --privileged \ --device /dev/fuse \ --security-opt apparmor=unconfined \ -v ./packages:/output \ ghcr.io/tw93/pake \ https://example.com --name MyApp --icon ./icon.png --targets appimage ``` ================================================ FILE: docs/faq.md ================================================ # Frequently Asked Questions (FAQ)

English | 简体中文

Common issues and solutions when using Pake. ## Table of Contents - [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: AppImage Build Fails with "failed to run linuxdeploy"](#linux-appimage-build-fails-with-failed-to-run-linuxdeploy) - [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) - [macOS: Build Fails with Module Compilation Errors](#macos-build-fails-with-module-compilation-errors) - [Runtime Issues](#runtime-issues) - [App Window is Too Small/Large](#app-window-is-too-smalllarge) - [App Icon Not Showing Correctly](#app-icon-not-showing-correctly) - [Website Features Not Working (Login, Upload, etc.)](#website-features-not-working-login-upload-etc) - [Installation Issues](#installation-issues) - [Permission Denied When Installing Globally](#permission-denied-when-installing-globally) - [Getting Help](#getting-help) --- ## Build Issues ### Rust Version Error: "feature 'edition2024' is required" **Problem:** When building Pake or using the CLI, you encounter an error like: ```txt error: failed to parse manifest Caused by: feature `edition2024` is required this Cargo does not support nightly features, but if you switch to nightly channel you can add `cargo-features = ["edition2024"] to enable this feature ``` **Why This Happens:** Pake's dependencies require Rust edition2024 support, which is only available in Rust 1.85.0 or later. Specifically: - The dependency chain includes: `tauri` → `image` → `moxcms` → `pxfm v0.1.25` (requires edition2024) - Rust edition2024 became stable in Rust 1.85.0 (released February 2025) - If your Rust version is older (e.g., 1.82.0 from August 2024), you'll see this error **Solution:** Update your Rust toolchain to version 1.85.0 or later: ```bash # Update to the latest stable Rust version rustup update stable # Or install the latest stable version rustup install stable # Verify the update rustc --version # Should show: rustc 1.85.0 or higher ``` After updating, retry your build command. **For Development Setup:** If you're setting up a development environment, ensure: - Rust ≥1.85.0 (check with `rustc --version`) - Node.js ≥22.0.0 (check with `node --version`) See [CONTRIBUTING.md](../CONTRIBUTING.md) for complete prerequisites. --- ### Linux: Build Error "Can't detect any appindicator library" on Ubuntu 24.04 **Problem:** When building on Ubuntu 24.04 or newer, you may encounter: ```txt Can't detect any appindicator library ``` Or potentially errors related to Icon RGBA in older versions. **Solution:** Ubuntu 24.04+ replaced `libappindicator3-dev` with `libayatana-appindicator3-dev`. Install the correct dependency: ```bash sudo apt-get update sudo apt-get install -y libayatana-appindicator3-dev ``` --- ### Linux: AppImage Build Fails with "failed to run linuxdeploy" **Problem:** When building AppImage on Linux (Debian, Ubuntu, Arch, etc.), you may encounter errors like: ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file ``` **Solution 1: Automatic NO_STRIP Retry (Recommended)** Pake CLI now automatically retries AppImage builds with `NO_STRIP=1` when linuxdeploy fails to strip the binary. To skip the strip step from the very first attempt (or when scripting your own builds), set the variable manually: ```bash NO_STRIP=1 pake https://example.com --name MyApp --targets appimage ``` This bypasses the library stripping process that often causes issues on certain Linux distributions. **Solution 2: Install System Dependencies** If NO_STRIP doesn't work, ensure you have all required system dependencies: ```bash sudo apt update sudo apt install -y \ libdbus-1-dev \ libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev \ build-essential \ curl wget file \ libxdo-dev \ libssl-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ gnome-video-effects \ libglib2.0-dev \ libgirepository1.0-dev \ pkg-config ``` Then try building again (you can still pre-set `NO_STRIP=1` if you prefer). **Solution 3: Use DEB Format Instead** DEB packages are more stable on Debian-based systems: ```bash pake https://example.com --name MyApp --targets deb ``` **Solution 4: Use Docker (with FUSE access)** Build in a clean environment without installing dependencies. AppImage tooling needs access to `/dev/fuse`, so run the container in privileged mode (or grant FUSE explicitly): ```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 ``` > **Tip:** The generated AppImage may be owned by root. Run `sudo chown $(id -nu):$(id -ng) ./output/MyApp.AppImage` afterwards. **Why This Happens:** This is a known issue with Tauri's linuxdeploy tool, which can fail when: - System libraries have incompatible formats for stripping - Building on newer distributions (Arch, Debian Trixie, etc.) - Missing WebKit2GTK or GTK development libraries The `NO_STRIP=1` environment variable is the official workaround recommended by the Tauri community. --- ### Linux: "cargo: command not found" After Installing Rust **Problem:** You installed Rust but Pake still reports "cargo: command not found". **Solution:** Pake CLI automatically reloads the Rust environment, but if issues persist: ```bash # Reload environment in current terminal source ~/.cargo/env # Or restart your terminal ``` Then try building again. --- ### Windows: Installation Timeout During First Build **Problem:** When building for the first time on Windows, you may encounter: ```txt Error: Command timed out after 900000ms: "cd ... && pnpm install" ``` **Why This Happens:** First-time installation on Windows can be slow due to: - Native module compilation (requires Visual Studio Build Tools) - Large dependency downloads (Tauri, Rust toolchain) - Windows Defender real-time scanning - Network connectivity issues **Solution 1: Automatic Retry (Built-in)** Pake CLI now automatically retries with CN mirror if the initial installation times out. Simply wait for the retry to complete. **Solution 2: Manual Installation** If automatic retry fails, manually install dependencies: ```bash # Navigate to pake-cli installation directory cd %LOCALAPPDATA%\pnpm\global\5\.pnpm\pake-cli@VERSION\node_modules\pake-cli # Install with CN mirror pnpm install --registry=https://registry.npmmirror.com # Then retry your build pake https://github.com --name GitHub ``` **Solution 3: Improve Network Speed** - Use a stable network connection - Temporarily disable antivirus software during installation - Use a VPN or proxy if needed **Expected Time:** - First installation: 10-15 minutes on Windows - Subsequent builds: Much faster (dependencies cached) --- ### Windows: Missing Visual Studio Build Tools **Problem:** Build fails with errors about missing MSVC or Windows SDK. **Solution:** Install Visual Studio Build Tools: 1. Download [Visual Studio Build Tools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022) 2. During installation, select "Desktop development with C++" 3. For ARM64 support: Also select "MSVC v143 - VS 2022 C++ ARM64 build tools" under Individual Components --- ### macOS: Build Fails with Module Compilation Errors **Problem:** On macOS 26 Beta or newer, you may see errors related to `CoreFoundation` or `_Builtin_float` modules. **Solution:** Create a configuration file to use compatible SDK: ```bash cat > src-tauri/.cargo/config.toml << 'EOF' [env] MACOSX_DEPLOYMENT_TARGET = "15.0" SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" EOF ``` This file is already in `.gitignore` and won't be committed. --- ## Runtime Issues ### App Window is Too Small/Large **Solution:** Specify custom dimensions when building: ```bash pake https://example.com --width 1200 --height 800 ``` See [CLI Usage Guide](cli-usage.md#window-options) for all window options. --- ### App Icon Not Showing Correctly **Problem:** Custom icon doesn't appear or shows default icon. **Solution:** Ensure you're using the correct icon format for your platform: - **macOS**: `.icns` format - **Windows**: `.ico` format - **Linux**: `.png` format ```bash # macOS pake https://example.com --icon ./icon.icns # Windows pake https://example.com --icon ./icon.ico # Linux pake https://example.com --icon ./icon.png ``` Pake can automatically convert icons, but providing the correct format is more reliable. --- ### Website Features Not Working (Login, Upload, etc.) **Problem:** Some website features don't work in the Pake app. **Solution:** This is usually due to web compatibility issues. Try: 1. **Set custom User Agent:** ```bash pake https://example.com --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ``` 2. **Inject custom JavaScript:** ```bash pake https://example.com --inject ./fix.js ``` For pages that need periodic reloads, you can keep this behavior in a small injected script instead of adding a dedicated Pake option: ```javascript function isEditing(element) { if (!element) return false; const tagName = element.tagName; return ( element.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" ); } setInterval(() => { if (!document.hidden && !isEditing(document.activeElement)) { window.location.reload(); } }, 300000); ``` Save it as `refresh.js` and package with: ```bash pake https://news.ycombinator.com --name HackerNews --inject ./refresh.js ``` 3. **Check if the site requires specific permissions** that may not be available in WebView 4. **Be aware of embedded-webview sign-in limits** 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. --- ## Installation Issues ### Permission Denied When Installing Globally **Problem:** `npm install -g pake-cli` fails with permission errors. **Solution:** Use one of these approaches: ```bash # Option 1: Use npx (no installation needed) npx pake-cli https://example.com # Option 2: Fix npm permissions npm config set prefix ~/.npm-global echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc npm install -g pake-cli # Option 3: Use pnpm (recommended) pnpm install -g pake-cli ``` --- ## Getting Help If your issue isn't covered here: 1. Check the [CLI Usage Guide](cli-usage.md) for detailed parameter documentation 2. See [Advanced Usage](advanced-usage.md) for prerequisites and system setup 3. Search [existing GitHub issues](https://github.com/tw93/Pake/issues) 4. [Open a new issue](https://github.com/tw93/Pake/issues/new) with: - Your OS and version - Node.js and Rust versions (`node --version`, `rustc --version`) - Complete error message - Build command you used ================================================ FILE: docs/faq_CN.md ================================================ # 常见问题 (FAQ)

English | 简体中文

使用 Pake 时的常见问题和解决方案。 ## 目录 - [构建问题](#构建问题) - [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:AppImage 构建失败,提示 "failed to run linuxdeploy"](#linuxappimage-构建失败提示-failed-to-run-linuxdeploy) - [Linux:"cargo: command not found" 即使已安装 Rust](#linuxcargo-command-not-found-即使已安装-rust) - [Windows:首次构建时安装超时](#windows首次构建时安装超时) - [Windows:缺少 Visual Studio 构建工具](#windows缺少-visual-studio-构建工具) - [macOS:构建失败,出现模块编译错误](#macos构建失败出现模块编译错误) - [运行时问题](#运行时问题) - [应用窗口太小/太大](#应用窗口太小太大) - [应用图标显示不正确](#应用图标显示不正确) - [网站功能不工作(登录、上传等)](#网站功能不工作登录上传等) - [安装问题](#安装问题) - [全局安装时权限被拒绝](#全局安装时权限被拒绝) - [获取帮助](#获取帮助) --- ## 构建问题 ### Rust 版本错误:"feature 'edition2024' is required" **问题描述:** 在构建 Pake 或使用 CLI 时,遇到如下错误: ```txt error: failed to parse manifest Caused by: feature `edition2024` is required this Cargo does not support nightly features, but if you switch to nightly channel you can add `cargo-features = ["edition2024"]` to enable this feature ``` **原因分析:** Pake 的依赖项需要 Rust edition2024 支持,该特性仅在 Rust 1.85.0 或更高版本中可用。具体来说: - 依赖链包括:`tauri` → `image` → `moxcms` → `pxfm v0.1.25`(需要 edition2024) - Rust edition2024 在 Rust 1.85.0(2025 年 2 月发布)中成为稳定版 - 如果您的 Rust 版本较旧(例如 2024 年 8 月的 1.82.0),就会看到此错误 **解决方案:** 将 Rust 工具链更新到 1.85.0 或更高版本: ```bash # 更新到最新稳定版 Rust rustup update stable # 或者安装最新稳定版 rustup install stable # 验证更新 rustc --version # 应显示:rustc 1.85.0 或更高版本 ``` 更新后,重新执行构建命令。 **对于开发环境设置:** 如果您正在设置开发环境,请确保: - Rust ≥1.85.0(使用 `rustc --version` 检查) - Node.js ≥22.0.0(使用 `node --version` 检查) 详见 [CONTRIBUTING.md](../CONTRIBUTING.md) 获取完整的前置条件。 --- ### Linux:Ubuntu 24.04 构建报错 "Can't detect any appindicator library" **问题描述:** 在 Ubuntu 24.04 或更新版本上构建时,可能遇到以下错误: ```txt Can't detect any appindicator library ``` 或者在之前的版本中可能看到关于 Icon RGBA 的报错。 **解决方案:** 这是因为 Ubuntu 24.04+ 使用 `libayatana-appindicator3-dev` 替代了旧的 `libappindicator3-dev`。 请安装正确的依赖库: ```bash sudo apt-get update sudo apt-get install -y libayatana-appindicator3-dev ``` --- ### Linux:AppImage 构建失败,提示 "failed to run linuxdeploy" **问题描述:** 在 Linux 系统(Debian、Ubuntu、Arch 等)上构建 AppImage 时,可能遇到如下错误: ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file ``` **解决方案 1:自动 NO_STRIP 重试(推荐)** Pake CLI 已在 linuxdeploy 剥离失败时自动使用 `NO_STRIP=1` 进行二次构建。如果你希望一开始就跳过剥离步骤(或在脚本中使用),可以手动设置该变量: ```bash NO_STRIP=1 pake https://example.com --name MyApp --targets appimage ``` 这会绕过经常在某些 Linux 发行版上出现问题的库文件剥离过程。 **解决方案 2:安装系统依赖** 如果 NO_STRIP 不起作用,确保已安装所有必需的系统依赖: ```bash sudo apt update sudo apt install -y \ libdbus-1-dev \ libsoup-3.0-dev \ libjavascriptcoregtk-4.1-dev \ libwebkit2gtk-4.1-dev \ build-essential \ curl wget file \ libxdo-dev \ libssl-dev \ libgtk-3-dev \ libayatana-appindicator3-dev \ librsvg2-dev \ gnome-video-effects \ libglib2.0-dev \ libgirepository1.0-dev \ pkg-config ``` 然后再次尝试构建(也可以提前设置 `NO_STRIP=1`)。 **解决方案 3:改用 DEB 格式** DEB 包在基于 Debian 的系统上更稳定: ```bash pake https://example.com --name MyApp --targets deb ``` **解决方案 4:使用 Docker(需开放 FUSE)** 在干净的环境中构建,无需安装依赖。AppImage 工具需要访问 `/dev/fuse`,因此需要以特权模式运行(或显式授权 FUSE): ```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 可能属于 root,需要执行 `sudo chown $(id -nu):$(id -ng) ./output/MyApp.AppImage` 调整所有权。 **原因:** 这是 Tauri 的 linuxdeploy 工具的已知问题,在以下情况下可能失败: - 系统库的格式不兼容剥离操作 - 在较新的发行版上构建(Arch、Debian Trixie 等) - 缺少 WebKit2GTK 或 GTK 开发库 `NO_STRIP=1` 环境变量是 Tauri 社区推荐的官方解决方法。 --- ### Linux:"cargo: command not found" 即使已安装 Rust **问题描述:** 已安装 Rust 但 Pake 仍然提示 "cargo: command not found"。 **解决方案:** Pake CLI 会自动重新加载 Rust 环境,但如果问题仍然存在: ```bash # 在当前终端重新加载环境 source ~/.cargo/env # 或者重启终端 ``` 然后再次尝试构建。 --- ### Windows:首次构建时安装超时 **问题描述:** 在 Windows 上首次构建时,可能遇到: ```txt Error: Command timed out after 900000ms: "cd ... && pnpm install" ``` **原因分析:** Windows 首次安装可能较慢,原因包括: - 本地模块编译(需要 Visual Studio Build Tools) - 大量依赖下载(Tauri、Rust 工具链) - Windows Defender 实时扫描 - 网络连接问题 **解决方案 1:自动重试(内置)** Pake CLI 现在会在初次安装超时后自动使用国内镜像重试。只需等待重试完成即可。 **解决方案 2:手动安装依赖** 如果自动重试失败,可手动安装依赖: ```bash # 进入 pake-cli 安装目录 cd %LOCALAPPDATA%\pnpm\global\5\.pnpm\pake-cli@版本号\node_modules\pake-cli # 使用国内镜像安装 pnpm install --registry=https://registry.npmmirror.com # 然后重新构建 pake https://github.com --name GitHub ``` **解决方案 3:改善网络环境** - 使用稳定的网络连接 - 安装过程中临时关闭杀毒软件 - 必要时使用 VPN 或代理 **预期时间:** - 首次安装:Windows 上需要 10-15 分钟 - 后续构建:依赖已缓存,速度会快很多 --- ### Windows:缺少 Visual Studio 构建工具 **问题描述:** 构建失败,提示缺少 MSVC 或 Windows SDK。 **解决方案:** 安装 Visual Studio 构建工具: 1. 下载 [Visual Studio Build Tools](https://visualstudio.microsoft.com/zh-hans/downloads/#build-tools-for-visual-studio-2022) 2. 安装时选择"使用 C++ 的桌面开发" 3. ARM64 支持:在"单个组件"下额外选择"MSVC v143 - VS 2022 C++ ARM64 构建工具" --- ### macOS:构建失败,出现模块编译错误 **问题描述:** 在 macOS 26 Beta 或更新版本上,可能看到与 `CoreFoundation` 或 `_Builtin_float` 模块相关的错误。 **解决方案:** 创建配置文件以使用兼容的 SDK: ```bash cat > src-tauri/.cargo/config.toml << 'EOF' [env] MACOSX_DEPLOYMENT_TARGET = "15.0" SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" EOF ``` 此文件已在 `.gitignore` 中,不会被提交。 --- ## 运行时问题 ### 应用窗口太小/太大 **解决方案:** 构建时指定自定义尺寸: ```bash pake https://example.com --width 1200 --height 800 ``` 查看 [CLI 使用指南](cli-usage_CN.md#窗口选项) 了解所有窗口选项。 --- ### 应用图标显示不正确 **问题描述:** 自定义图标没有显示或显示默认图标。 **解决方案:** 确保为您的平台使用正确的图标格式: - **macOS**:`.icns` 格式 - **Windows**:`.ico` 格式 - **Linux**:`.png` 格式 ```bash # macOS pake https://example.com --icon ./icon.icns # Windows pake https://example.com --icon ./icon.ico # Linux pake https://example.com --icon ./icon.png ``` Pake 可以自动转换图标,但提供正确的格式更可靠。 --- ### 网站功能不工作(登录、上传等) **问题描述:** 某些网站功能在 Pake 应用中无法工作。 **解决方案:** 这通常是由于 Web 兼容性问题。尝试: 1. **设置自定义 User Agent:** ```bash pake https://example.com --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" ``` 2. **注入自定义 JavaScript:** ```bash pake https://example.com --inject ./fix.js ``` 对于需要定时刷新的页面,建议把这类行为放在一个小的注入脚本里,而不是增加专门的 Pake 参数: ```javascript function isEditing(element) { if (!element) return false; const tagName = element.tagName; return ( element.isContentEditable || tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT" ); } setInterval(() => { if (!document.hidden && !isEditing(document.activeElement)) { window.location.reload(); } }, 300000); ``` 将其保存为 `refresh.js`,然后这样打包: ```bash pake https://news.ycombinator.com --name HackerNews --inject ./refresh.js ``` 3. **检查网站是否需要 WebView 中可能不可用的特定权限** 4. **注意嵌入式 WebView 的登录限制** 某些认证提供方,尤其是 Google,可能会阻止在嵌入式 WebView 中完成登录。由于 Pake 是把网站包装进桌面 WebView,Google 自家站点或依赖 Google OAuth 的网站,即使启用了 `--new-window` 或 `--multi-window`,也仍然可能无法在应用内完成登录。这属于提供方策略限制,不是打包逻辑错误。遇到这种情况时,建议改用普通浏览器、浏览器安装版站点应用,或官方原生桌面客户端。 --- ## 安装问题 ### 全局安装时权限被拒绝 **问题描述:** `npm install -g pake-cli` 失败,提示权限错误。 **解决方案:** 使用以下方法之一: ```bash # 方案 1:使用 npx(无需安装) npx pake-cli https://example.com # 方案 2:修复 npm 权限 npm config set prefix ~/.npm-global echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.bashrc source ~/.bashrc npm install -g pake-cli # 方案 3:使用 pnpm(推荐) pnpm install -g pake-cli ``` --- ## 获取帮助 如果您的问题未在此处涵盖: 1. 查看 [CLI 使用指南](cli-usage_CN.md) 了解详细参数文档 2. 参阅 [高级用法](advanced-usage_CN.md) 了解前置条件和系统设置 3. 搜索 [现有的 GitHub issues](https://github.com/tw93/Pake/issues) 4. [提交新 issue](https://github.com/tw93/Pake/issues/new) 时请包含: - 您的操作系统和版本 - Node.js 和 Rust 版本(`node --version`、`rustc --version`) - 完整的错误信息 - 您使用的构建命令 ### Linux: 打包失败,提示 `Can't detect any appindicator library` **问题描述:** 在 Linux 上打包时,构建失败并显示以下错误: ```txt Can't detect any appindicator library ``` **原因分析:** 这个错误表示您的 Linux 系统缺少创建“系统托盘图标”所需的核心库 `libappindicator`。Pake 打包的应用支持系统托盘功能,因此该库是必需的。 **解决方案:** 您需要在您的 Linux 系统上安装这个缺失的开发库。 - **对于 Debian / Ubuntu 系统:** ```bash sudo apt-get update && sudo apt-get install -y libappindicator3-dev ``` - **对于 Fedora / CentOS / RHEL 系统:** ```bash sudo dnf install -y libappindicator-devel ``` 为了确保打包环境的完整性,推荐一次性安装所有 Tauri 所需的依赖。请参考本文档中关于 `failed to run linuxdeploy` 问题的解决方案,其中包含了完整的依赖列表。 ================================================ FILE: docs/github-actions-usage.md ================================================ # GitHub Actions Usage Guide

English | 简体中文

Build Pake apps online without installing development tools locally. ## Quick Steps ### 1. Fork Repository [Fork this project](https://github.com/tw93/Pake/fork) ### 2. Run Workflow 1. Go to Actions tab in your forked repository 2. Select `Build App With Pake CLI` 3. Fill in the form (same parameters as [CLI options](cli-usage.md)) 4. Click `Run Workflow` ![Actions Interface](https://raw.githubusercontent.com/tw93/static/main/pake/action.png) ### 3. Download App - Green checkmark = build success - Click the workflow name to view details - Find `Artifacts` section and download your app ![Build Success](https://raw.githubusercontent.com/tw93/static/main/pake/action2.png) ### 4. Build Times - **First run**: ~10-15 minutes (sets up cache) - **Subsequent runs**: ~5 minutes (uses cache) - Cache size: 400-600MB when complete ## Tips - Be patient on first run - let cache build completely - Stable network connection recommended - If build fails, delete cache and retry ## Links - [CLI Documentation](cli-usage.md) - [Advanced Usage](advanced-usage.md) ================================================ FILE: docs/github-actions-usage_CN.md ================================================ # GitHub Actions 使用指南

English | 简体中文

无需本地安装开发工具,在线构建 Pake 应用。 ## 快速步骤 ### 1. Fork 仓库 [Fork 此项目](https://github.com/tw93/Pake/fork) ### 2. 运行工作流 1. 前往你 Fork 的仓库的 Actions 页面 2. 选择 `Build App With Pake CLI` 3. 填写表单(参数与 [CLI 选项](cli-usage_CN.md) 相同) 4. 点击 `Run Workflow` ![Actions 界面](https://raw.githubusercontent.com/tw93/static/main/pake/action.png) ### 3. 下载应用 - 绿色勾号 = 构建成功 - 点击工作流名称查看详情 - 在 `Artifacts` 部分下载应用 ![构建成功](https://raw.githubusercontent.com/tw93/static/main/pake/action2.png) ### 4. 构建时间 - **首次运行**:约 10-15 分钟(建立缓存) - **后续运行**:约 5 分钟(使用缓存) - 缓存大小:完成时为 400-600MB ## 提示 - 首次运行需要耐心等待,让缓存完全建立 - 建议网络连接稳定 - 如果构建失败,删除缓存后重试 ## 链接 - [CLI 文档](cli-usage_CN.md) - [高级用法](advanced-usage_CN.md) ================================================ FILE: docs/pake-action.md ================================================ # Pake Action Transform any webpage into a lightweight desktop app with a single GitHub Actions step. > This guide shows how to use Pake as a GitHub Action in your own projects. For using our project's built-in GitHub Actions workflow, see [GitHub Actions Usage](github-actions-usage.md). ## Quick Start ```yaml - name: Build Pake App uses: tw93/Pake@v3 with: url: "https://example.com" name: "MyApp" ``` ## Inputs | Parameter | Description | Required | Default | | ------------ | ------------------------ | -------- | ------- | | `url` | Target URL to package | ✅ | | | `name` | Application name | ✅ | | | `output-dir` | Output directory | | `dist` | | `icon` | Custom app icon URL/path | | | | `width` | Window width | | `1200` | | `height` | Window height | | `780` | | `debug` | Enable debug mode | | `false` | ## Outputs | Output | Description | | -------------- | ----------------------------- | | `package-path` | Path to the generated package | ## Examples ### Basic Usage ```yaml name: Build Web App on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: tw93/Pake@v3 with: url: "https://weekly.tw93.fun" name: "WeeklyApp" ``` ### With Custom Icon ```yaml - uses: tw93/Pake@v3 with: url: "https://example.com" name: "MyApp" icon: "https://example.com/icon.png" width: 1400 height: 900 ``` ### Multi-Platform Build ```yaml jobs: build: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: tw93/Pake@v3 with: url: "https://example.com" name: "CrossPlatformApp" ``` ## How It Works 1. **Auto Setup**: Installs Rust, Node.js dependencies, builds Pake CLI 2. **Build App**: Runs `pake` command with your parameters 3. **Package Output**: Finds and moves the generated package to output directory ## Supported Platforms - **Linux**: `.deb` packages (Ubuntu runners) - **macOS**: `.app` and `.dmg` packages (macOS runners) - **Windows**: `.exe` and `.msi` packages (Windows runners) Use GitHub's matrix strategy to build for multiple platforms simultaneously. ## Related Documentation - [GitHub Actions Usage](github-actions-usage.md) - Using Pake's built-in workflow - [CLI Usage](cli-usage.md) - Command-line interface reference - [Advanced Usage](advanced-usage.md) - Customization options ================================================ FILE: icns2png.py ================================================ """ 批量将icns文件转成png文件 Batch convert ICNS files to PNG files """ import os try: from PIL import Image except ImportError: os.system("pip install Pillow") from PIL import Image if __name__ == "__main__": now_dir = os.path.dirname(os.path.abspath(__file__)) icons_dir = os.path.join(now_dir, "src-tauri", "icons") png_dir = os.path.join(now_dir, "src-tauri", "png") if not os.path.exists(png_dir): os.mkdir(png_dir) file_list = os.listdir(icons_dir) file_list = [file for file in file_list if file.endswith(".icns")] for file in file_list: icns_path = os.path.join(icons_dir, file) image = Image.open(icns_path) image_512 = image.copy().resize((512, 512)) image_256 = image.copy().resize((256, 256)) image_32 = image.copy().resize((32, 32)) image_name = os.path.splitext(file)[0] image_512_path = os.path.join(png_dir, image_name + "_512.png") image_256_path = os.path.join(png_dir, image_name + "_256.ico") image_32_path = os.path.join(png_dir, image_name + "_32.ico") image_512.save(image_512_path, "PNG") image_256.save(image_256_path, "ICO") image_32.save(image_32_path, "ICO") print("png file write success.") print(f"There are {len(os.listdir(png_dir))} png picture in ", png_dir) ================================================ FILE: package.json ================================================ { "name": "pake-cli", "version": "3.10.1", "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" }, "repository": { "type": "git", "url": "https://github.com/tw93/pake.git" }, "author": { "name": "Tw93", "email": "tw93@qq.com" }, "keywords": [ "pake", "pake-cli", "rust", "tauri", "no-electron", "productivity" ], "files": [ "dist", "src-tauri" ], "scripts": { "start": "pnpm run dev", "dev": "pnpm run tauri dev", "build": "tauri build", "build:debug": "tauri build --debug", "build:mac": "tauri build --target universal-apple-darwin", "analyze": "cd src-tauri && cargo bloat --release --crates", "tauri": "tauri", "cli": "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", "update": "pnpm update --verbose && cd src-tauri && cargo update", "prepublishOnly": "pnpm run cli:build" }, "type": "module", "exports": "./dist/cli.js", "license": "MIT", "dependencies": { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", "chalk": "^5.6.2", "commander": "^14.0.3", "execa": "^9.6.1", "file-type": "^21.3.0", "fs-extra": "^11.3.3", "icon-gen": "^5.0.0", "loglevel": "^1.9.2", "ora": "^9.3.0", "prompts": "^2.4.2", "psl": "^1.15.0", "sharp": "^0.34.5", "tmp-promise": "^3.0.3", "update-notifier": "^7.3.1" }, "devDependencies": { "@rollup/plugin-alias": "^6.0.0", "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^0.4.4", "@types/fs-extra": "^11.0.4", "@types/node": "^25.3.2", "@types/page-icon": "^0.3.6", "@types/prompts": "^2.4.9", "@types/tmp": "^0.2.6", "@types/update-notifier": "^6.0.8", "app-root-path": "^3.1.0", "cross-env": "^10.1.0", "prettier": "^3.8.1", "rollup": "^4.59.0", "rollup-plugin-typescript2": "^0.36.0", "tslib": "^2.8.1", "typescript": "^5.9.3", "vitest": "^4.0.18" }, "pnpm": { "overrides": { "sharp": "^0.34.5" }, "onlyBuiltDependencies": [ "esbuild", "sharp" ] } } ================================================ FILE: rollup.config.js ================================================ import path from "path"; import fs from "fs"; import appRootPath from "app-root-path"; import typescript from "rollup-plugin-typescript2"; import alias from "@rollup/plugin-alias"; import commonjs from "@rollup/plugin-commonjs"; import json from "@rollup/plugin-json"; import replace from "@rollup/plugin-replace"; import chalk from "chalk"; import { spawn, exec } from "child_process"; // Set macOS SDK environment variables for compatibility if (process.platform === "darwin") { process.env.MACOSX_DEPLOYMENT_TARGET = process.env.MACOSX_DEPLOYMENT_TARGET || "14.0"; process.env.CFLAGS = process.env.CFLAGS || "-fno-modules"; process.env.CXXFLAGS = process.env.CXXFLAGS || "-fno-modules"; } const isProduction = process.env.NODE_ENV === "production"; const devPlugins = !isProduction ? [pakeCliDevPlugin()] : []; export default { input: isProduction ? "bin/cli.ts" : "bin/dev.ts", output: { file: isProduction ? "dist/cli.js" : "dist/dev.js", format: "es", sourcemap: !isProduction, banner: isProduction ? "#!/usr/bin/env node" : "", }, watch: { include: "bin/**", exclude: "node_modules/**", }, external: (id) => { if (id === "bin/cli.ts" || id === "bin/dev.ts") return false; if (id.startsWith(".") || path.isAbsolute(id) || id.startsWith("@/")) return false; return true; }, onwarn(warning, warn) { if (warning.code === "UNRESOLVED_IMPORT") { return; } warn(warning); }, plugins: [ typescript({ tsconfig: "./tsconfig.json", sourceMap: !isProduction, inlineSources: !isProduction, noEmitOnError: false, compilerOptions: { target: "es2020", module: "esnext", moduleResolution: "node", esModuleInterop: true, allowSyntheticDefaultImports: true, }, }), json(), commonjs(), replace({ "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), preventAssignment: true, }), alias({ entries: [{ find: "@", replacement: path.join(appRootPath.path, "bin") }], }), ...devPlugins, ], }; function pakeCliDevPlugin() { let devChildProcess; let cliChildProcess; let devHasStarted = false; // 智能检测包管理器 const detectPackageManager = () => { if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"; if (fs.existsSync("yarn.lock")) return "yarn"; return "npm"; }; return { name: "pake-cli-dev-plugin", buildEnd() { const command = "node"; // Pass through arguments, ignoring the first 2 (node rollup) and filtering out rollup-specifics // We need to keep only arguments meant for our CLI script const args = process.argv.slice(2).filter((arg) => { // Filter out typical rollup flags if they are mixed in // This is a simplistic filter, might need adjustment based on how npm script invokes rollup return !["-c", "-w", "--config", "--watch"].includes(arg); }); const cliCmdArgs = ["./dist/dev.js", ...args]; cliChildProcess = spawn(command, cliCmdArgs, { detached: true }); cliChildProcess.stdout.on("data", (data) => { console.log(chalk.green(data.toString())); }); cliChildProcess.stderr.on("data", (data) => { console.error(chalk.yellow(data.toString())); }); cliChildProcess.on("close", async (code) => { console.log(chalk.yellow(`cli running end with code: ${code}`)); if (devHasStarted) return; devHasStarted = true; const packageManager = detectPackageManager(); const command = `${packageManager} run tauri dev --config ./src-tauri/.pake/tauri.conf.json -- --features cli-build`; devChildProcess = exec(command); devChildProcess.stdout.on("data", (data) => { console.log(chalk.green(data.toString())); }); devChildProcess.stderr.on("data", (data) => { console.error(chalk.yellow(data.toString())); }); devChildProcess.on("close", (code) => { console.log(chalk.yellow(`dev running end: ${code}`)); process.exit(code); }); }); }, }; } ================================================ FILE: rust-toolchain.toml ================================================ [toolchain] channel = "1.93.0" components = ["rustfmt", "clippy"] ================================================ FILE: src-tauri/.gitignore ================================================ # Generated by Cargo # will have compiled files and executables /target/ ================================================ FILE: src-tauri/Cargo.toml ================================================ [package] name = "pake" version = "3.10.1" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" repository = "https://github.com/tw93/Pake" edition = "2021" rust-version = "1.85.0" [lib] name = "app_lib" crate-type = ["staticlib", "cdylib", "lib"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] tauri-build = { version = "2.5.5", features = [] } [dependencies] serde_json = "1.0.149" serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.49.0", features = ["full"] } tauri = { version = "2.10.2", features = [ "tray-icon", "image-ico", "image-png", "macos-proxy", ] } tauri-plugin-window-state = "2.4.1" tauri-plugin-oauth = "2.0.0" tauri-plugin-http = "2.5.7" tauri-plugin-global-shortcut = { version = "2.3.1" } tauri-plugin-shell = { version = "2.3.5" } tauri-plugin-opener = { version = "2.5.3" } tauri-plugin-single-instance = "2.4.0" tauri-plugin-notification = "2.3.3" [features] # this feature is used for development builds from development cli cli-build = [] # by default Tauri runs in production mode # when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL default = ["custom-protocol"] # this feature is used for production builds where `devPath` points to the filesystem # DO NOT remove this custom-protocol = ["tauri/custom-protocol"] [profile.release] panic = "abort" codegen-units = 16 lto = "thin" opt-level = "z" strip = true ================================================ FILE: src-tauri/Info.plist ================================================ NSCameraUsageDescription Request camera access NSMicrophoneUsageDescription Request microphone access NSAppTransportSecurity NSAllowsArbitraryLoads ================================================ FILE: src-tauri/assets/main.wxs ================================================ {{#if allow_downgrades}} {{else}} {{/if}} Installed AND NOT UPGRADINGPRODUCTCODE {{#if banner_path}} {{/if}} {{#if dialog_image_path}} {{/if}} {{#if license}} {{/if}} {{#if homepage}} {{/if}} WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed {{#unless license}} 1 1 {{/unless}} {{#each deep_link_protocols as |protocol| ~}} {{/each~}} {{#each file_associations as |association| ~}} {{#each association.ext as |ext| ~}} {{/each~}} {{/each~}} {{#each binaries as |bin| ~}} {{/each~}} {{#if enable_elevated_update_task}} {{/if}} {{resources}} {{#each merge_modules as |msm| ~}} {{/each~}} {{#each resource_file_ids as |resource_file_id| ~}} {{/each~}} {{#if enable_elevated_update_task}} {{/if}} {{#each binaries as |bin| ~}} {{/each~}} {{#each component_group_refs as |id| ~}} {{/each~}} {{#each component_refs as |id| ~}} {{/each~}} {{#each feature_group_refs as |id| ~}} {{/each~}} {{#each feature_refs as |id| ~}} {{/each~}} {{#each merge_refs as |id| ~}} {{/each~}} {{#if install_webview}} {{#if download_bootstrapper}} {{/if}} {{#if webview2_bootstrapper_path}} {{/if}} {{#if webview2_installer_path}} {{/if}} {{/if}} {{#if enable_elevated_update_task}} NOT(REMOVE) (REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE {{/if}} AUTOLAUNCHAPP AND NOT Installed ================================================ FILE: src-tauri/build.rs ================================================ fn main() { tauri_build::build() } ================================================ FILE: src-tauri/capabilities/default.json ================================================ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "pake-capability", "description": "Capability for the pake app.", "webviews": ["pake"], "remote": { "urls": ["https://*.*"] }, "permissions": [ "shell:allow-open", "core:window:allow-theme", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", "core:window:allow-is-fullscreen", "core:window:allow-set-fullscreen", "core:window:allow-set-resizable", "core:window:allow-maximize", "core:window:allow-minimize", "core:window:allow-close", "core:webview:allow-internal-toggle-devtools", "notification:allow-is-permission-granted", "notification:allow-notify", "notification:allow-get-active", "notification:allow-register-listener", "notification:allow-register-action-types", "notification:default", "core:path:default" ] } ================================================ FILE: src-tauri/entitlements.plist ================================================ ================================================ FILE: src-tauri/pake.json ================================================ { "windows": [ { "url": "https://weekly.tw93.fun/en", "url_type": "web", "hide_title_bar": true, "fullscreen": false, "width": 1200, "height": 780, "resizable": true, "always_on_top": false, "dark_mode": false, "activation_shortcut": "", "disabled_web_shortcuts": false, "hide_on_close": true, "incognito": false, "enable_wasm": false, "enable_drag_drop": false, "maximize": false, "start_to_tray": false, "force_internal_navigation": false, "internal_url_regex": "", "new_window": false } ], "user_agent": { "macos": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.2 Safari/605.1.15", "linux": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", "windows": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36" }, "system_tray": { "macos": false, "linux": true, "windows": true }, "system_tray_path": "icons/icon.png", "inject": [], "proxy_url": "", "multi_instance": false, "multi_window": false } ================================================ FILE: src-tauri/rust_proxy.toml ================================================ [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 ================================================ FILE: src-tauri/src/app/config.rs ================================================ use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WindowConfig { pub url: String, pub hide_title_bar: bool, pub fullscreen: bool, pub maximize: bool, pub width: f64, pub height: f64, pub resizable: bool, pub url_type: String, pub always_on_top: bool, pub dark_mode: bool, pub disabled_web_shortcuts: bool, pub activation_shortcut: String, pub hide_on_close: bool, pub incognito: bool, pub title: Option, pub enable_wasm: bool, pub enable_drag_drop: bool, #[serde(default)] pub new_window: bool, pub start_to_tray: bool, #[serde(default)] pub force_internal_navigation: bool, #[serde(default)] pub internal_url_regex: String, #[serde(default = "default_zoom")] pub zoom: u32, #[serde(default)] pub min_width: f64, #[serde(default)] pub min_height: f64, #[serde(default)] pub ignore_certificate_errors: bool, } fn default_zoom() -> u32 { 100 } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PlatformSpecific { pub macos: T, pub linux: T, pub windows: T, } impl PlatformSpecific { pub const fn get(&self) -> &T { #[cfg(target_os = "macos")] let platform = &self.macos; #[cfg(target_os = "linux")] let platform = &self.linux; #[cfg(target_os = "windows")] let platform = &self.windows; platform } } impl PlatformSpecific where T: Copy, { pub const fn copied(&self) -> T { *self.get() } } pub type UserAgent = PlatformSpecific; pub type FunctionON = PlatformSpecific; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct PakeConfig { pub windows: Vec, pub user_agent: UserAgent, pub system_tray: FunctionON, pub system_tray_path: String, pub proxy_url: String, #[serde(default)] pub multi_instance: bool, #[serde(default)] pub multi_window: bool, } impl PakeConfig { pub fn show_system_tray(&self) -> bool { self.system_tray.copied() } } ================================================ FILE: src-tauri/src/app/invoke.rs ================================================ use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType}; use std::fs::{self, File}; use std::io::Write; use std::str::FromStr; 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, } #[derive(serde::Deserialize)] pub struct BinaryDownloadParams { filename: String, binary: Vec, language: Option, } #[derive(serde::Deserialize)] pub struct NotificationParams { title: String, body: String, icon: String, } #[command] pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> 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); let client = ClientBuilder::new() .build() .map_err(|e| format!("Failed to build client: {}", e))?; let url = Url::from_str(¶ms.url).map_err(|e| format!("Invalid URL: {}", e))?; let request = Request::new(Method::GET, url); let response = client.execute(request).await; match response { Ok(mut res) => { let mut file = File::create(file_path).map_err(|e| format!("Failed to create file: {}", e))?; while let Some(chunk) = res .chunk() .await .map_err(|e| format!("Failed to get chunk: {}", e))? { file.write_all(&chunk) .map_err(|e| format!("Failed to write chunk: {}", e))?; } 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 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; app.notification() .builder() .title(¶ms.title) .body(¶ms.body) .icon(¶ms.icon) .show() .map_err(|e| format!("Failed to show notification: {}", e))?; Ok(()) } #[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; } } #[command] #[allow(unreachable_code)] pub fn clear_cache_and_restart(app: AppHandle) -> Result<(), 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()) } } ================================================ FILE: src-tauri/src/app/menu.rs ================================================ // Menu functionality is only used on macOS #![cfg(target_os = "macos")] 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> { let pake_version = env!("CARGO_PKG_VERSION"); let pake_menu_item_title = format!("Built with Pake V{}", pake_version); let menu = Menu::with_items( app, &[ &app_menu(app)?, &file_menu(app, allow_multi_window)?, &edit_menu(app)?, &view_menu(app)?, &navigation_menu(app)?, &window_menu(app)?, &help_menu(app, &pake_menu_item_title)?, ], )?; Ok(menu) } fn app_menu(app: &AppHandle) -> tauri::Result> { let app_menu = Submenu::new(app, "Pake", true)?; let about_metadata = AboutMetadata::default(); app_menu.append(&PredefinedMenuItem::about( app, Some("Pake"), Some(about_metadata), )?)?; app_menu.append(&PredefinedMenuItem::separator(app)?)?; app_menu.append(&PredefinedMenuItem::services(app, None)?)?; app_menu.append(&PredefinedMenuItem::separator(app)?)?; app_menu.append(&PredefinedMenuItem::hide(app, None)?)?; app_menu.append(&PredefinedMenuItem::hide_others(app, None)?)?; app_menu.append(&PredefinedMenuItem::show_all(app, None)?)?; app_menu.append(&PredefinedMenuItem::separator(app)?)?; app_menu.append(&PredefinedMenuItem::quit(app, None)?)?; Ok(app_menu) } fn file_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result> { let file_menu = Submenu::new(app, "File", true)?; if allow_multi_window { file_menu.append(&MenuItem::with_id( app, "new_window", "New Window", true, Some("CmdOrCtrl+N"), )?)?; file_menu.append(&PredefinedMenuItem::separator(app)?)?; } file_menu.append(&PredefinedMenuItem::close_window(app, None)?)?; file_menu.append(&PredefinedMenuItem::separator(app)?)?; file_menu.append(&MenuItem::with_id( app, "clear_cache_restart", "Clear Cache & Restart", true, Some("CmdOrCtrl+Shift+Backspace"), )?)?; Ok(file_menu) } fn edit_menu(app: &AppHandle) -> tauri::Result> { let edit_menu = Submenu::new(app, "Edit", true)?; edit_menu.append(&PredefinedMenuItem::undo(app, None)?)?; edit_menu.append(&PredefinedMenuItem::redo(app, None)?)?; edit_menu.append(&PredefinedMenuItem::separator(app)?)?; edit_menu.append(&PredefinedMenuItem::cut(app, None)?)?; edit_menu.append(&PredefinedMenuItem::copy(app, None)?)?; edit_menu.append(&PredefinedMenuItem::paste(app, None)?)?; edit_menu.append(&MenuItem::with_id( app, "paste_and_match_style", "Paste and Match Style", true, Some("CmdOrCtrl+Shift+Option+V"), )?)?; edit_menu.append(&PredefinedMenuItem::select_all(app, None)?)?; edit_menu.append(&PredefinedMenuItem::separator(app)?)?; edit_menu.append(&MenuItem::with_id( app, "copy_url", "Copy URL", true, Some("CmdOrCtrl+L"), )?)?; Ok(edit_menu) } fn view_menu(app: &AppHandle) -> tauri::Result> { let view_menu = Submenu::new(app, "View", true)?; view_menu.append(&MenuItem::with_id( app, "reload", "Reload", true, Some("CmdOrCtrl+R"), )?)?; view_menu.append(&PredefinedMenuItem::separator(app)?)?; view_menu.append(&MenuItem::with_id( app, "zoom_in", "Zoom In", true, Some("CmdOrCtrl+="), )?)?; view_menu.append(&MenuItem::with_id( app, "zoom_out", "Zoom Out", true, Some("CmdOrCtrl+-"), )?)?; view_menu.append(&MenuItem::with_id( app, "zoom_reset", "Actual Size", true, Some("CmdOrCtrl+0"), )?)?; view_menu.append(&PredefinedMenuItem::separator(app)?)?; view_menu.append(&PredefinedMenuItem::fullscreen(app, None)?)?; view_menu.append(&PredefinedMenuItem::separator(app)?)?; view_menu.append(&MenuItem::with_id( app, "toggle_devtools", "Toggle Developer Tools", cfg!(debug_assertions), Some("CmdOrCtrl+Option+I"), )?)?; Ok(view_menu) } fn navigation_menu(app: &AppHandle) -> tauri::Result> { let navigation_menu = Submenu::new(app, "Navigation", true)?; navigation_menu.append(&MenuItem::with_id( app, "go_back", "Back", true, Some("CmdOrCtrl+["), )?)?; navigation_menu.append(&MenuItem::with_id( app, "go_forward", "Forward", true, Some("CmdOrCtrl+]"), )?)?; navigation_menu.append(&MenuItem::with_id( app, "go_home", "Go Home", true, Some("CmdOrCtrl+Shift+H"), )?)?; Ok(navigation_menu) } fn window_menu(app: &AppHandle) -> tauri::Result> { let window_menu = Submenu::new(app, "Window", true)?; window_menu.append(&PredefinedMenuItem::minimize(app, None)?)?; window_menu.append(&PredefinedMenuItem::maximize(app, None)?)?; window_menu.append(&PredefinedMenuItem::separator(app)?)?; window_menu.append(&MenuItem::with_id( app, "always_on_top", "Toggle Always on Top", true, None::<&str>, )?)?; window_menu.append(&PredefinedMenuItem::separator(app)?)?; window_menu.append(&PredefinedMenuItem::close_window(app, None)?)?; Ok(window_menu) } fn help_menu(app: &AppHandle, title: &str) -> tauri::Result> { let help_menu = Submenu::new(app, "Help", true)?; let github_item = MenuItem::with_id(app, "pake_github_link", title, true, None::<&str>)?; help_menu.append(&github_item)?; Ok(help_menu) } pub fn handle_menu_click(app_handle: &AppHandle, id: &str) { match id { "new_window" => { open_additional_window_safe(app_handle); } "pake_github_link" => { let _ = app_handle .opener() .open_url("https://github.com/tw93/Pake", None::<&str>); } "reload" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("window.location.reload()"); } } "toggle_devtools" => { #[cfg(debug_assertions)] // Only allow in debug builds if let Some(window) = app_handle.get_webview_window("pake") { if window.is_devtools_open() { window.close_devtools(); } else { window.open_devtools(); } } } "zoom_in" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("zoomIn()"); } } "zoom_out" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("zoomOut()"); } } "zoom_reset" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("setZoom('100%')"); } } "go_back" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("window.history.back()"); } } "go_forward" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("window.history.forward()"); } } "go_home" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("window.location.href = window.pakeConfig.url"); } } "copy_url" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("navigator.clipboard.writeText(window.location.href)"); } } "paste_and_match_style" => { if let Some(window) = app_handle.get_webview_window("pake") { let _ = window.eval("triggerPasteAsPlainText()"); } } "clear_cache_restart" => { if let Some(window) = app_handle.get_webview_window("pake") { if let Ok(_) = window.clear_all_browsing_data() { app_handle.restart(); } } } "always_on_top" => { if let Some(window) = app_handle.get_webview_window("pake") { let is_on_top = window.is_always_on_top().unwrap_or(false); let _ = window.set_always_on_top(!is_on_top); } } _ => {} } } ================================================ FILE: src-tauri/src/app/mod.rs ================================================ pub mod config; pub mod invoke; #[cfg(target_os = "macos")] pub mod menu; pub mod setup; pub mod window; ================================================ FILE: src-tauri/src/app/setup.rs ================================================ use crate::app::window::open_additional_window_safe; use std::str::FromStr; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::{ menu::{MenuBuilder, MenuItemBuilder}, tray::{TrayIconBuilder, TrayIconEvent}, AppHandle, Manager, }; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut}; use tauri_plugin_window_state::{AppHandleExt, StateFlags}; pub fn set_system_tray( app: &AppHandle, show_system_tray: bool, tray_icon_path: &str, _init_fullscreen: bool, allow_multi_window: bool, ) -> tauri::Result<()> { if !show_system_tray { app.remove_tray_by_id("pake-tray"); return Ok(()); } let new_window = MenuItemBuilder::with_id("new_window", "New Window").build(app)?; let hide_app = MenuItemBuilder::with_id("hide_app", "Hide").build(app)?; let show_app = MenuItemBuilder::with_id("show_app", "Show").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; let menu = if allow_multi_window { MenuBuilder::new(app) .items(&[&new_window, &hide_app, &show_app, &quit]) .build()? } else { MenuBuilder::new(app) .items(&[&hide_app, &show_app, &quit]) .build()? }; app.app_handle().remove_tray_by_id("pake-tray"); let tray = TrayIconBuilder::new() .menu(&menu) .on_menu_event(move |app, event| match event.id().as_ref() { "new_window" => { open_additional_window_safe(app); } "hide_app" => { if let Some(window) = app.get_webview_window("pake") { window.minimize().unwrap(); } } "show_app" => { if let Some(window) = app.get_webview_window("pake") { window.show().unwrap(); #[cfg(target_os = "linux")] if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) { let _ = window.set_fullscreen(true); let _ = window.set_focus(); } } } "quit" => { app.save_window_state(StateFlags::all()).unwrap(); std::process::exit(0); } _ => (), }) .on_tray_icon_event(move |tray, event| match event { TrayIconEvent::Click { button, .. } => { 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); if is_visible { window.hide().unwrap(); } else { window.show().unwrap(); window.set_focus().unwrap(); #[cfg(target_os = "linux")] if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) { let _ = window.set_fullscreen(true); } } } } } _ => {} }) .icon(if tray_icon_path.is_empty() { app.default_window_icon() .unwrap_or_else(|| panic!("Failed to get default window icon")) .clone() } else { tauri::image::Image::from_path(tray_icon_path).unwrap_or_else(|_| { // If custom tray icon fails to load, fallback to default app.default_window_icon() .unwrap_or_else(|| panic!("Failed to get default window icon")) .clone() }) }) .build(app)?; tray.set_icon_as_template(false)?; Ok(()) } pub fn set_global_shortcut( app: &AppHandle, shortcut: String, _init_fullscreen: bool, ) -> tauri::Result<()> { if shortcut.is_empty() { return Ok(()); } let app_handle = app.clone(); let shortcut_hotkey = Shortcut::from_str(&shortcut).unwrap(); 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 mut last_triggered = last_triggered.lock().unwrap(); 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(); if is_visible { window.hide().unwrap(); } else { window.show().unwrap(); window.set_focus().unwrap(); #[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"); app.global_shortcut().register(shortcut_hotkey).unwrap(); Ok(()) } ================================================ FILE: src-tauri/src/app/window.rs ================================================ use crate::app::config::PakeConfig; use crate::util::get_data_dir; use std::{path::PathBuf, str::FromStr, sync::Mutex}; use tauri::{ webview::{NewWindowFeatures, NewWindowResponse}, AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; #[cfg(target_os = "macos")] use tauri::{Theme, TitleBarStyle}; #[cfg(target_os = "windows")] fn build_proxy_browser_arg(url: &Url) -> Option { let host = url.host_str()?; let scheme = url.scheme(); let port = url.port().or_else(|| match scheme { "http" => Some(80), "socks5" => Some(1080), _ => None, })?; match scheme { "http" | "socks5" => Some(format!("--proxy-server={scheme}://{host}:{port}")), _ => None, } } pub struct MultiWindowState { pub pake_config: PakeConfig, pub tauri_config: Config, next_window_index: Mutex, } impl MultiWindowState { pub fn new(pake_config: PakeConfig, tauri_config: Config) -> Self { Self { pake_config, tauri_config, next_window_index: Mutex::new(0), } } fn next_window_label(&self) -> String { let mut index = self.next_window_index.lock().unwrap(); *index += 1; format!("pake-{}", *index) } } 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 open_additional_window(app: &AppHandle) -> tauri::Result { let state = app.state::(); let label = state.next_window_label(); build_window_with_label(app, &state.pake_config, &state.tauri_config, &label) } struct WindowBuildOptions<'a> { label: &'a str, url: WebviewUrl, visible: bool, new_window_features: Option, } fn open_requested_window( app: &AppHandle, config: &PakeConfig, tauri_config: &Config, target_url: Url, features: NewWindowFeatures, ) -> tauri::Result { let state = app.state::(); let label = state.next_window_label(); let window = build_window( app, config, tauri_config, WindowBuildOptions { label: &label, url: WebviewUrl::External("about:blank".parse().unwrap()), visible: true, new_window_features: Some(features), }, )?; let title = target_url.host_str().unwrap_or(target_url.as_str()); let _ = window.set_title(title); let _ = window.set_focus(); Ok(window) } pub fn open_additional_window_safe(app: &AppHandle) { #[cfg(target_os = "windows")] { let app_handle = app.clone(); std::thread::spawn(move || { if let Ok(window) = open_additional_window(&app_handle) { let _ = window.show(); let _ = window.set_focus(); } }); } #[cfg(not(target_os = "windows"))] { if let Ok(window) = open_additional_window(app) { let _ = window.show(); let _ = window.set_focus(); } } } fn build_window_with_label( app: &AppHandle, config: &PakeConfig, tauri_config: &Config, label: &str, ) -> tauri::Result { let window_config = config .windows .first() .expect("At least one window configuration is required"); let url = match window_config.url_type.as_str() { "web" => WebviewUrl::App(window_config.url.parse().unwrap()), "local" => WebviewUrl::App(PathBuf::from(&window_config.url)), _ => panic!("url type can only be web or local"), }; build_window( app, config, tauri_config, WindowBuildOptions { label, url, visible: false, new_window_features: None, }, ) } fn build_window( app: &AppHandle, config: &PakeConfig, tauri_config: &Config, opts: WindowBuildOptions, ) -> tauri::Result { let WindowBuildOptions { label, url, visible, new_window_features, } = opts; let package_name = tauri_config.clone().product_name.unwrap(); let _data_dir = get_data_dir(app, package_name); let window_config = config .windows .first() .expect("At least one window configuration is required"); let user_agent = config.user_agent.get(); let config_script = format!( "window.pakeConfig = {}", serde_json::to_string(&window_config).unwrap() ); // Platform-specific title: macOS prefers empty, others fallback to product name let effective_title = window_config.title.as_deref().unwrap_or_else(|| { if cfg!(target_os = "macos") { "" } else { tauri_config.product_name.as_deref().unwrap_or("") } }); let mut window_builder = WebviewWindowBuilder::new(app, label, url) .title(effective_title) .visible(visible) .user_agent(user_agent) .resizable(window_config.resizable) .maximized(window_config.maximize); #[cfg(target_os = "windows")] { let scale_factor = app .primary_monitor() .ok() .flatten() .map(|m| m.scale_factor()) .unwrap_or(1.0); let logical_width = window_config.width / scale_factor; let logical_height = window_config.height / scale_factor; window_builder = window_builder.inner_size(logical_width, logical_height); } #[cfg(not(target_os = "windows"))] { window_builder = window_builder.inner_size(window_config.width, window_config.height); } window_builder = window_builder .always_on_top(window_config.always_on_top) .incognito(window_config.incognito); #[cfg(any(target_os = "windows", target_os = "macos"))] { window_builder = window_builder.fullscreen(window_config.fullscreen); } if window_config.min_width > 0.0 || window_config.min_height > 0.0 { let min_w = if window_config.min_width > 0.0 { window_config.min_width } else { window_config.width }; let min_h = if window_config.min_height > 0.0 { window_config.min_height } else { window_config.height }; window_builder = window_builder.min_inner_size(min_w, min_h); } if !window_config.enable_drag_drop { window_builder = window_builder.disable_drag_drop_handler(); } if window_config.new_window { let app_handle = app.clone(); let popup_config = config.clone(); let popup_tauri_config = tauri_config.clone(); window_builder = window_builder.on_new_window(move |target_url, features| { match open_requested_window( &app_handle, &popup_config, &popup_tauri_config, target_url, features, ) { Ok(window) => NewWindowResponse::Create { window }, Err(error) => { eprintln!("[Pake] Failed to open requested window: {error}"); NewWindowResponse::Deny } } }); } // Add initialization scripts window_builder = window_builder .initialization_script(&config_script) .initialization_script(include_str!("../inject/component.js")) .initialization_script(include_str!("../inject/event.js")) .initialization_script(include_str!("../inject/style.js")) .initialization_script(include_str!("../inject/theme_refresh.js")) .initialization_script(include_str!("../inject/auth.js")) .initialization_script(include_str!("../inject/custom.js")); #[cfg(target_os = "windows")] let mut windows_browser_args = String::from("--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-blink-features=AutomationControlled"); #[cfg(target_os = "linux")] let mut linux_browser_args = String::from("--disable-blink-features=AutomationControlled"); if window_config.ignore_certificate_errors { #[cfg(target_os = "windows")] { windows_browser_args.push_str(" --ignore-certificate-errors"); } #[cfg(target_os = "linux")] { linux_browser_args.push_str(" --ignore-certificate-errors"); } #[cfg(target_os = "macos")] { window_builder = window_builder.additional_browser_args("--ignore-certificate-errors"); } } if window_config.enable_wasm { #[cfg(target_os = "windows")] { windows_browser_args.push_str(" --enable-features=SharedArrayBuffer"); windows_browser_args.push_str(" --enable-unsafe-webgpu"); } #[cfg(target_os = "linux")] { linux_browser_args.push_str(" --enable-features=SharedArrayBuffer"); linux_browser_args.push_str(" --enable-unsafe-webgpu"); } #[cfg(target_os = "macos")] { window_builder = window_builder .additional_browser_args("--enable-features=SharedArrayBuffer") .additional_browser_args("--enable-unsafe-webgpu"); } } let mut parsed_proxy_url: Option = None; // Platform-specific configuration must be set before proxy on Windows/Linux #[cfg(target_os = "macos")] { let title_bar_style = if window_config.hide_title_bar { TitleBarStyle::Overlay } else { 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); if !config.proxy_url.is_empty() { if let Ok(proxy_url) = Url::from_str(&config.proxy_url) { parsed_proxy_url = Some(proxy_url.clone()); #[cfg(target_os = "windows")] { if let Some(arg) = build_proxy_browser_arg(&proxy_url) { windows_browser_args.push(' '); windows_browser_args.push_str(&arg); } } } } #[cfg(target_os = "windows")] { window_builder = window_builder.additional_browser_args(&windows_browser_args); } #[cfg(target_os = "linux")] { window_builder = window_builder.additional_browser_args(&linux_browser_args); } } // Set proxy after platform-specific configs (required for Windows/Linux) if parsed_proxy_url.is_none() && !config.proxy_url.is_empty() { if let Ok(proxy_url) = Url::from_str(&config.proxy_url) { parsed_proxy_url = Some(proxy_url); } } if let Some(proxy_url) = parsed_proxy_url { window_builder = window_builder.proxy_url(proxy_url); #[cfg(debug_assertions)] println!("Proxy configured: {}", config.proxy_url); } if let Some(features) = new_window_features { window_builder = window_builder.window_features(features).focused(true); } // Allow navigation to OAuth/authentication domains window_builder = window_builder.on_navigation(|url| { let url_str = url.as_str(); // Always allow same-origin navigation if url_str.starts_with("http://localhost") || url_str.starts_with("http://127.0.0.1") { return true; } // Check for OAuth/authentication domains let auth_patterns = [ "accounts.google.com", "login.microsoftonline.com", "github.com/login", "appleid.apple.com", "facebook.com", "twitter.com", ]; let auth_paths = ["/oauth/", "/auth/", "/authorize", "/login"]; // Allow if matches auth patterns for pattern in &auth_patterns { if url_str.contains(pattern) { #[cfg(debug_assertions)] println!("Allowing OAuth navigation to: {}", url_str); return true; } } for path in &auth_paths { if url_str.contains(path) { #[cfg(debug_assertions)] println!("Allowing auth path navigation to: {}", url_str); return true; } } // Allow all other navigation by default true }); window_builder.build() } ================================================ FILE: src-tauri/src/inject/auth.js ================================================ // OAuth and Authentication Logic // Check if URL matches OAuth/authentication patterns function matchesAuthUrl(url, baseUrl = window.location.href) { try { const urlObj = new URL(url, baseUrl); const hostname = urlObj.hostname.toLowerCase(); const pathname = urlObj.pathname.toLowerCase(); const fullUrl = urlObj.href.toLowerCase(); // Common OAuth providers and paths const oauthPatterns = [ /accounts\.google\.com/, /accounts\.google\.[a-z]+/, /login\.microsoftonline\.com/, /github\.com\/login/, /facebook\.com\/.*\/dialog/, /twitter\.com\/oauth/, /appleid\.apple\.com/, /\/oauth\//, /\/auth\//, /\/authorize/, /\/login\/oauth/, /\/signin/, /\/login/, /servicelogin/, /\/o\/oauth2/, ]; const isMatch = oauthPatterns.some( (pattern) => pattern.test(hostname) || pattern.test(pathname) || pattern.test(fullUrl), ); if (isMatch) { console.log("[Pake] OAuth URL detected:", url); } return isMatch; } catch (e) { return false; } } // Check if URL is an OAuth/authentication link function isAuthLink(url) { return matchesAuthUrl(url); } // Check if this is an OAuth/authentication popup function isAuthPopup(url, name) { // Check for known authentication window names const authWindowNames = [ "AppleAuthentication", "oauth2", "oauth", "google-auth", "auth-popup", "signin", "login", ]; if (authWindowNames.includes(name)) { return true; } return matchesAuthUrl(url); } // Export functions to global scope window.matchesAuthUrl = matchesAuthUrl; window.isAuthLink = isAuthLink; window.isAuthPopup = isAuthPopup; ================================================ FILE: src-tauri/src/inject/component.js ================================================ 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.) (function () { if (window.__PAKE_FULLSCREEN_POLYFILL__) return; window.__PAKE_FULLSCREEN_POLYFILL__ = true; function initFullscreenPolyfill() { if (!window.__TAURI__ || !document.head) { setTimeout(initFullscreenPolyfill, 100); return; } const appWindow = window.__TAURI__.window.getCurrentWindow(); let fullscreenElement = null; let actualFullscreenElement = null; let originalStyles = null; let originalParent = null; let originalNextSibling = null; 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"; styleEl.textContent = ` body.pake-fullscreen-active { overflow: hidden !important; } .pake-fullscreen-element { position: fixed !important; top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; max-width: 100vw !important; max-height: 100vh !important; margin: 0 !important; padding: 0 !important; z-index: 2147483647 !important; background: #000 !important; object-fit: contain !important; } .pake-fullscreen-element video { width: 100% !important; height: 100% !important; object-fit: contain !important; } `; document.head.appendChild(styleEl); } function startFullscreenMonitor() { if (monitorId) return; monitorId = setInterval(() => { appWindow .isFullscreen() .then((isFullscreen) => { if (fullscreenElement && !isFullscreen) { exitFullscreen(); } }) .catch(() => {}); }, 500); } function stopFullscreenMonitor() { if (!monitorId) return; clearInterval(monitorId); monitorId = null; } // Find the actual video element function findMediaElement() { const videos = document.querySelectorAll("video"); if (videos.length > 0) { let largestVideo = videos[0]; let maxArea = 0; videos.forEach((video) => { const rect = video.getBoundingClientRect(); const area = rect.width * rect.height; if (area > maxArea || !video.paused) { maxArea = area; largestVideo = video; } }); return largestVideo; } 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(); if (mediaElement) { targetElement = mediaElement; actualFullscreenElement = mediaElement; } else { actualFullscreenElement = element; } } else { actualFullscreenElement = element; } // Save original state originalStyles = { position: targetElement.style.position, top: targetElement.style.top, left: targetElement.style.left, width: targetElement.style.width, height: targetElement.style.height, maxWidth: targetElement.style.maxWidth, maxHeight: targetElement.style.maxHeight, margin: targetElement.style.margin, padding: targetElement.style.padding, zIndex: targetElement.style.zIndex, background: targetElement.style.background, objectFit: targetElement.style.objectFit, }; wasInBody = targetElement.parentNode === document.body; if (!wasInBody) { originalParent = targetElement.parentNode; originalNextSibling = targetElement.nextSibling; } // Apply fullscreen targetElement.classList.add("pake-fullscreen-element"); document.body.classList.add("pake-fullscreen-active"); if (!wasInBody) { document.body.appendChild(targetElement); } // Fullscreen window appWindow.setFullscreen(true).then(() => { startFullscreenMonitor(); const event = new Event("fullscreenchange", { bubbles: true }); document.dispatchEvent(event); element.dispatchEvent(event); const webkitEvent = new Event("webkitfullscreenchange", { bubbles: true, }); document.dispatchEvent(webkitEvent); element.dispatchEvent(webkitEvent); }); return Promise.resolve(); } // Exit fullscreen function exitFullscreen() { if (!fullscreenElement) { return Promise.resolve(); } stopFullscreenMonitor(); const exitingElement = fullscreenElement; const targetElement = actualFullscreenElement; // Restore styles and position targetElement.classList.remove("pake-fullscreen-element"); document.body.classList.remove("pake-fullscreen-active"); if (originalStyles) { Object.keys(originalStyles).forEach((key) => { targetElement.style[key] = originalStyles[key]; }); } if (!wasInBody && originalParent) { if ( originalNextSibling && originalNextSibling.parentNode === originalParent ) { originalParent.insertBefore(targetElement, originalNextSibling); } else if (originalParent.isConnected) { originalParent.appendChild(targetElement); } } // Reset state fullscreenElement = null; actualFullscreenElement = null; originalStyles = null; originalParent = null; originalNextSibling = null; wasInBody = false; // Exit window fullscreen return appWindow.setFullscreen(false).then(() => { const event = new Event("fullscreenchange", { bubbles: true }); document.dispatchEvent(event); exitingElement.dispatchEvent(event); const webkitEvent = new Event("webkitfullscreenchange", { bubbles: true, }); document.dispatchEvent(webkitEvent); exitingElement.dispatchEvent(webkitEvent); }); } // Override fullscreenEnabled Object.defineProperty(document, "fullscreenEnabled", { get: () => true, configurable: true, }); Object.defineProperty(document, "webkitFullscreenEnabled", { get: () => true, configurable: true, }); // Override fullscreenElement Object.defineProperty(document, "fullscreenElement", { get: () => fullscreenElement, configurable: true, }); Object.defineProperty(document, "webkitFullscreenElement", { get: () => fullscreenElement, configurable: true, }); Object.defineProperty(document, "webkitCurrentFullScreenElement", { get: () => fullscreenElement, configurable: true, }); // Override requestFullscreen Element.prototype.requestFullscreen = function () { return enterFullscreen(this); }; Element.prototype.webkitRequestFullscreen = function () { return enterFullscreen(this); }; Element.prototype.webkitRequestFullScreen = function () { return enterFullscreen(this); }; // Override exitFullscreen document.exitFullscreen = exitFullscreen; document.webkitExitFullscreen = exitFullscreen; document.webkitCancelFullScreen = exitFullscreen; // Handle Escape key document.addEventListener( "keydown", (e) => { if (e.key === "Escape" && fullscreenElement) { exitFullscreen(); } }, true, ); } initFullscreenPolyfill(); })(); ================================================ FILE: src-tauri/src/inject/custom.js ================================================ ================================================ FILE: src-tauri/src/inject/event.js ================================================ const shortcuts = { "[": () => window.history.back(), "]": () => window.history.forward(), "-": () => zoomOut(), "=": () => zoomIn(), "+": () => zoomIn(), 0: () => setZoom("100%"), r: () => window.location.reload(), ArrowUp: () => scrollTo(0, 0), ArrowDown: () => scrollTo(0, document.body.scrollHeight), }; 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.localStorage.setItem("htmlZoom", zoom); } function zoomCommon(zoomChange) { const currentZoom = window.localStorage.getItem("htmlZoom") || "100%"; setZoom(zoomChange(currentZoom)); } function zoomIn() { zoomCommon((currentZoom) => `${Math.min(parseInt(currentZoom) + 10, 200)}%`); } function zoomOut() { zoomCommon((currentZoom) => `${Math.max(parseInt(currentZoom) - 10, 30)}%`); } let pasteAsPlainTextPending = false; function triggerPasteAsPlainText() { pasteAsPlainTextPending = true; document.execCommand("paste"); setTimeout(() => { pasteAsPlainTextPending = false; }, 100); } function handleShortcut(event) { if (shortcuts[event.key]) { event.preventDefault(); shortcuts[event.key](); } } const DOWNLOADABLE_FILE_EXTENSIONS = { documents: [ "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "rtf", "odt", "ods", "odp", "pages", "numbers", "key", "epub", "mobi", ], archives: [ "zip", "rar", "7z", "tar", "gz", "gzip", "bz2", "xz", "lzma", "deb", "rpm", "pkg", "msi", "exe", "dmg", "apk", "ipa", ], data: [ "json", "xml", "csv", "sql", "db", "sqlite", "yaml", "yml", "toml", "ini", "cfg", "conf", "log", ], code: [ "js", "ts", "jsx", "tsx", "css", "scss", "sass", "less", "sh", "bat", "ps1", ], fonts: ["ttf", "otf", "woff", "woff2", "eot"], design: ["ai", "psd", "sketch", "fig", "xd"], system: [ "iso", "img", "bin", "torrent", "jar", "war", "indd", "fla", "swf", "raw", ], }; const ALL_DOWNLOADABLE_EXTENSIONS = Object.values( DOWNLOADABLE_FILE_EXTENSIONS, ).flat(); const PREVIEWABLE_MEDIA_EXTENSIONS = [ "png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "tiff", "tif", "avif", "heic", "heif", "mp4", "webm", "mov", "m4v", "mkv", "avi", "ogv", "mp3", "wav", "ogg", "flac", "aac", "m4a", ]; const DOWNLOAD_PATH_PATTERNS = [ "/download/", "/files/", "/attachments/", "/assets/", "/releases/", "/dist/", ]; // Language detection utilities function getUserLanguage() { return navigator.language || navigator.userLanguage; } function isChineseLanguage(language = getUserLanguage()) { return ( language && (language.startsWith("zh") || language.includes("CN") || language.includes("TW") || language.includes("HK")) ); } // User notification helper function showDownloadError(filename) { const isChinese = isChineseLanguage(); const message = isChinese ? `下载失败: ${filename}` : `Download failed: ${filename}`; if (window.Notification && Notification.permission === "granted") { new Notification(isChinese ? "下载错误" : "Download Error", { body: message, }); } else { console.error(message); } } function getExtension(url) { try { const pathname = new URL(url).pathname.toLowerCase(); const extensionIndex = pathname.lastIndexOf("."); return extensionIndex > -1 ? pathname.slice(extensionIndex + 1) : ""; } catch (e) { return ""; } } function isPreviewableMedia(url) { const extension = getExtension(url); return PREVIEWABLE_MEDIA_EXTENSIONS.includes(extension); } // Unified file detection - replaces both isDownloadLink and isFileLink function isDownloadableFile(url) { try { const extension = getExtension(url); if (PREVIEWABLE_MEDIA_EXTENSIONS.includes(extension)) { return false; } const urlObj = new URL(url); const hasDownloadHints = urlObj.searchParams.has("download") || urlObj.searchParams.has("attachment"); if (hasDownloadHints) { return true; } return ( ALL_DOWNLOADABLE_EXTENSIONS.includes(extension) || DOWNLOAD_PATH_PATTERNS.some((pattern) => urlObj.pathname.toLowerCase().includes(pattern), ) ); } catch (e) { return false; } } document.addEventListener("DOMContentLoaded", () => { const tauri = window.__TAURI__; const appWindow = tauri.window.getCurrentWindow(); const invoke = tauri.core.invoke; const pakeConfig = window["pakeConfig"] || {}; const forceInternalNavigation = pakeConfig.force_internal_navigation === true; const internalUrlRegex = pakeConfig.internal_url_regex || ""; let internalUrlPattern = null; if (internalUrlRegex) { try { internalUrlPattern = new RegExp(internalUrlRegex); } catch (e) { console.error("[Pake] Invalid internal_url_regex pattern:", e); } } if (!document.getElementById("pake-top-dom")) { const topDom = document.createElement("div"); topDom.id = "pake-top-dom"; document.body.appendChild(topDom); } const domEl = document.getElementById("pake-top-dom"); domEl.addEventListener("touchstart", () => { appWindow.startDragging(); }); domEl.addEventListener("mousedown", (e) => { e.preventDefault(); if (e.buttons === 1 && e.detail !== 2) { appWindow.startDragging(); } }); domEl.addEventListener("dblclick", () => { appWindow.isFullscreen().then((fullscreen) => { appWindow.setFullscreen(!fullscreen); }); }); if (window["pakeConfig"]?.disabled_web_shortcuts !== true) { document.addEventListener("keyup", (event) => { if (/windows|linux/i.test(navigator.userAgent) && event.ctrlKey) { handleShortcut(event); } if (/macintosh|mac os x/i.test(navigator.userAgent) && event.metaKey) { handleShortcut(event); } }); } document.addEventListener( "paste", (event) => { if (pasteAsPlainTextPending) { event.preventDefault(); event.stopImmediatePropagation(); const text = event.clipboardData?.getData("text/plain") || ""; if (text) { document.execCommand("insertText", false, text); } } }, 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) => { const blob = window.blobToUrlCaches.get(blobUrl); const reader = new FileReader(); reader.readAsArrayBuffer(blob); reader.onload = () => { resolve(Array.from(new Uint8Array(reader.result))); }; }); } 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; }; } // process special download protocol['data:','blob:'] const isSpecialDownload = (url) => ["blob", "data"].some((protocol) => url.startsWith(protocol)); const isDownloadRequired = (url, anchorElement, e) => anchorElement.download || e.metaKey || e.ctrlKey || isDownloadableFile(url); const handleExternalLink = (url) => { // Don't try to open blob: or data: URLs with shell if (isSpecialDownload(url)) { console.warn("Cannot open special URL with shell:", url); return; } invoke("plugin:shell|open", { path: url, }).catch((error) => { console.error("Failed to open URL with shell:", url, error); }); }; // Check if URL belongs to the same domain (including subdomains) const isSameDomain = (url) => { try { const linkUrl = new URL(url); const currentUrl = new URL(window.location.href); if (linkUrl.hostname === currentUrl.hostname) return true; // Extract root domain (e.g., bilibili.com from www.bilibili.com) const getRootDomain = (hostname) => { const parts = hostname.split("."); return parts.length >= 2 ? parts.slice(-2).join(".") : hostname; }; return ( getRootDomain(currentUrl.hostname) === getRootDomain(linkUrl.hostname) ); } catch (e) { return false; } }; // Check if URL should be treated as internal based on regex pattern or domain const isInternalUrl = (url) => { // If regex pattern is configured, use it as the primary check if (internalUrlPattern) { try { return internalUrlPattern.test(url); } catch (e) { console.error("[Pake] Error testing internal_url_regex:", e); // Fall back to domain check on error return isSameDomain(url); } } // Default to domain-based check return isSameDomain(url); }; const detectAnchorElementClick = (e) => { // Safety check: ensure e.target exists and is an Element with closest method if (!e.target || typeof e.target.closest !== "function") { return; } const anchorElement = e.target.closest("a"); if (anchorElement && anchorElement.href) { const target = anchorElement.target; const hrefUrl = new URL(anchorElement.href); const absoluteUrl = hrefUrl.href; let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl); // Keep OAuth/authentication flows inside the app when popup support is enabled. if (window.isAuthLink(absoluteUrl)) { console.log("[Pake] Handling OAuth navigation in-app:", absoluteUrl); if (window.pakeConfig?.new_window) { e.preventDefault(); e.stopImmediatePropagation(); const authWindow = originalWindowOpen.call( window, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); if (!authWindow) { window.location.href = absoluteUrl; } } return; } // Handle _blank links: same domain navigates in-app, cross-domain opens new window if (target === "_blank") { if (forceInternalNavigation) { e.preventDefault(); e.stopImmediatePropagation(); window.location.href = absoluteUrl; return; } if (isInternalUrl(absoluteUrl)) { // For internal links (based on regex or domain), let the browser handle it naturally return; } e.preventDefault(); e.stopImmediatePropagation(); const newWindow = originalWindowOpen.call( window, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); if (!newWindow) handleExternalLink(absoluteUrl); return; } if (target === "_new") { if (forceInternalNavigation) { e.preventDefault(); e.stopImmediatePropagation(); window.location.href = absoluteUrl; return; } e.preventDefault(); handleExternalLink(absoluteUrl); return; } // Process download links for Rust to handle. if ( isDownloadRequired(absoluteUrl, anchorElement, e) && !isSpecialDownload(absoluteUrl) ) { e.preventDefault(); e.stopImmediatePropagation(); const userLanguage = getUserLanguage(); invoke("download_file", { params: { url: absoluteUrl, filename, language: userLanguage }, }); return; } // Handle regular links: internal URLs allow normal navigation, external opens new window if (!target || target === "_self") { // Optimization: Allow previewable media to be handled by the app/browser directly // This fixes issues where CDN links are treated as external if (isPreviewableMedia(absoluteUrl)) { return; } if (!isInternalUrl(absoluteUrl)) { if (forceInternalNavigation) { return; } e.preventDefault(); e.stopImmediatePropagation(); const newWindow = originalWindowOpen.call( window, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); if (!newWindow) handleExternalLink(absoluteUrl); } } } }; // 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) { // Allow authentication popups to open normally if (window.isAuthPopup(url, name)) { return originalWindowOpen.call(window, url, name, specs); } try { const baseUrl = window.location.origin + window.location.pathname; const hrefUrl = new URL(url, baseUrl); const absoluteUrl = hrefUrl.href; if (!isInternalUrl(absoluteUrl)) { if (forceInternalNavigation) { return originalWindowOpen.call(window, absoluteUrl, name, specs); } handleExternalLink(absoluteUrl); return null; } return originalWindowOpen.call(window, absoluteUrl, name, specs); } catch (error) { return originalWindowOpen.call(window, url, name, specs); } }; // Set the default zoom, There are problems with Loop without using try-catch. try { setDefaultZoom(); } catch (e) { console.log(e); } // Fix Chinese input method "Enter" on Safari document.addEventListener( "keydown", (e) => { if (e.key === "Process") e.stopPropagation(); }, true, ); // Language detection and texts const isChinese = isChineseLanguage(); const menuTexts = { // Media operations downloadImage: isChinese ? "下载图片" : "Download Image", downloadVideo: isChinese ? "下载视频" : "Download Video", downloadFile: isChinese ? "下载文件" : "Download File", copyAddress: isChinese ? "复制地址" : "Copy Address", openInBrowser: isChinese ? "浏览器打开" : "Open in Browser", }; // Menu theme configuration const MENU_THEMES = { dark: { menu: { background: "#2d2d2d", border: "1px solid #404040", color: "#ffffff", shadow: "0 4px 16px rgba(0, 0, 0, 0.4)", }, item: { divider: "#404040", hoverBg: "#404040", }, }, light: { menu: { background: "#ffffff", border: "1px solid #e0e0e0", color: "#333333", shadow: "0 4px 16px rgba(0, 0, 0, 0.15)", }, item: { divider: "#f0f0f0", hoverBg: "#d0d0d0", }, }, }; // Theme detection and menu styles function getTheme() { const prefersDark = window.matchMedia( "(prefers-color-scheme: dark)", ).matches; return prefersDark ? "dark" : "light"; } function getMenuStyles(theme = getTheme()) { return MENU_THEMES[theme] || MENU_THEMES.light; } // Menu configuration constants const MENU_CONFIG = { id: "pake-context-menu", minWidth: "120px", // Compact width for better UX borderRadius: "6px", // Slightly more rounded for modern look fontSize: "13px", zIndex: "999999", fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", // Menu item dimensions itemPadding: "8px 16px", // Increased vertical padding for better comfort itemLineHeight: "1.2", itemBorderRadius: "3px", // Subtle rounded corners for menu items itemTransition: "background-color 0.1s ease", }; // Create custom context menu function createContextMenu() { const contextMenu = document.createElement("div"); contextMenu.id = MENU_CONFIG.id; const styles = getMenuStyles(); contextMenu.style.cssText = ` position: fixed; background: ${styles.menu.background}; border: ${styles.menu.border}; border-radius: ${MENU_CONFIG.borderRadius}; box-shadow: ${styles.menu.shadow}; padding: 4px 0; min-width: ${MENU_CONFIG.minWidth}; font-family: ${MENU_CONFIG.fontFamily}; font-size: ${MENU_CONFIG.fontSize}; color: ${styles.menu.color}; z-index: ${MENU_CONFIG.zIndex}; display: none; user-select: none; `; document.body.appendChild(contextMenu); return contextMenu; } function createMenuItem(text, onClick, divider = false) { const item = document.createElement("div"); const styles = getMenuStyles(); item.style.cssText = ` padding: ${MENU_CONFIG.itemPadding}; cursor: pointer; user-select: none; font-weight: 400; line-height: ${MENU_CONFIG.itemLineHeight}; transition: ${MENU_CONFIG.itemTransition}; white-space: nowrap; border-radius: ${MENU_CONFIG.itemBorderRadius}; margin: 2px 4px; border-bottom: ${divider ? `1px solid ${styles.item.divider}` : "none"}; `; item.textContent = text; item.addEventListener("mouseenter", () => { item.style.backgroundColor = styles.item.hoverBg; }); item.addEventListener("mouseleave", () => { item.style.backgroundColor = "transparent"; }); item.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); onClick(); hideContextMenu(); }); return item; } function showContextMenu(x, y, items) { let contextMenu = document.getElementById(MENU_CONFIG.id); // Always recreate menu to ensure theme is up-to-date if (contextMenu) { contextMenu.remove(); } contextMenu = createContextMenu(); items.forEach((item) => { contextMenu.appendChild(item); }); contextMenu.style.left = x + "px"; contextMenu.style.top = y + "px"; contextMenu.style.display = "block"; // Adjust position if menu goes off screen const rect = contextMenu.getBoundingClientRect(); if (rect.right > window.innerWidth) { contextMenu.style.left = x - rect.width + "px"; } if (rect.bottom > window.innerHeight) { contextMenu.style.top = y - rect.height + "px"; } } function hideContextMenu() { const contextMenu = document.getElementById(MENU_CONFIG.id); if (contextMenu) { contextMenu.style.display = "none"; } } function downloadImage(imageUrl) { // Convert relative URLs to absolute if (imageUrl.startsWith("/")) { imageUrl = window.location.origin + imageUrl; } else if (imageUrl.startsWith("./")) { imageUrl = new URL(imageUrl, window.location.href).href; } else if ( !imageUrl.startsWith("http") && !imageUrl.startsWith("data:") && !imageUrl.startsWith("blob:") ) { imageUrl = new URL(imageUrl, window.location.href).href; } // Generate filename from URL 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); } } else { // Regular HTTP(S) image const userLanguage = getUserLanguage(); invoke("download_file", { params: { url: imageUrl, filename: filename, language: userLanguage, }, }).catch((error) => { console.error("Failed to download image:", filename, error); showDownloadError(filename); }); } } // Check if element is media (image or video) function getMediaInfo(target) { // Check for img tags if (target.tagName.toLowerCase() === "img") { return { isMedia: true, url: target.src, type: "image" }; } // Check for video tags if (target.tagName.toLowerCase() === "video") { return { isMedia: true, url: target.src || target.currentSrc, type: "video", }; } // Check for elements with background images if (target.style && target.style.backgroundImage) { const bgImage = target.style.backgroundImage; const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); if (urlMatch) { return { isMedia: true, url: urlMatch[1], type: "image" }; } } // Check for parent elements with background images const parentWithBg = target && typeof target.closest === "function" ? target.closest('[style*="background-image"]') : null; if (parentWithBg) { const bgImage = parentWithBg.style.backgroundImage; const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); if (urlMatch) { return { isMedia: true, url: urlMatch[1], type: "image" }; } } return { isMedia: false, url: "", type: "" }; } // Simplified menu builder function buildMenuItems(type, data) { const userLanguage = getUserLanguage(); const items = []; switch (type) { case "media": const downloadText = data.type === "image" ? menuTexts.downloadImage : menuTexts.downloadVideo; items.push( createMenuItem(downloadText, () => downloadImage(data.url)), createMenuItem(menuTexts.copyAddress, () => navigator.clipboard.writeText(data.url), ), createMenuItem(menuTexts.openInBrowser, () => invoke("plugin:shell|open", { path: data.url }), ), ); break; case "link": if (data.isFile) { items.push( createMenuItem(menuTexts.downloadFile, () => { const filename = getFilenameFromUrl(data.url); invoke("download_file", { params: { url: data.url, filename, language: userLanguage }, }).catch((error) => { console.error("Failed to download file:", filename, error); showDownloadError(filename); }); }), ); } items.push( createMenuItem(menuTexts.copyAddress, () => navigator.clipboard.writeText(data.url), ), createMenuItem(menuTexts.openInBrowser, () => invoke("plugin:shell|open", { path: data.url }), ), ); break; } return items; } // Handle right-click context menu document.addEventListener( "contextmenu", function (event) { const target = event.target; // Check for media elements (images/videos) const mediaInfo = getMediaInfo(target); // Check for links (but not if it's media) const linkElement = target && typeof target.closest === "function" ? target.closest("a") : null; const isLink = linkElement && linkElement.href && !mediaInfo.isMedia; // Only show custom menu for media or links if (mediaInfo.isMedia || isLink) { event.preventDefault(); event.stopPropagation(); let menuItems = []; if (mediaInfo.isMedia) { menuItems = buildMenuItems("media", mediaInfo); } else if (isLink) { const linkUrl = linkElement.href; menuItems = buildMenuItems("link", { url: linkUrl, isFile: isDownloadableFile(linkUrl), }); } showContextMenu(event.clientX, event.clientY, menuItems); } // For all other elements, let browser's default context menu handle it }, true, ); // Hide context menu when clicking elsewhere document.addEventListener("click", hideContextMenu); document.addEventListener("keydown", (e) => { if (e.key === "Escape") { hideContextMenu(); } }); }); document.addEventListener("DOMContentLoaded", function () { let permVal = "granted"; window.Notification = function (title, options) { const { invoke } = window.__TAURI__.core; const body = options?.body || ""; let icon = options?.icon || ""; // If the icon is a relative path, convert to full path using URI if (icon.startsWith("/")) { icon = window.location.origin + icon; } invoke("send_notification", { params: { title, body, icon, }, }); }; window.Notification.requestPermission = async () => "granted"; Object.defineProperty(window.Notification, "permission", { enumerable: true, get: () => permVal, set: (v) => { permVal = v; }, }); }); function setDefaultZoom() { const htmlZoom = window.localStorage.getItem("htmlZoom"); if (htmlZoom) { setZoom(htmlZoom); } else if (window.pakeConfig?.zoom && window.pakeConfig.zoom !== 100) { setZoom(`${window.pakeConfig.zoom}%`); } } function getFilenameFromUrl(url) { try { const urlPath = new URL(url).pathname; let filename = urlPath.substring(urlPath.lastIndexOf("/") + 1); // If no filename or no extension, generate one if (!filename || !filename.includes(".")) { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); // Detect image type from URL or data URI if (url.startsWith("data:image/")) { const mimeType = url.substring(11, url.indexOf(";")); filename = `image-${timestamp}.${mimeType}`; } else { // Default to common image extensions based on common patterns if (url.includes("jpg") || url.includes("jpeg")) { filename = `image-${timestamp}.jpg`; } else if (url.includes("png")) { filename = `image-${timestamp}.png`; } else if (url.includes("gif")) { filename = `image-${timestamp}.gif`; } else if (url.includes("webp")) { filename = `image-${timestamp}.webp`; } else if (url.includes("svg")) { filename = `image-${timestamp}.svg`; } else { filename = `image-${timestamp}.png`; // default } } } return filename; } catch (e) { // Fallback for invalid URLs const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); return `image-${timestamp}.png`; } } ================================================ FILE: src-tauri/src/inject/style.js ================================================ window.addEventListener("DOMContentLoaded", (_event) => { // Customize and transform existing functions const contentCSS = ` #page #footer-wrapper, .drawing-board .toolbar .toolbar-action, .c-swiper-container, .download_entry, .lang, .copyright, .wwads-cn, .adsbygoogle, #Bottom > div.content > div.inner, #Rightbar .sep20:nth-of-type(5), #Rightbar > div.box:nth-child(4), #Main > div.box:nth-child(8) > div #Wrapper > div.sep20, #Main > div.box:nth-child(8), #masthead-ad, #app > header > div > div.menu, #root > div > div.fixed.top-0.left-0.w-64.h-screen.p-10.pb-0.flex.flex-col.justify-between > div > div.space-y-4 > a:nth-child(3), #app > div.layout > div.main-container > div.side-bar > li.divider, #Rightbar > div:nth-child(6) > div.sidebar_compliance, #__next > div.PageWithSidebarLayout_centeringDiv___L9br > aside > div > div > a.ChatPageFollowTwitterLink_followLink__Gl2tt, #__next > div.PageWithSidebarLayout_centeringDiv___L9br > aside > div > div > a.Button_buttonBase__0QP_m.Button_primary__pIDjn.ChatPageDownloadLinks_downloadButton__amBRh, #__next > div.PageWithSidebarLayout_centeringDiv___L9br > aside > div > div > section a[href*="/contact"], .dc04ec1d .c7f51894 .a1e75851, .a7f3a288 .b91228e4, .efe408db .a24007f4{ display: none !important; } #app > header .right .avatar.logged-in{ opacity: 0; transition: opacity 0.3s; } #app > header .right .avatar.logged-in:hover{ opacity: 1; } html::-webkit-scrollbar { display: none !important; } #__next .ChatPageSidebar_menuFooter__E1KTY,#__next > div.PageWithSidebarLayout_centeringDiv___L9br > div > aside > div > menu > section:nth-child(6) { display: none; } #__next > div.overflow-hidden.w-full.h-full .min-h-\\[20px\\].items-start.gap-4.whitespace-pre-wrap.break-words { word-break: break-all; } #__next .PageWithSidebarLayout_mainSection__i1yOg { width: 100%; max-width: 1000px; } #__next > div.PageWithSidebarLayout_centeringDiv___L9br > aside{ min-width: 260px; } #__next > div.overflow-hidden.w-full.h-full.relative.flex.z-0 > div.relative.flex.h-full.max-w-full.flex-1.overflow-hidden > div > main > div.absolute.left-2.top-2.z-10.hidden.md\\:inline-block{ margin-top:20px; margin-left: 10px; } .a7f3a288.f0d4f23d { padding-top: 34px; } .ec92d1d3 { padding-top: 48px; } .chakra-ui-light #app .chakra-heading, .chakra-ui-dark #app .chakra-heading, .chakra-ui-light #app .chakra-stack, .chakra-ui-dark #app .chakra-stack, .app-main .sidebar-mouse-in-out, .chakra-modal__content-container .chakra-modal__header > div > div, #__next > div.PageWithSidebarLayout_centeringDiv___L9br > section > header { padding-top: 10px; } #__next .overflow-hidden>.hidden.bg-gray-900 span.rounded-md.bg-yellow-200 { display: none; } #__next .absolute .px-3.pt-2.pb-3.text-center { visibility: hidden; padding-bottom: 4px; } #__next .h-full.w-full .text-center.text-xs.text-gray-600>span { visibility: hidden; height: 15px; } #__next > div.overflow-hidden.w-full.h-full.relative.flex > div.dark.hidden.flex-shrink-0.bg-gray-900.md\\:flex.md\\:w-\\[260px\\].md\\:flex-col > div > div > nav { width: 100%; } .panel.give_me .nav_view { top: 164px !important; } #Wrapper{ background-color: #F8F8F8 !important; background-image:none !important; } #Top { border-bottom: none; } #global > div.header-container.showSearchBoxOrHeaderFixed > header > div.right > div > div.dropdown-nav{ display: none; } #__next > div.AnnouncementWrapper_container__Z51yh > div > aside > div > div > menu > section:nth-child(4) > section, #__next > div.AnnouncementWrapper_container__Z51yh > div > aside > div > div > menu > section:nth-child(4){ display: none; } #react-root [data-testid="placementTracking"] article, #react-root a[href*="quick_promote_web"], #react-root [data-testid="AppTabBar_Explore_Link"], #react-root a[href*="/lists"][role="link"][aria-label], #react-root a[href*="/i/communitynotes"][role="link"][aria-label], #react-root a[role="link"][aria-label="Communities"], #react-root a[href*="/i/verified-orgs-signup"][role="link"][aria-label] { display: none !important; } #react-root [data-testid="DMDrawer"], #root > main > footer.justify-center.ease-in { visibility: hidden !important; } #__next > div.overflow-hidden.w-full.h-full .absolute.bottom-0.left-0.w-full > div.text-center.text-xs { visibility: hidden !important; height: 0px !important; } #react-root [data-testid="primaryColumn"] > div > div { position: relative !important; } #react-root [data-testid="sidebarColumn"] { visibility: hidden !important; width: 0 !important; margin: 0 !important; padding: 0 !important; z-index: 1 !important; } @media only screen and (min-width: 1000px) { #react-root main[role="main"] { align-items: center !important; overflow-x: clip !important; } #react-root [data-testid="primaryColumn"] { width: 700px !important; max-width: 700px !important; margin: 0 auto !important; } #react-root [data-testid="primaryColumn"] > div > div:last-child, #react-root [data-testid="primaryColumn"] > div > div:last-child div { max-width: unset !important; } #react-root div[aria-label][role="group"][id^="id__"] { margin-right: 81px !important; } #react-root header[role="banner"] { position: fixed !important; left: 0 !important; } #react-root header[role="banner"] > div > div > div { justify-content: center !important; padding-top: 0; overflow-x: hidden; } #react-root form[role="search"] > div:nth-child(1) > div { background-color: transparent !important; } #react-root h1[role="heading"] { padding-top: 4px !important; } #react-root header[role="banner"] nav[role="navigation"] * div[dir="auto"]:not([aria-label]) > span, #react-root [data-testid="SideNav_AccountSwitcher_Button"] > div:not(:first-child) { display: inline-block !important; opacity: 0 !important; transition: 0.5s cubic-bezier(0.2, 0.8, 0.2, 1); } #react-root header[role="banner"] nav[role="navigation"]:hover * div[dir="auto"]:not([aria-label]) > span, #react-root [data-testid="SideNav_AccountSwitcher_Button"]:hover > div:not(:first-child) { opacity: 1 !important; } #react-root header[role="banner"] nav[role="navigation"]:hover > * > div { backdrop-filter: blur(12px) !important; } #react-root header[role="banner"] nav[role="navigation"] > a { position: relative; } #react-root header[role="banner"] nav[role="navigation"] > a::before { content: ""; position: absolute; top: 0px; right: -40px; bottom: 0px; left: 0px; } #react-root [data-testid="SideNav_AccountSwitcher_Button"] { bottom: 18px !important; left: 1px !important; } #react-root [data-testid="SideNav_NewTweet_Button"], #react-root [aria-label="Twitter Blue"]{ display: none; } } @media only screen and (min-width: 1265px) { #react-root [data-testid="sidebarColumn"] form[role="search"] { visibility: visible !important; position: fixed !important; top: 12px !important; right: 16px !important; } #react-root [data-testid="sidebarColumn"] input[placeholder="Search Twitter"] { width: 150px; } #react-root [data-testid="sidebarColumn"] form[role="search"]:focus-within { width: 374px !important; backdrop-filter: blur(12px) !important; } #react-root [data-testid="sidebarColumn"] input[placeholder="Search Twitter"]:focus { width: 328px !important; } #react-root div[style*="left: -12px"] { left: unset !important; } #react-root div[style="left: -8px; width: 306px;"] { left: unset !important; width: 374px !important; } #react-root .searchFilters { visibility: visible !important; position: fixed; top: 12px; right: 16px; width: 240px; } #react-root .searchFilters > div > div:first-child { display: none; } } @media (min-width:1280px){ #__next .text-base.xl\\:max-w-3xl, #__next form.stretch.xl\\:max-w-3xl { max-width: 48rem; } } #__next .prose ol li p { margin: 0; display: inline; } .AppHeader .AppHeader-globalBar.js-global-bar { padding-top: 35px; } .header-overlay .header-logged-out { margin-top: 15px; } .w-full #stage-slideover-sidebar { padding-top: 16px; } .w-full #thread #page-header { padding-top: 36px; } `; const contentStyleElement = document.createElement("style"); contentStyleElement.innerHTML = contentCSS; document.head.appendChild(contentStyleElement); // Top spacing adapts to head-hiding scenarios const topPaddingCSS = ` #layout > ytmusic-nav-bar{ padding-top: 20px; } .columns .column #header, .main > div > div.panel.give_me > div.header { padding-top: 30px; } ytd-masthead>#container.style-scope.ytd-masthead { padding-top: 12px; } #__next header.HeaderBar_header__jn5ju { padding-top: 16px; } #root > .excalidraw-app> .excalidraw-container .App-menu.App-menu_top{ margin-top: 15px; } .geist-page nav.dashboard_nav__PRmJv, #app > div.layout > div.header-container.showSearchBoxOrHeaderFixed > header > a { padding-top:10px; } .geist-page .submenu button{ margin-top:24px; } .container-with-note #home, .container-with-note #switcher{ top: 30px; } #__next .overflow-hidden>.overflow-x-hidden .scrollbar-trigger > nav { padding-top: 12px; } #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.relative.flex.h-full.max-w-full.flex-1.flex-col.overflow-hidden > main > div.flex.h-full.flex-col > div.flex-1.overflow-hidden > div > div.absolute.left-0.right-0 > div > div.flex.items-center.gap-2 > button{ margin-left: 60px; margin-right: -10px; } #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.dark.flex-shrink-0.overflow-x-hidden.bg-black > div > div > div > div > nav > div.flex.flex-col.pt-2.empty\\:hidden.dark\\:border-white\\/20 > a, #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.relative.flex.h-full.max-w-full.flex-1.flex-col.overflow-hidden > main > div.group.fixed.bottom-3.right-3.z-10.hidden.gap-1.lg\\:flex > div, #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.flex-shrink-0.overflow-x-hidden.bg-token-sidebar-surface-primary > div > div > div > div > nav > div.flex.flex-col.pt-2.empty\\:hidden.dark\\:border-white\\/20 > a { display: none; } #__next .md\\:px-\\[60px\\].text-token-text-secondary.text-xs.text-center.py-2.px-2.relative{ visibility:hidden; } #__next>div>div>.flex.h-screen.w-full.flex-col.items-center { padding-top: 20px; } .h-dvh.flex-grow .bg-gradient-to-b.from-background.via-background { padding-top: 40px; } body > div.relative.flex.h-full.w-full.overflow-hidden.transition-colors.z-0 > div.z-\\[21\\].flex-shrink-0.overflow-x-hidden.bg-token-sidebar-surface-primary.max-md\\:\\!w-0 > div > div > div > nav > div.flex.justify-between.h-\\[60px\\].items-center.md\\:h-header-height { padding-top: 25px; } body > div.relative.flex.h-full.w-full.overflow-hidden.transition-colors.z-0 > div.relative.flex.h-full.max-w-full.flex-1.flex-col.overflow-hidden > main > div.composer-parent.flex.h-full.flex-col.focus-visible\\:outline-0 > div.flex-1.overflow-hidden.\\@container\\/thread > div > div.absolute.left-0.right-0 > div{ padding-top: 35px; } #__next .sticky.left-0.right-0.top-0.z-20.bg-black{ padding-top: 0px; } #header-area > div > .css-gtiexd > div:nth-child(1) > div, #header-area .logoIcon .user-info{ padding-top: 20px; } #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.flex-shrink-0.overflow-x-hidden.bg-token-sidebar-surface-primary > div > div > div > div > nav, #__next > div.relative.z-0.flex.h-full.w-full.overflow-hidden > div.relative.flex.h-full.max-w-full.flex-1.flex-col.overflow-hidden > main { padding-top: 6px; } #__next > div.AnnouncementWrapper_container__Z51yh > div > aside.SidebarLayout_sidebar__SXeDJ.SidebarLayout_left__k163a > div > div > header{ padding-left: 84px; padding-top: 10px; } #page .main_header, .cb-layout-basic--navbar, #app .splitpanes.splitpanes--horizontal.no-splitter header, .fui-FluentProvider .fui-Button[data-testid="HomeButton"], #__next > div.PageWithSidebarLayout_centeringDiv___L9br > aside .ChatPageSidebar_logo__9PIXq { padding-top: 20px; } #tabs-sidebar--tabpanel-0 > div.tw-flex.tw-items-center.tw-mb-\\[12px\\].tw-mt-\\[14px\\].tw-px-4 { padding-top: 15px; } #tabs-sidebar--tabpanel-1 > div > div.tw-p-\\[16px\\].tw-flex.tw-flex-col.tw-gap-1\\.5{ padding-top: 30px; } #tabs-sidebar--tabpanel-2 > div > h2 { padding-top: 20px; height: 70px; } .lark > .dashboard-sidebar, .lark > .dashboard-sidebar > .sidebar-user-info , .lark > .dashboard-sidebar .index-module_wrapper_F-Wbq{ padding-top:15px; } #app-root .mat-mdc-tooltip-trigger.main-menu-button.mdc-icon-button { margin-top: 15px; } .lark > .main-wrapper [data-testid="aside"] { top: 15px; } #global > div.header-container > .mask-paper { padding-top: 20px; } #background.ytd-masthead { height: 68px; } .wrap.h1body-exist.max-container > div.menu-tocs > div.menu-btn{ top: 28px; } .flex.w-full.h-full.overflow-hidden{ padding-top:20px; } .text-sidebar-foreground .bg-sidebar{ padding-top:30px; } #pake-top-dom:active { cursor: grabbing; cursor: -webkit-grabbing; } #pake-top-dom{ position:fixed; background:transparent; top:0; width: 100%; height: 20px; cursor: grab; -webkit-app-region: drag; user-select: none; -webkit-user-select: none; z-index: 99999; } @media (max-width:767px){ #__next .overflow-hidden.w-full .max-w-full>.sticky.top-0 { padding-top: 20px; } #__next > div.overflow-hidden.w-full.h-full main.relative.h-full.w-full.flex-1 > .flex-1.overflow-hidden .h-32.md\\:h-48.flex-shrink-0{ height: 0px; } } `; const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; if (window["pakeConfig"]?.hide_title_bar && isMac) { const topPaddingStyleElement = document.createElement("style"); topPaddingStyleElement.innerHTML = topPaddingCSS; document.head.appendChild(topPaddingStyleElement); } }); ================================================ FILE: src-tauri/src/inject/theme_refresh.js ================================================ document.addEventListener("DOMContentLoaded", () => { const debounce = (func, wait) => { let timeout; return (...args) => { clearTimeout(timeout); timeout = setTimeout(() => func(...args), wait); }; }; const updateTheme = () => { const doc = document.documentElement; const body = document.body; let mode = null; // Check for explicit theme classes or attributes const isDark = doc.classList.contains("dark") || body.classList.contains("dark") || doc.getAttribute("data-theme") === "dark" || body.getAttribute("data-theme") === "dark" || doc.style.colorScheme === "dark"; const isLight = doc.classList.contains("light") || body.classList.contains("light") || doc.getAttribute("data-theme") === "light" || body.getAttribute("data-theme") === "light" || doc.style.colorScheme === "light"; if (isDark) mode = "dark"; else if (isLight) mode = "light"; // Only invoke Rust command if an explicit theme override is detected if (mode && window.__TAURI__?.core) { window.__TAURI__.core.invoke("update_theme_mode", { mode }); } }; const debouncedUpdateTheme = debounce(updateTheme, 200); // Initial check with delay to allow site to render setTimeout(updateTheme, 500); // Watch for DOM changes const observer = new MutationObserver(debouncedUpdateTheme); const config = { attributes: true, attributeFilter: ["class", "data-theme", "style"], subtree: false, }; observer.observe(document.documentElement, config); observer.observe(document.body, config); // Watch for system theme changes (though window should handle this natively now) window .matchMedia("(prefers-color-scheme: dark)") .addEventListener("change", updateTheme); }); ================================================ FILE: src-tauri/src/lib.rs ================================================ #[cfg_attr(mobile, tauri::mobile_entry_point)] mod app; mod util; use tauri::Manager; use tauri_plugin_window_state::Builder as WindowStatePlugin; use tauri_plugin_window_state::StateFlags; #[cfg(target_os = "macos")] use std::time::Duration; const WINDOW_SHOW_DELAY: u64 = 50; use app::{ invoke::{ clear_cache_and_restart, download_file, download_file_by_binary, send_notification, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, }; use util::get_pake_config; 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"); } } let (pake_config, tauri_config) = get_pake_config(); let tauri_app = tauri::Builder::default(); let show_system_tray = pake_config.show_system_tray(); let hide_on_close = pake_config.windows[0].hide_on_close; let activation_shortcut = pake_config.windows[0].activation_shortcut.clone(); let init_fullscreen = pake_config.windows[0].fullscreen; 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 window_state_plugin = WindowStatePlugin::default() .with_state_flags(if init_fullscreen { StateFlags::FULLSCREEN } else { // Prevent flickering on the first open. StateFlags::all() & !StateFlags::VISIBLE }) .build(); #[allow(deprecated)] let mut app_builder = tauri_app .plugin(window_state_plugin) .plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()); // Add this // Only add single instance plugin if multiple instances are not allowed if !multi_instance { app_builder = app_builder.plugin(tauri_plugin_single_instance::init( move |app, _args, _cwd| { if multi_window { open_additional_window_safe(app); } else if let Some(window) = app.get_webview_window("pake") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } }, )); } app_builder .invoke_handler(tauri::generate_handler![ download_file, download_file_by_binary, send_notification, update_theme_mode, clear_cache_and_restart, ]) .setup(move |app| { app.manage(MultiWindowState::new( pake_config.clone(), tauri_config.clone(), )); // --- Menu Construction Start --- #[cfg(target_os = "macos")] { let menu = app::menu::get_menu(app.app_handle(), multi_window)?; app.set_menu(menu)?; // Event Handling for Custom Menu Item app.on_menu_event(move |app_handle, event| { app::menu::handle_menu_click(app_handle, event.id().as_ref()); }); } // --- Menu Construction End --- let window = set_window(app.app_handle(), &pake_config, &tauri_config); set_system_tray( app.app_handle(), show_system_tray, &pake_config.system_tray_path, init_fullscreen, multi_window, ) .unwrap(); set_global_shortcut(app.app_handle(), activation_shortcut, init_fullscreen).unwrap(); // Show window after state restoration to prevent position flashing // Unless start_to_tray is enabled, then keep it hidden if !start_to_tray { let window_clone = window.clone(); tauri::async_runtime::spawn(async move { tokio::time::sleep(tokio::time::Duration::from_millis(WINDOW_SHOW_DELAY)).await; window_clone.show().unwrap(); // Fixed: Linux fullscreen issue with virtual keyboard #[cfg(target_os = "linux")] { if init_fullscreen { window_clone.set_fullscreen(true).unwrap(); // Ensure webview maintains focus for input after fullscreen let _ = window_clone.set_focus(); } else { // Fix: Ubuntu 24.04/GNOME window buttons non-functional until resize (#1122) // The window manager needs time to process the MapWindow event before // accepting focus requests. Without this, decorations remain non-interactive. tokio::time::sleep(tokio::time::Duration::from_millis(30)).await; let _ = window_clone.set_focus(); } } }); } Ok(()) }) .on_window_event(move |_window, _event| { if let tauri::WindowEvent::CloseRequested { api, .. } = _event { if hide_on_close && _window.label() == "pake" { // Hide window when hide_on_close is enabled (regardless of tray status) let window = _window.clone(); tauri::async_runtime::spawn(async move { #[cfg(target_os = "macos")] { if window.is_fullscreen().unwrap_or(false) { window.set_fullscreen(false).unwrap(); tokio::time::sleep(Duration::from_millis(900)).await; } } #[cfg(target_os = "linux")] { if window.is_fullscreen().unwrap_or(false) { window.set_fullscreen(false).unwrap(); // Restore focus after exiting fullscreen to fix input issues let _ = window.set_focus(); } } // On macOS, directly hide without minimize to avoid duplicate Dock icons #[cfg(not(target_os = "macos"))] window.minimize().unwrap(); window.hide().unwrap(); }); api.prevent_close(); } // If hide_on_close is false, allow normal close behavior // This lets tauri-plugin-window-state save the window position and size } }) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_app, _event| { // Handle macOS dock icon click to reopen hidden window #[cfg(target_os = "macos")] if let tauri::RunEvent::Reopen { has_visible_windows, .. } = _event { if !has_visible_windows { if let Some(window) = _app.get_webview_window("pake") { let _ = window.show(); let _ = window.set_focus(); } } } }); } pub fn run() { run_app() } ================================================ FILE: src-tauri/src/main.rs ================================================ #![cfg_attr( all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows" )] fn main() { app_lib::run() } ================================================ FILE: src-tauri/src/util.rs ================================================ use crate::app::config::PakeConfig; use std::env; use std::path::PathBuf; use tauri::{AppHandle, Config, Manager, WebviewWindow}; pub fn get_pake_config() -> (PakeConfig, Config) { #[cfg(feature = "cli-build")] let pake_config: PakeConfig = serde_json::from_str(include_str!("../.pake/pake.json")) .expect("Failed to parse pake config"); #[cfg(not(feature = "cli-build"))] let pake_config: PakeConfig = serde_json::from_str(include_str!("../pake.json")).expect("Failed to parse pake config"); #[cfg(feature = "cli-build")] let tauri_config: Config = serde_json::from_str(include_str!("../.pake/tauri.conf.json")) .expect("Failed to parse tauri config"); #[cfg(not(feature = "cli-build"))] let tauri_config: Config = serde_json::from_str(include_str!("../tauri.conf.json")) .expect("Failed to parse tauri 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 show_toast(window: &WebviewWindow, message: &str) { let script = format!(r#"pakeToast("{message}");"#); window.eval(&script).unwrap(); } pub enum MessageType { Start, Success, Failure, } pub fn get_download_message_with_lang( message_type: MessageType, language: Option, ) -> String { let default_start_message = "Start downloading~"; let chinese_start_message = "开始下载中~"; let default_success_message = "Download successful, saved to download directory~"; let chinese_success_message = "下载成功,已保存到下载目录~"; let default_failure_message = "Download failed, please check your network connection~"; let chinese_failure_message = "下载失败,请检查你的网络连接~"; let is_chinese = language .as_ref() .map(|lang| { lang.starts_with("zh") || lang.contains("CN") || lang.contains("TW") || lang.contains("HK") }) .unwrap_or_else(|| { // Try multiple environment variables for better system detection ["LANG", "LC_ALL", "LC_MESSAGES", "LANGUAGE"] .iter() .find_map(|var| env::var(var).ok()) .map(|lang| { lang.starts_with("zh") || lang.contains("CN") || lang.contains("TW") || lang.contains("HK") }) .unwrap_or(false) }); if is_chinese { match message_type { MessageType::Start => chinese_start_message, MessageType::Success => chinese_success_message, MessageType::Failure => chinese_failure_message, } } else { match message_type { MessageType::Start => default_start_message, MessageType::Success => default_success_message, MessageType::Failure => default_failure_message, } } .to_string() } // Check if the file exists, if it exists, add a number to file name 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.file_stem().unwrap().to_string_lossy().to_string(); let extension = new_path.extension().unwrap().to_string_lossy().to_string(); let parent_dir = new_path.parent().unwrap(); let new_file_stem = match file_stem.rfind('-') { Some(index) if file_stem[index + 1..].parse::().is_ok() => { let base_name = &file_stem[..index]; counter = file_stem[index + 1..].parse::().unwrap() + 1; format!("{base_name}-{counter}") } _ => { counter += 1; format!("{file_stem}-{counter}") } }; new_path = parent_dir.join(format!("{new_file_stem}.{extension}")); } new_path.to_string_lossy().into_owned() } ================================================ FILE: src-tauri/tauri.conf.json ================================================ { "productName": "Weekly", "identifier": "com.pake.weekly", "version": "3.10.1", "app": { "withGlobalTauri": true, "trayIcon": { "iconPath": "png/weekly_512.png", "iconAsTemplate": false, "id": "pake-tray" }, "security": { "headers": {}, "csp": null } }, "build": { "frontendDist": "../dist" } } ================================================ FILE: src-tauri/tauri.linux.conf.json ================================================ { "bundle": { "icon": ["png/weekly_512.png"], "active": true, "linux": { "deb": { "depends": ["curl", "wget"] } }, "targets": ["deb", "appimage"] } } ================================================ FILE: src-tauri/tauri.macos.conf.json ================================================ { "bundle": { "icon": ["icons/weekly.icns"], "active": true, "targets": ["dmg"], "macOS": { "signingIdentity": "-", "hardenedRuntime": true, "entitlements": "entitlements.plist", "infoPlist": "Info.plist", "dmg": { "background": "assets/macos/dmg/background.png", "windowSize": { "width": 680, "height": 420 }, "appPosition": { "x": 190, "y": 250 }, "applicationFolderPosition": { "x": 500, "y": 250 } } } } } ================================================ FILE: src-tauri/tauri.windows.conf.json ================================================ { "bundle": { "icon": ["png/weekly_256.ico", "png/weekly_32.ico"], "active": true, "resources": ["png/weekly_32.ico"], "targets": ["msi"], "windows": { "digestAlgorithm": "sha256", "wix": { "language": ["en-US"], "template": "assets/main.wxs" } } } } ================================================ FILE: tests/config.js ================================================ /** * Test Configuration for Pake CLI * * This file contains test configuration and utilities * shared across different test files. */ import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = path.dirname(__dirname); export const CLI_PATH = path.join(PROJECT_ROOT, "dist/cli.js"); // Test timeouts (in milliseconds) export const TIMEOUTS = { QUICK: 10000, // For version, help commands MEDIUM: 20000, // For validation tests LONG: 300000, // For build tests (5 minutes) }; // Test URLs for different scenarios export const TEST_URLS = { WEEKLY: "https://weekly.tw93.fun", VALID: "https://example.com", GITHUB: "https://github.com", GOOGLE: "https://www.google.com", INVALID: "not://a/valid[url]", LOCAL: "./test-file.html", }; // Test assets for different scenarios export const TEST_ASSETS = { WEEKLY_ICNS: "https://cdn.tw93.fun/pake/weekly.icns", INVALID_ICON: "https://example.com/nonexistent.icns", }; // Test app names export const TEST_NAMES = { WEEKLY: "Weekly", BASIC: "TestApp", DEBUG: "DebugApp", FULL: "FullscreenApp", GOOGLE_TRANSLATE: "Google Translate", MAC: "MacApp", }; // Expected file extensions by platform export const PLATFORM_EXTENSIONS = { darwin: "dmg", win32: "msi", linux: "deb", }; // Helper functions export const testHelpers = { /** * Clean test name for filesystem */ sanitizeName: (name) => name.replace(/[^a-zA-Z0-9]/g, ""), /** * Get expected output file for current platform */ getExpectedOutput: (appName) => { const ext = PLATFORM_EXTENSIONS[process.platform] || "bin"; return `${appName}.${ext}`; }, /** * Create test command with common options */ createCommand: (url, options = {}) => { const baseCmd = `node "${CLI_PATH}" "${url}"`; const optionsStr = Object.entries(options) .map(([key, value]) => { if (value === true) return `--${key}`; if (value === false) return ""; return `--${key} "${value}"`; }) .filter(Boolean) .join(" "); return `${baseCmd} ${optionsStr}`.trim(); }, }; export default { PROJECT_ROOT, CLI_PATH, TIMEOUTS, TEST_URLS, TEST_NAMES, PLATFORM_EXTENSIONS, testHelpers, }; ================================================ FILE: tests/index.js ================================================ #!/usr/bin/env node /** * Unified Test Runner for Pake CLI * * This is a simplified, unified test runner that replaces the scattered * test files with a single, easy-to-use interface. */ import { execSync, spawn } from "child_process"; import fs from "fs"; import path from "path"; import ora from "ora"; import config, { TIMEOUTS, TEST_URLS } from "./config.js"; class PakeTestRunner { constructor() { this.results = []; this.tempFiles = []; this.tempDirs = []; } async runAll(options = {}) { const { unit = true, integration = true, builder = true, pakeCliTests = false, e2e = false, quick = false, realBuild = false, // Add option for real build test } = options; console.log("Pake CLI Test Suite"); console.log("======================\n"); this.validateEnvironment(); // Clean up any leftover files from previous test runs console.log("[Clean] Removing any leftover test artifacts..."); this.cleanupTempIcons(); let testCount = 0; if (unit && !quick) { console.log("Running CLI Health Checks..."); await this.runCliHealthChecks(); testCount++; console.log("\nRunning Project Unit Tests (Vitest)..."); try { execSync("npx vitest run", { stdio: "inherit", cwd: config.PROJECT_ROOT, }); this.results.push({ name: "Vitest Unit Tests", passed: true }); testCount++; } catch (e) { console.log("[FAIL] Vitest unit tests failed"); this.results.push({ name: "Vitest Unit Tests", passed: false, error: e.message, }); } } if (integration && !quick) { console.log("\n[Integration] Running Integration Tests..."); await this.runIntegrationTests(); testCount++; } if (builder && !quick) { console.log("\n[Build] Running Builder Tests..."); await this.runBuilderTests(); testCount++; } if (pakeCliTests) { console.log("\n[Package] Running Pake-CLI GitHub Actions Tests..."); await this.runPakeCliTests(); testCount++; } if (e2e && !quick) { console.log("\n[Run] Running End-to-End Tests..."); await this.runE2ETests(); testCount++; console.log("\n[Network] Running Proxy Configuration Test..."); await this.runProxyTest(); testCount++; } if (builder && !quick) { console.log("\n[Build] Running Local File Build Test..."); await this.runLocalFileTest(); testCount++; } if (realBuild && !quick) { // On macOS, prefer multi-arch test as it's more likely to catch issues if (process.platform === "darwin") { console.log("\n[Build] Running Real Build Test (Multi-Arch)..."); await this.runMultiArchBuildTest(); testCount++; } else { console.log("\n[Build] Running Real Build Test..."); await this.runRealBuildTest(); testCount++; } } this.cleanup(); this.displayFinalResults(); const passed = this.results.filter((r) => r.passed).length; const total = this.results.length; return passed === total; } validateEnvironment() { console.log("Environment Validation:"); console.log("-----------------------"); // Check if CLI file exists if (!fs.existsSync(config.CLI_PATH)) { console.log("[FAIL] CLI file not found. Run: pnpm run cli:build"); process.exit(1); } console.log("[PASS] CLI file exists"); // Check if CLI is executable try { execSync(`node "${config.CLI_PATH}" --version`, { encoding: "utf8", timeout: 3000, }); console.log("[PASS] CLI is executable"); } catch (error) { console.log("[FAIL] CLI is not executable"); process.exit(1); } // Platform info console.log(`[PASS] Platform: ${process.platform} (${process.arch})`); console.log(`[PASS] Node.js: ${process.version}`); const isCI = process.env.CI || process.env.GITHUB_ACTIONS; console.log(`[INFO] CI Environment: ${isCI ? "Yes" : "No"}`); console.log(); } async runTest(name, testFn, timeout = TIMEOUTS.MEDIUM) { const spinner = ora(`Running ${name}...`).start(); try { const result = await Promise.race([ testFn(), new Promise((_, reject) => setTimeout(() => reject(new Error("Test timeout")), timeout), ), ]); if (result) { spinner.succeed(`${name}: PASS`); this.results.push({ name, passed: true }); } else { spinner.fail(`${name}: FAIL`); this.results.push({ name, passed: false }); } } catch (error) { spinner.fail(`${name}: ERROR - ${error.message.slice(0, 100)}...`); this.results.push({ name, passed: false, error: error.message, }); } } async runCliHealthChecks() { // Version command test await this.runTest( "Version Command", () => { const output = execSync(`node "${config.CLI_PATH}" --version`, { encoding: "utf8", timeout: TIMEOUTS.QUICK, }); return /^\d+\.\d+\.\d+/.test(output.trim()); }, TIMEOUTS.QUICK, ); // Help command test await this.runTest( "Help Command", () => { const output = execSync(`node "${config.CLI_PATH}"`, { encoding: "utf8", timeout: TIMEOUTS.QUICK, }); return output.includes("Usage: cli [url] [options]"); }, TIMEOUTS.QUICK, ); // URL validation test await this.runTest("URL Validation", () => { try { execSync(`node "${config.CLI_PATH}" "invalid-url" --name TestApp`, { encoding: "utf8", timeout: TIMEOUTS.QUICK, }); return false; // Should have failed } catch (error) { return error.status !== 0; } }); // Number validation test await this.runTest("Number Validation", () => { try { execSync(`node "${config.CLI_PATH}" https://example.com --width abc`, { encoding: "utf8", timeout: TIMEOUTS.QUICK, }); return false; // Should throw error } catch (error) { return error.message.includes("Not a number"); } }); // CLI response time test await this.runTest("CLI Response Time", () => { const start = Date.now(); execSync(`node "${config.CLI_PATH}" --version`, { encoding: "utf8", timeout: TIMEOUTS.QUICK, }); const elapsed = Date.now() - start; return elapsed < 5000; }); // Weekly URL accessibility test await this.runTest("Weekly URL Accessibility", () => { try { const testCommand = `node "${config.CLI_PATH}" ${TEST_URLS.WEEKLY} --name "URLTest" --debug`; execSync(`echo "n" | timeout 5s ${testCommand} || true`, { encoding: "utf8", timeout: 8000, }); return true; // If we get here, URL was parsed successfully } catch (error) { return ( !error.message.includes("Invalid URL") && !error.message.includes("invalid") ); } }); } async runIntegrationTests() { // Process spawning test await this.runTest("CLI Process Spawning", () => { return new Promise((resolve) => { const child = spawn("node", [config.CLI_PATH, "--version"], { stdio: ["pipe", "pipe", "pipe"], }); let output = ""; child.stdout.on("data", (data) => { output += data.toString(); }); child.on("close", (code) => { resolve(code === 0 && /\d+\.\d+\.\d+/.test(output)); }); setTimeout(() => { child.kill(); resolve(false); }, TIMEOUTS.QUICK); }); }); // File system permissions test await this.runTest("File System Permissions", () => { try { const testFile = "test-write-permission.tmp"; fs.writeFileSync(testFile, "test"); this.trackTempFile(testFile); const cliStats = fs.statSync(config.CLI_PATH); return cliStats.isFile(); } catch { return false; } }); // Dependency resolution test await this.runTest("Dependency Resolution", () => { try { const packageJsonPath = path.join(config.PROJECT_ROOT, "package.json"); const packageJson = JSON.parse( fs.readFileSync(packageJsonPath, "utf8"), ); const essentialDeps = ["commander", "chalk", "fs-extra", "execa"]; return essentialDeps.every( (dep) => packageJson.dependencies && packageJson.dependencies[dep], ); } catch { return false; } }); } async runBuilderTests() { // Platform detection test await this.runTest("Platform Detection", () => { const platform = process.platform; const platformConfigs = { darwin: { ext: ".dmg", multiArch: true }, win32: { ext: ".msi", multiArch: false }, linux: { ext: ".deb", multiArch: false }, }; const config = platformConfigs[platform]; return config && typeof config.ext === "string"; }); // Architecture detection test await this.runTest("Architecture Detection", () => { const currentArch = process.arch; const macArch = currentArch === "arm64" ? "aarch64" : currentArch; const linuxArch = currentArch === "x64" ? "amd64" : currentArch; return typeof macArch === "string" && typeof linuxArch === "string"; }); // File naming pattern test await this.runTest("File Naming Patterns", () => { const testNames = ["Simple App", "App-With_Symbols", "CamelCaseApp"]; return testNames.every((name) => { const processed = name.toLowerCase().replace(/\s+/g, ""); return processed.length > 0; }); }); } async runPakeCliTests() { // Package installation test await this.runTest( "pake-cli Package Installation", async () => { try { execSync("pnpm install pake-cli@latest", { encoding: "utf8", timeout: 60000, cwd: "/tmp", }); const pakeCliPath = "/tmp/node_modules/.bin/pake"; return fs.existsSync(pakeCliPath); } catch (error) { console.error("Package installation failed:", error.message); return false; } }, TIMEOUTS.LONG, ); // Version command test await this.runTest("pake-cli Version Command", async () => { try { const version = execSync("npx pake --version", { encoding: "utf8", timeout: 10000, }); return /^\d+\.\d+\.\d+/.test(version.trim()); } catch { return false; } }); // Configuration validation test await this.runTest("Configuration Validation", async () => { try { const validateConfig = (config) => { const required = ["url", "name", "width", "height"]; const hasRequired = required.every((field) => config.hasOwnProperty(field), ); const validTypes = typeof config.url === "string" && typeof config.name === "string" && typeof config.width === "number" && typeof config.height === "number"; let validUrl = false; try { new URL(config.url); validUrl = true; } catch {} const validName = config.name.length > 0; return hasRequired && validTypes && validUrl && validName; }; const testConfig = { url: "https://github.com", name: "github", width: 1200, height: 780, }; return validateConfig(testConfig); } catch { return false; } }); } async runE2ETests() { // GitHub.com CLI build test await this.runTest( "GitHub.com CLI Build Test", async () => { return new Promise((resolve, reject) => { const testName = "GitHubApp"; const command = `node "${config.CLI_PATH}" "https://github.com" --name "${testName}" --debug --width 1200 --height 780`; const child = spawn(command, { shell: true, cwd: config.PROJECT_ROOT, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PAKE_E2E_TEST: "1", PAKE_CREATE_APP: "1", }, }); let buildStarted = false; let configGenerated = false; child.stdout.on("data", (data) => { const output = data.toString(); if ( output.includes("Building app") || output.includes("Compiling") || output.includes("Installing package") || output.includes("Bundling") ) { buildStarted = true; } if ( output.includes("GitHub") && (output.includes("config") || output.includes("name")) ) { configGenerated = true; } }); child.stderr.on("data", (data) => { const output = data.toString(); if ( output.includes("Building app") || output.includes("Compiling") || output.includes("Installing package") || output.includes("Bundling") || output.includes("Finished") || output.includes("Built application at:") ) { buildStarted = true; } }); // Kill process after 60 seconds if build started const timeout = setTimeout(() => { child.kill("SIGTERM"); const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); const dmgFile = path.join(config.PROJECT_ROOT, `${testName}.dmg`); this.trackTempFile(appFile); this.trackTempFile(dmgFile); if (buildStarted) { console.log( `✓ GitHub.com CLI build started successfully (${testName})`, ); resolve(true); } else { reject( new Error("GitHub.com CLI build did not start within timeout"), ); } }, 60000); child.on("close", () => { clearTimeout(timeout); const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); const dmgFile = path.join(config.PROJECT_ROOT, `${testName}.dmg`); this.trackTempFile(appFile); this.trackTempFile(dmgFile); if (buildStarted) { resolve(true); } else { reject( new Error("GitHub.com CLI build process ended before starting"), ); } }); child.on("error", (error) => { reject( new Error(`GitHub.com CLI build process error: ${error.message}`), ); }); child.stdin.end(); }); }, 70000, // 70 seconds timeout ); // Configuration verification test await this.runTest( "Configuration File Verification", async () => { const pakeDir = path.join(config.PROJECT_ROOT, "src-tauri", ".pake"); return new Promise((resolve, reject) => { const testName = "GitHubConfigTest"; const command = `node "${config.CLI_PATH}" "https://github.com" --name "${testName}" --debug --width 1200 --height 780`; const child = spawn(command, { shell: true, cwd: config.PROJECT_ROOT, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PAKE_E2E_TEST: "1", PAKE_CREATE_APP: "1", }, }); const checkConfigFiles = () => { if (fs.existsSync(pakeDir)) { const configFile = path.join(pakeDir, "tauri.conf.json"); const pakeConfigFile = path.join(pakeDir, "pake.json"); if (fs.existsSync(configFile) && fs.existsSync(pakeConfigFile)) { try { const config = JSON.parse( fs.readFileSync(configFile, "utf8"), ); const pakeConfig = JSON.parse( fs.readFileSync(pakeConfigFile, "utf8"), ); if ( config.productName === testName && pakeConfig.windows[0].url === "https://github.com/" ) { child.kill("SIGTERM"); this.trackTempDir(pakeDir); console.log( "✓ GitHub.com configuration files verified correctly", ); resolve(true); return true; } } catch (error) { // Continue if config parsing fails } } } return false; }; child.stdout.on("data", (data) => { const output = data.toString(); if ( output.includes("Installing package") || output.includes("Building app") ) { setTimeout(checkConfigFiles, 1000); } }); child.stderr.on("data", (data) => { const output = data.toString(); if ( output.includes("Installing package") || output.includes("Building app") || output.includes("Package installed") ) { setTimeout(checkConfigFiles, 1000); } }); // Timeout after 20 seconds setTimeout(() => { child.kill("SIGTERM"); this.trackTempDir(pakeDir); reject(new Error("GitHub.com configuration verification timeout")); }, 40000); child.on("error", (error) => { reject( new Error( `GitHub.com config verification error: ${error.message}`, ), ); }); child.stdin.end(); }); }, 45000, ); } async runProxyTest() { await this.runTest("Proxy Configuration", async () => { const command = `node "${config.CLI_PATH}" "https://google.com" --name "ProxyTest" --proxy-url "http://127.0.0.1:7890" --debug`; // We just want to check if the command parses the proxy argument correctly // It might fail to connect if no proxy is running, but that's expected try { execSync(`echo "n" | timeout 5s ${command} || true`, { encoding: "utf8", timeout: 8000, }); return true; } catch (error) { // If it fails with "connection refused" or similar, it means it TRIED to use the proxy return true; } }); } async runLocalFileTest() { await this.runTest("Local File Build Handling", async () => { const testFile = path.join(config.PROJECT_ROOT, "test-local.html"); fs.writeFileSync( testFile, "

Hello Pake

", ); this.trackTempFile(testFile); try { const command = `node "${config.CLI_PATH}" "${testFile}" --name "LocalApp" --debug`; // We just verify it accepts the local file path execSync(`echo "n" | timeout 5s ${command} || true`, { encoding: "utf8", timeout: 8000, }); return true; } catch (error) { // Validation failure is what we want to catch (if it rejected local files) return !error.message.includes("Invalid URL"); } }); } async runRealBuildTest() { // Real build test that actually creates a complete app await this.runTest( "Complete GitHub.com App Build", async () => { return new Promise((resolve, reject) => { const testName = "GitHubRealBuild"; // Platform-specific output files const outputFiles = { darwin: { app: path.join(config.PROJECT_ROOT, `${testName}.app`), installer: path.join(config.PROJECT_ROOT, `${testName}.dmg`), bundleDir: path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle", ), }, linux: { app: path.join( config.PROJECT_ROOT, `src-tauri/target/release/pake`, ), installer: path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/deb", ), bundleDir: path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle", ), }, win32: { app: path.join( config.PROJECT_ROOT, "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi", ), installer: path.join( config.PROJECT_ROOT, "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi", ), bundleDir: path.join( config.PROJECT_ROOT, "src-tauri/target/x86_64-pc-windows-msvc/release/bundle", ), // Alternative directories to check altDirs: [ path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/msi", ), path.join( config.PROJECT_ROOT, "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis", ), path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/nsis", ), ], }, }; const platform = process.platform; const expectedFiles = outputFiles[platform] || outputFiles.darwin; console.log( `[Integration] Starting real build test for GitHub.com...`, ); console.log(`[Note] Platform: ${platform}`); console.log(`[Note] Expected app directory: ${expectedFiles.app}`); console.log( `[Note] Expected installer directory: ${expectedFiles.installer}`, ); if (expectedFiles.bundleDir) { console.log(`[Note] Bundle directory: ${expectedFiles.bundleDir}`); } if (expectedFiles.altDirs) { console.log(`[Note] Alternative directories to check:`); expectedFiles.altDirs.forEach((dir, i) => { console.log(` ${i + 1}. ${dir}`); }); } const command = `node "${config.CLI_PATH}" "https://github.com" --name "${testName}" --width 1200 --height 800 --hide-title-bar`; const child = spawn(command, { shell: true, cwd: config.PROJECT_ROOT, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PAKE_CREATE_APP: "1", }, }); let buildStarted = false; let compilationStarted = false; // Track progress without too much noise child.stdout.on("data", (data) => { const output = data.toString(); if (output.includes("Installing package")) { console.log(" [Package] Installing dependencies..."); } if (output.includes("Building app")) { buildStarted = true; console.log(" [Build] Build started..."); } if (output.includes("Compiling")) { compilationStarted = true; console.log(" ⚙️ Compiling..."); } if (output.includes("Bundling")) { console.log(" [Package] Bundling..."); } if (output.includes("Built application at:")) { console.log(" [PASS] Build completed!"); } }); let errorOutput = ""; child.stderr.on("data", (data) => { const output = data.toString(); if (output.includes("Building app")) buildStarted = true; if (output.includes("Compiling")) compilationStarted = true; if (output.includes("Finished")) console.log(" [PASS] Compilation finished!"); // Capture error output for debugging if ( output.includes("error:") || output.includes("Error:") || output.includes("ERROR") ) { errorOutput += output; } }); // Real timeout - 8 minutes for actual build const timeout = setTimeout(() => { console.log( " [Check] Build timeout reached, checking for output files...", ); const foundFiles = this.findBuildOutputFiles(testName, platform); if (foundFiles.length > 0) { console.log( " [Success] Build completed successfully - found output files!", ); foundFiles.forEach((file) => { console.log(` [App] Found: ${file.path} (${file.type})`); }); console.log(" [Success] Build artifacts tracked for cleanup"); child.kill("SIGTERM"); resolve(true); } else { console.log( " [Warn] Build process completed but no output files found", ); this.debugBuildDirectories(); child.kill("SIGTERM"); reject( new Error("Real build test timeout - no output files found"), ); } }, 480000); // 8 minutes child.on("close", (code) => { clearTimeout(timeout); console.log( ` [Status] Build process finished with exit code: ${code}`, ); const foundFiles = this.findBuildOutputFiles(testName, platform); if (foundFiles.length > 0) { console.log( " [Success] Real build test SUCCESS: Build file(s) generated!", ); foundFiles.forEach((file) => { console.log(` [App] ${file.type}: ${file.path}`); try { const stats = fs.statSync(file.path); const size = (stats.size / 1024 / 1024).toFixed(1); console.log(` Size: ${size}MB`); } catch (error) { console.log(` (Could not get file size)`); } }); console.log(" [Success] Build artifacts tracked for cleanup"); // Track files for cleanup foundFiles.forEach((f) => this.trackTempFile(f.path)); resolve(true); } else if (code === 0 && buildStarted && compilationStarted) { console.log( " [Warn] Build process completed but no output files found", ); this.debugBuildDirectories(); resolve(false); } else { console.log( ` [FAIL] Build process failed with exit code: ${code}`, ); if (buildStarted) { console.log( " [Status] Build was started but failed during execution", ); if (errorOutput.trim()) { console.log(" [Check] Error details:"); errorOutput.split("\n").forEach((line) => { if (line.trim()) console.log(` ${line.trim()}`); }); } this.debugBuildDirectories(); } else { console.log( " [Status] Build failed before starting compilation", ); if (errorOutput.trim()) { console.log(" [Check] Error details:"); errorOutput.split("\n").forEach((line) => { if (line.trim()) console.log(` ${line.trim()}`); }); } } reject(new Error(`Real build test failed with code ${code}`)); } }); child.on("error", (error) => { clearTimeout(timeout); reject( new Error(`Real build test process error: ${error.message}`), ); }); child.stdin.end(); }); }, 500000, // 8+ minutes timeout ); } async runMultiArchBuildTest() { // Multi-arch build test specifically for macOS await this.runTest( "Multi-Arch GitHub.com Build (Universal Binary)", async () => { return new Promise((resolve, reject) => { const testName = "GitHubMultiArch"; const appFile = path.join(config.PROJECT_ROOT, `${testName}.app`); const dmgFile = path.join(config.PROJECT_ROOT, `${testName}.dmg`); console.log( `[Integration] Starting multi-arch build test for GitHub.com...`, ); console.log(`[Note] Expected output: ${appFile}`); console.log( `[Build] Building Universal Binary (Intel + Apple Silicon)`, ); const command = `node "${config.CLI_PATH}" "https://github.com" --name "${testName}" --width 1200 --height 800 --hide-title-bar --multi-arch`; const child = spawn(command, { shell: true, cwd: config.PROJECT_ROOT, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, PAKE_CREATE_APP: "1", HDIUTIL_QUIET: "1", HDIUTIL_NO_AUTOOPEN: "1", }, }); let buildStarted = false; let compilationStarted = false; // Track progress child.stdout.on("data", (data) => { const output = data.toString(); if (output.includes("Installing package")) { console.log(" [Package] Installing dependencies..."); } if (output.includes("Building app")) { buildStarted = true; console.log(" [Build] Multi-arch build started..."); } if (output.includes("Compiling")) { compilationStarted = true; console.log(" ⚙️ Compiling for multiple architectures..."); } if ( output.includes("universal-apple-darwin") || output.includes("Universal") ) { console.log(" [Multi] Universal binary target detected"); } if (output.includes("Bundling")) { console.log(" [Package] Bundling universal binary..."); } if (output.includes("Built application at:")) { console.log(" [PASS] Multi-arch build completed!"); } }); child.stderr.on("data", (data) => { const output = data.toString(); if (output.includes("Building app")) buildStarted = true; if (output.includes("Compiling")) compilationStarted = true; if (output.includes("Finished")) console.log(" [PASS] Multi-arch compilation finished!"); }); // Multi-arch builds take longer - 20 minutes timeout const timeout = setTimeout(() => { console.log( " [Check] Multi-arch build timeout reached, checking for output files...", ); const foundFiles = this.findBuildOutputFiles(testName, "darwin"); if (foundFiles.length > 0) { console.log( " [Success] Multi-arch build completed successfully!", ); foundFiles.forEach((file) => { console.log(` [App] Found: ${file.path} (${file.type})`); }); console.log( " [Multi] Universal binary preserved for inspection", ); child.kill("SIGTERM"); resolve(true); } else { console.log( " [FAIL] Multi-arch build timeout - no output files generated", ); this.debugBuildDirectories( { app: appFile, installer: dmgFile, bundleDir: path.join( config.PROJECT_ROOT, "src-tauri/target/universal-apple-darwin/release/bundle", ), }, "darwin", ); child.kill("SIGTERM"); reject(new Error("Multi-arch build test timeout")); } }, 1200000); // 20 minutes for multi-arch child.on("close", (code) => { clearTimeout(timeout); console.log( ` [Status] Multi-arch build process finished with exit code: ${code}`, ); const foundFiles = this.findBuildOutputFiles(testName, "darwin"); if (foundFiles.length > 0) { console.log( " [Success] Multi-arch build test SUCCESS: Universal binary generated!", ); foundFiles.forEach((file) => { console.log(` [App] ${file.type}: ${file.path}`); }); console.log( " [Multi] Universal binary preserved for inspection", ); // Verify it's actually a universal binary const appFile = foundFiles.find((f) => f.type.includes("App")); if (appFile) { try { const binaryPath = path.join( appFile.path, "Contents/MacOS/pake", ); const fileOutput = execSync(`file "${binaryPath}"`, { encoding: "utf8", }); if (fileOutput.includes("universal binary")) { console.log( " [PASS] Verified: Universal binary created successfully", ); } else { console.log( " [Warn] Note: Binary architecture:", fileOutput.trim(), ); } } catch (error) { console.log( " [Warn] Could not verify binary architecture", ); } } resolve(true); } else if (buildStarted && compilationStarted) { // If build started and compilation happened, but no output files found console.log( " [Warn] Multi-arch build process completed but no output files found", ); this.debugBuildDirectories( { app: appFile, installer: dmgFile, bundleDir: path.join( config.PROJECT_ROOT, "src-tauri/target/universal-apple-darwin/release/bundle", ), }, "darwin", ); resolve(false); } else { // Only reject if the build never started or failed early reject( new Error(`Multi-arch build test failed with code ${code}`), ); } }); child.on("error", (error) => { clearTimeout(timeout); reject( new Error( `Multi-arch build test process error: ${error.message}`, ), ); }); child.stdin.end(); }); }, 1250000, // 20+ minutes timeout ); } // Simplified build output detection - if build succeeds, check for any output files findBuildOutputFiles(testName, platform) { const foundFiles = []; console.log(` [Check] Checking for ${platform} build outputs...`); // Simple approach: look for common build artifacts in project root and common locations const searchLocations = [ // Always check project root first (most builds output there) config.PROJECT_ROOT, // Platform-specific bundle directories ...(platform === "linux" ? [ path.join(config.PROJECT_ROOT, "src-tauri/target/release"), path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/deb", ), ] : []), ...(platform === "win32" ? [ path.join( config.PROJECT_ROOT, "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi", ), path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/msi", ), ] : []), ...(platform === "darwin" ? [ path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/macos", ), path.join( config.PROJECT_ROOT, "src-tauri/target/release/bundle/dmg", ), path.join( config.PROJECT_ROOT, "src-tauri/target/universal-apple-darwin/release/bundle", ), ] : []), ]; // Define what we're looking for based on platform const buildPatterns = { win32: [".msi", ".exe"], linux: [".deb", ".appimage"], darwin: [".dmg", ".app"], }; const patterns = buildPatterns[platform] || buildPatterns.darwin; for (const location of searchLocations) { if (!fs.existsSync(location)) { continue; } console.log( ` [Dir] Checking: ${path.relative(config.PROJECT_ROOT, location)}`, ); try { const items = fs.readdirSync(location); const buildFiles = items.filter((item) => { const itemPath = path.join(location, item); const stats = fs.statSync(itemPath); // Skip common non-build directories if ( stats.isDirectory() && [".git", ".github", "node_modules", "src", "bin", "tests"].includes( item, ) ) { return false; } // Check if it's a build artifact we care about const lowerItem = item.toLowerCase(); return ( patterns.some((pattern) => lowerItem.endsWith(pattern)) || lowerItem.includes(testName.toLowerCase()) || (lowerItem.includes("github") && !item.startsWith(".")) || // Avoid .github directory (platform === "linux" && item === "pake") ); // Linux binary }); buildFiles.forEach((file) => { const fullPath = path.join(location, file); const stats = fs.statSync(fullPath); let fileType = "Build Artifact"; if (file.endsWith(".msi")) fileType = "MSI Installer"; else if (file.endsWith(".exe")) fileType = "Windows Executable"; else if (file.endsWith(".deb")) fileType = "DEB Package"; else if (file.endsWith(".appimage")) fileType = "AppImage"; else if (file.endsWith(".dmg")) fileType = "DMG Image"; else if (file.endsWith(".app")) fileType = stats.isDirectory() ? "macOS App Bundle" : "macOS App"; else if (file === "pake") fileType = "Linux Binary"; foundFiles.push({ path: fullPath, type: fileType, size: stats.isFile() ? stats.size : 0, }); const size = stats.isFile() && stats.size > 0 ? ` (${(stats.size / 1024 / 1024).toFixed(1)}MB)` : ""; console.log(` [PASS] Found ${fileType}: ${file}${size}`); }); // For Linux, also check inside architecture directories if (platform === "linux") { const archDirs = items.filter( (item) => item.includes("amd64") || item.includes("x86_64"), ); for (const archDir of archDirs) { const archPath = path.join(location, archDir); if (fs.statSync(archPath).isDirectory()) { console.log(` [Check] Checking arch directory: ${archDir}`); try { const archFiles = fs.readdirSync(archPath); archFiles .filter((f) => f.endsWith(".deb")) .forEach((debFile) => { const debPath = path.join(archPath, debFile); const debStats = fs.statSync(debPath); foundFiles.push({ path: debPath, type: "DEB Package", size: debStats.size, }); const size = `(${(debStats.size / 1024 / 1024).toFixed(1)}MB)`; console.log( ` [PASS] Found DEB Package: ${debFile} ${size}`, ); }); } catch (error) { console.log( ` [Warn] Could not check ${archDir}: ${error.message}`, ); } } } } } catch (error) { console.log( ` [Warn] Could not read ${location}: ${error.message}`, ); } } console.log(` [Status] Found ${foundFiles.length} build artifact(s)`); return foundFiles; } // Debug function to show directory structure debugBuildDirectories() { console.log(" [Check] Debug: Analyzing build directories..."); const targetDir = path.join(config.PROJECT_ROOT, "src-tauri/target"); if (fs.existsSync(targetDir)) { console.log(" [Check] Target directory structure:"); try { this.listTargetContents(targetDir); } catch (error) { console.log( ` [Warn] Could not list target contents: ${error.message}`, ); } } else { console.log(` [FAIL] Target directory does not exist: ${targetDir}`); } // Check project root for direct outputs console.log(" [Check] Project root files:"); try { const rootFiles = fs .readdirSync(config.PROJECT_ROOT) .filter( (file) => file.endsWith(".app") || file.endsWith(".dmg") || file.endsWith(".msi") || file.endsWith(".deb") || file.endsWith(".exe"), ); if (rootFiles.length > 0) { rootFiles.forEach((file) => { console.log(` [App] ${file}`); }); } else { console.log(` (No build artifacts in project root)`); } } catch (error) { console.log(` [FAIL] Error reading project root: ${error.message}`); } } listTargetContents(targetDir, maxDepth = 3, currentDepth = 0) { if (currentDepth >= maxDepth) return; try { const items = fs.readdirSync(targetDir); items.forEach((item) => { const fullPath = path.join(targetDir, item); const relativePath = path.relative(config.PROJECT_ROOT, fullPath); const indent = " ".repeat(currentDepth + 1); try { const stats = fs.statSync(fullPath); if (stats.isDirectory()) { console.log(`${indent}[Dir] ${relativePath}/`); // Show more directories for Windows debugging if ( item === "bundle" || item === "release" || item === "msi" || item === "nsis" || item.includes("windows") || item.includes("msvc") ) { this.listTargetContents(fullPath, maxDepth, currentDepth + 1); } } else { const size = stats.size > 0 ? ` (${(stats.size / 1024 / 1024).toFixed(1)}MB)` : ""; console.log(`${indent}[File] ${relativePath}${size}`); } } catch (statError) { console.log(`${indent}❓ ${relativePath} (cannot stat)`); } }); } catch (error) { console.log( ` [Warn] Could not list contents of ${targetDir}: ${error.message}`, ); } } trackTempFile(filepath) { this.tempFiles.push(filepath); } trackTempDir(dirpath) { this.tempDirs.push(dirpath); } cleanupTempIcons() { // Clean up temporary icon files generated during tests const iconsDir = path.join(config.PROJECT_ROOT, "src-tauri/icons"); const testNames = [ "urltest", "githubapp", "githubmultiarch", "githubconfigtest", "localapp", "proxytest", ]; testNames.forEach((name) => { const iconPath = path.join(iconsDir, `${name}.icns`); try { if (fs.existsSync(iconPath)) { fs.unlinkSync(iconPath); console.log(` [Clean] Cleaned up temporary icon: ${name}.icns`); } } catch (error) { console.warn(`Warning: Could not clean up icon ${iconPath}`); } }); } cleanup() { console.log("\nCleaning up test artifacts..."); // Clean up temporary icon files generated during tests this.cleanupTempIcons(); // Clean up tracked files this.tempFiles.forEach((file) => { try { if (fs.existsSync(file)) { if (fs.statSync(file).isDirectory()) { fs.rmSync(file, { recursive: true, force: true }); } else { fs.unlinkSync(file); } } } catch (error) { // Ignore errors during cleanup } }); this.tempDirs.forEach((dir) => { try { if (fs.existsSync(dir)) { fs.rmSync(dir, { recursive: true, force: true }); } } catch (error) { // Ignore errors } }); // Aggressive cleanup of known test artifacts in project root const testPatterns = [ "GitHubRealBuild", "GitHubApp", "GitHubMultiArch", "GitHubConfigTest", "LocalApp", "ProxyTest", "URLTest", ]; const extensions = [".app", ".dmg", ".msi", ".deb", ".exe", ".AppImage"]; try { const files = fs.readdirSync(config.PROJECT_ROOT); files.forEach((file) => { // Check if file matches any test name pattern and extension const isTestArtifact = testPatterns.some((pattern) => file.includes(pattern)) && (extensions.some((ext) => file.endsWith(ext)) || (!file.includes(".") && !fs .statSync(path.join(config.PROJECT_ROOT, file)) .isDirectory())); // Linux binary often has no extension if (isTestArtifact) { const fullPath = path.join(config.PROJECT_ROOT, file); console.log(` [Clean] Removing artifact: ${file}`); fs.rmSync(fullPath, { recursive: true, force: true }); } }); // Also clean src-tauri/.pake directory if it exists const pakeDir = path.join(config.PROJECT_ROOT, "src-tauri", ".pake"); if (fs.existsSync(pakeDir)) { fs.rmSync(pakeDir, { recursive: true, force: true }); } } catch (e) { console.warn(" [Warn] Cleanup warning:", e.message); } } displayFinalResults() { const passed = this.results.filter((r) => r.passed).length; const total = this.results.length; console.log("\nOverall Test Summary"); console.log("===================="); console.log(`Total: ${passed}/${total} tests passed`); if (passed === total) { console.log("All tests passed! CLI is ready for use.\n"); } else { console.log( `[FAIL] ${total - passed} test(s) failed. Please check the issues above.\n`, ); // Show failed tests const failed = this.results.filter((r) => !r.passed); if (failed.length > 0) { console.log("Failed tests:"); failed.forEach((result) => { const error = result.error ? ` (${result.error})` : ""; console.log(` [FAIL] ${result.name}${error}`); }); console.log(); } } } } import ReleaseBuildTest from "./release.js"; // Command line interface const args = process.argv.slice(2); // Complete test suite by default - no more smart modes const options = { unit: !args.includes("--no-unit"), integration: !args.includes("--no-integration"), builder: !args.includes("--no-builder"), pakeCliTests: args.includes("--pake-cli"), e2e: args.includes("--e2e"), realBuild: !args.includes("--no-build"), // Always include real build test quick: false, }; // Help message if (args.includes("--help") || args.includes("-h")) { console.log(` [Run] Pake CLI Test Suite Usage: npm test [-- options] Complete Test Suite (Default): pnpm test # Run complete test suite with real build (8-12 minutes) Test Components: [PASS] Unit Tests # CLI commands, validation, response time [PASS] Integration Tests # Process spawning, file permissions, dependencies [PASS] Builder Tests # Platform detection, architecture, file naming [PASS] Real Build Test # Complete GitHub.com app build with packaging Optional Components: --e2e Add end-to-end configuration tests --pake-cli Add pake-cli GitHub Actions tests --release Run release workflow tests (Twitter/WeRead) - Slow! Skip Components (if needed): --no-unit Skip unit tests --no-integration Skip integration tests --no-builder Skip builder tests --no-build Skip real build test Examples: npm test # Complete test suite (recommended) npm test -- --release # Run everything including release workflow pnpm test -- --no-build # Skip real build (faster for development) Environment: CI=1 # Enable CI mode DEBUG=1 # Enable debug output PAKE_CREATE_APP=1 # Allow app creation in tests `); process.exit(0); } // Run tests const runner = new PakeTestRunner(); runner .runAll(options) .then(async (success) => { // Run release workflow tests as part of the standard suite // We skip this if builder tests are explicitly disabled (often used for quick checks) if (success && options.realBuild) { console.log("\n[Package] Running Release Workflow Test..."); console.log( " (This mimics the GitHub Actions release process for popular apps)", ); // Pass skipCliBuild=true since "npm test" already builds the CLI const releaseTester = new ReleaseBuildTest(); const releaseSuccess = await releaseTester.run({ skipCliBuild: true }); if (!releaseSuccess) { console.error("\n[FAIL] Release workflow tests failed"); process.exit(1); } } process.exit(success ? 0 : 1); }) .catch((error) => { console.error("Test runner failed:", error); process.exit(1); }); export default runner; ================================================ FILE: tests/integration/workflow-paths.test.js ================================================ /** * Workflow Path Integration Tests * * These tests verify that the paths used in GitHub Actions workflows * match the actual output paths from the CLI builders. */ import { describe, it, expect } from "vitest"; import path from "path"; describe("Workflow path integration", () => { describe("Platform-specific output paths", () => { it("should match Linux output paths", () => { // Expected paths based on LinuxBuilder behavior const linuxPaths = { deb: { // CLI copies to project root primary: "appname.deb", // Fallback location in bundle directory fallback: "src-tauri/target/release/bundle/deb", }, appimage: { primary: "appname.AppImage", fallback: "src-tauri/target/release/bundle/appimage", }, rpm: { primary: "appname.rpm", fallback: "src-tauri/target/release/bundle/rpm", }, }; // Verify paths are defined expect(linuxPaths.deb.primary).toBeTruthy(); expect(linuxPaths.deb.fallback).toBeTruthy(); expect(linuxPaths.appimage.primary).toBeTruthy(); expect(linuxPaths.appimage.fallback).toBeTruthy(); }); it("should match Windows output paths", () => { // Expected paths based on WinBuilder behavior const windowsPaths = { msi: { // For x64 builds, files are in architecture-specific directory architectureSpecific: "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi", // Fallback to generic path generic: "src-tauri/target/release/bundle/msi", }, }; expect(windowsPaths.msi.architectureSpecific).toBeTruthy(); expect(windowsPaths.msi.generic).toBeTruthy(); }); it("should match macOS output paths", () => { // Expected paths based on MacBuilder behavior const macosPaths = { dmg: { // CLI copies to project root primary: "appname.dmg", // Universal builds use universal-apple-darwin target universalBundle: "src-tauri/target/universal-apple-darwin/release/bundle/dmg", // Regular builds genericBundle: "src-tauri/target/release/bundle/dmg", }, app: { primary: "appname.app", universalBundle: "src-tauri/target/universal-apple-darwin/release/bundle/macos", genericBundle: "src-tauri/target/release/bundle/macos", }, }; expect(macosPaths.dmg.primary).toBeTruthy(); expect(macosPaths.dmg.universalBundle).toBeTruthy(); expect(macosPaths.app.primary).toBeTruthy(); expect(macosPaths.app.universalBundle).toBeTruthy(); }); }); describe("Multi-target scenarios", () => { it("should handle Linux multi-target builds", () => { const targets = "deb,appimage"; const parsedTargets = targets.split(",").map((t) => t.trim()); expect(parsedTargets).toEqual(["deb", "appimage"]); expect(parsedTargets).toHaveLength(2); }); it("should handle targets with spaces", () => { const targets = "deb, appimage, rpm"; const parsedTargets = targets.split(",").map((t) => t.trim()); expect(parsedTargets).toEqual(["deb", "appimage", "rpm"]); expect(parsedTargets).toHaveLength(3); }); it("should filter valid targets", () => { const targets = "deb,invalid,appimage"; const parsedTargets = targets.split(",").map((t) => t.trim()); const validTargets = ["deb", "appimage", "rpm"]; const filtered = parsedTargets.filter((t) => validTargets.includes(t)); expect(filtered).toEqual(["deb", "appimage"]); expect(filtered).not.toContain("invalid"); }); }); describe("Architecture-specific paths", () => { it("should construct correct Windows x64 path", () => { const basePath = "src-tauri/target"; const arch = "x86_64-pc-windows-msvc"; const mode = "release"; const bundleType = "msi"; const fullPath = path.join(basePath, arch, mode, "bundle", bundleType); expect(fullPath).toContain("x86_64-pc-windows-msvc"); expect(fullPath).toContain("release"); expect(fullPath).toContain("msi"); }); it("should construct correct macOS universal path", () => { const basePath = "src-tauri/target"; const arch = "universal-apple-darwin"; const mode = "release"; const bundleType = "dmg"; const fullPath = path.join(basePath, arch, mode, "bundle", bundleType); expect(fullPath).toContain("universal-apple-darwin"); expect(fullPath).toContain("release"); expect(fullPath).toContain("dmg"); }); it("should construct correct Linux arm64 path", () => { const basePath = "src-tauri/target"; const arch = "aarch64-unknown-linux-gnu"; const mode = "release"; const bundleType = "deb"; const fullPath = path.join(basePath, arch, mode, "bundle", bundleType); expect(fullPath).toContain("aarch64-unknown-linux-gnu"); expect(fullPath).toContain("release"); expect(fullPath).toContain("deb"); }); }); describe("File naming patterns", () => { it("should match Linux DEB naming pattern", () => { // Format: {name}_{version}_{arch}.deb const pattern = /^[\w-]+_\d+\.\d+\.\d+_(amd64|arm64)\.deb$/; expect("myapp_1.0.0_amd64.deb").toMatch(pattern); expect("my-app_2.5.1_arm64.deb").toMatch(pattern); expect("invalid.deb").not.toMatch(pattern); }); it("should match Windows MSI naming pattern", () => { // Format: {name}_{version}_{arch}_{language}.msi const pattern = /^[\w-]+_\d+\.\d+\.\d+_(x64|arm64)_[\w-]+\.msi$/; expect("myapp_1.0.0_x64_en-US.msi").toMatch(pattern); expect("my-app_2.5.1_arm64_zh-CN.msi").toMatch(pattern); expect("invalid.msi").not.toMatch(pattern); }); it("should match macOS DMG naming pattern", () => { // Format: {name}_{version}_{arch}.dmg const pattern = /^[\w-]+_\d+\.\d+\.\d+_(universal|x64|aarch64)\.dmg$/; expect("myapp_1.0.0_universal.dmg").toMatch(pattern); expect("my-app_2.5.1_x64.dmg").toMatch(pattern); expect("my-app_3.0.0_aarch64.dmg").toMatch(pattern); expect("invalid.dmg").not.toMatch(pattern); }); }); describe("Path traversal safety", () => { it("should handle paths without directory traversal", () => { const safePaths = [ "src-tauri/target/release/bundle/msi", "output/windows", "dist/cli.js", ]; safePaths.forEach((p) => { expect(p).not.toContain(".."); expect(p).not.toMatch(/\.\.[/\\]/); }); }); it("should normalize paths correctly", () => { const inputPath = "src-tauri/target/../target/release/bundle"; const normalized = path.normalize(inputPath); // path.normalize should resolve the .. reference expect(normalized).not.toContain(".."); expect(normalized).toContain("target"); expect(normalized).toContain("release"); }); }); describe("Cross-platform path handling", () => { it("should use correct path separator", () => { const joined = path.join("src-tauri", "target", "release"); // Should not contain wrong separators if (path.sep === "/") { expect(joined).not.toContain("\\"); } else { expect(joined).not.toContain("/"); } }); it("should handle paths with both separators", () => { // This can happen when paths come from different sources const mixedPath = "src-tauri\\target/release"; const normalized = path.normalize(mixedPath); // After normalization, should use consistent separator const parts = normalized.split(path.sep); expect(parts.length).toBeGreaterThan(1); }); }); }); ================================================ FILE: tests/release.js ================================================ #!/usr/bin/env node /** * Release Build Test * * Tests the actual release workflow by building 2 sample apps. * Validates the complete packaging process. */ import fs from "fs"; import path from "path"; import { execSync } from "child_process"; import { PROJECT_ROOT } from "./config.js"; const GREEN = "\x1b[32m"; const YELLOW = "\x1b[33m"; const BLUE = "\x1b[34m"; const RED = "\x1b[31m"; const NC = "\x1b[0m"; // Fixed test apps for consistent testing const TEST_APPS = ["weread", "twitter"]; class ReleaseBuildTest { constructor() { this.startTime = Date.now(); } log(level, message) { const colors = { INFO: GREEN, WARN: YELLOW, ERROR: RED, DEBUG: BLUE }; const timestamp = new Date().toLocaleTimeString(); console.log(`${colors[level] || NC}[${timestamp}] ${message}${NC}`); } async getAppConfig(appName) { const configPath = path.join(PROJECT_ROOT, "default_app_list.json"); const apps = JSON.parse(fs.readFileSync(configPath, "utf8")); let config = apps.find((app) => app.name === appName); // All test apps should be in default_app_list.json if (!config) { throw new Error(`App "${appName}" not found in default_app_list.json`); } return config; } async buildApp(appName) { this.log("INFO", `🔨 Building ${appName}...`); const config = await this.getAppConfig(appName); if (!config) { throw new Error(`App config not found: ${appName}`); } // Set environment variables process.env.NAME = config.name; process.env.TITLE = config.title; process.env.NAME_ZH = config.name_zh; process.env.URL = config.url; try { // Build config this.log("INFO", `\n📦 Building ${appName}...\n`); // Build the app using CLI directly this.log("DEBUG", "Building app package..."); const commonArgs = `${config.new_window ? "--new-window " : ""}--iterative-build --debug`; const cmd = `node dist/cli.js ${config.url} --name ${config.name} --icon ${config.icon} ${commonArgs}`; try { execSync(cmd, { stdio: "pipe", timeout: 480000, // 8 minutes env: { ...process.env, PAKE_CREATE_APP: "1" }, }); // Check files immediately after build const outputFiles = this.findOutputFiles(config.name); if (outputFiles.length === 0) { throw new Error("No output files generated"); } } catch (buildError) { throw new Error(`Build failed: ${buildError.message}`); } // Always return true - release test just needs to verify the process works this.log("INFO", `✅ Successfully built ${config.title}`); return true; } catch (error) { this.log("ERROR", `❌ Failed to build ${config.title}: ${error.message}`); return false; } } findOutputFiles(appName) { const files = []; // Check for direct output files (created by PAKE_CREATE_APP=1) const directPatterns = [ `${appName}.dmg`, `${appName}.app`, `${appName}.msi`, `${appName}.deb`, `${appName}.AppImage`, ]; // Use Node.js fs instead of Unix find command for cross-platform compatibility for (const pattern of directPatterns) { try { const rootPath = path.join(PROJECT_ROOT); if (fs.existsSync(rootPath)) { const items = fs.readdirSync(rootPath); const matching = items.filter((item) => item === pattern); matching.forEach((item) => { files.push(path.join(rootPath, item)); }); } } catch (error) { // Ignore errors } } // Also check bundle directories for app and dmg files const bundleLocations = [ `src-tauri/target/release/bundle/macos/${appName}.app`, `src-tauri/target/release/bundle/dmg/${appName}.dmg`, `src-tauri/target/universal-apple-darwin/release/bundle/macos/${appName}.app`, `src-tauri/target/universal-apple-darwin/release/bundle/dmg/${appName}.dmg`, `src-tauri/target/release/bundle/deb/${appName}_*.deb`, `src-tauri/target/release/bundle/msi/${appName}_*.msi`, `src-tauri/target/release/bundle/appimage/${appName}_*.AppImage`, ]; for (const location of bundleLocations) { try { if (location.includes("*")) { // Handle wildcard patterns using Node.js const dir = path.dirname(location); const pattern = path.basename(location); const fullDir = path.join(PROJECT_ROOT, dir); if (fs.existsSync(fullDir)) { const items = fs.readdirSync(fullDir); // Convert glob pattern to regex const regex = new RegExp( "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", ); const matching = items.filter((item) => regex.test(item)); matching.forEach((item) => { const fullPath = path.join(fullDir, item); if (fs.statSync(fullPath).isFile() || item.endsWith(".app")) { files.push(fullPath); } }); } } else { // Direct path check const fullPath = path.join(PROJECT_ROOT, location); if (fs.existsSync(fullPath)) { files.push(fullPath); } } } catch (error) { // Ignore errors } } return files.filter((f) => f && f.length > 0); } async run(options = {}) { console.log(`${BLUE}🚀 Release Build Test${NC}`); console.log(`${BLUE}===================${NC}`); // Build CLI first (unless skipped) if (!options.skipCliBuild) { this.log("INFO", "🔨 Building CLI..."); try { execSync(`pnpm run cli:build`, { stdio: "pipe" }); } catch (e) { this.log("ERROR", "Failed to build CLI"); return false; } } console.log(`Testing apps: ${TEST_APPS.join(", ")}`); console.log(""); let successCount = 0; const results = []; for (const appName of TEST_APPS) { try { const success = await this.buildApp(appName); if (success) { successCount++; // Optional: Show generated files if found const outputFiles = this.findOutputFiles(appName); if (outputFiles.length > 0) { this.log("INFO", `📦 Generated files for ${appName}:`); outputFiles.forEach((file) => { try { const stats = fs.statSync(file); const size = (stats.size / 1024 / 1024).toFixed(1); this.log("INFO", ` - ${file} (${size}MB)`); } catch (error) { this.log("INFO", ` - ${file}`); } }); } } results.push({ app: appName, success, outputFiles: this.findOutputFiles(appName), }); } catch (error) { this.log("ERROR", `Failed to build ${appName}: ${error.message}`); results.push({ app: appName, success: false, error: error.message }); } console.log(""); // Add spacing between apps } // Summary const duration = Math.round((Date.now() - this.startTime) / 1000); console.log(`${BLUE}📊 Test Summary${NC}`); console.log(`==================`); console.log(`✅ Successful builds: ${successCount}/${TEST_APPS.length}`); console.log(`⏱️ Total time: ${duration}s`); if (successCount === TEST_APPS.length) { this.log("INFO", "🎉 All test builds completed successfully!"); this.log("INFO", "Release workflow logic is working correctly."); } else { this.log( "ERROR", `⚠️ ${TEST_APPS.length - successCount} builds failed.`, ); } return successCount === TEST_APPS.length; } } // Run if called directly if (import.meta.url === `file://${process.argv[1]}`) { const tester = new ReleaseBuildTest(); const success = await tester.run(); process.exit(success ? 0 : 1); } export default ReleaseBuildTest; ================================================ FILE: tests/unit/builders.test.ts ================================================ 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); }); }); 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('should check if target is valid', () => { const validTargets = ['deb', 'appimage', 'rpm']; const testTargets = ['deb', 'invalid', 'appimage', 'msi']; const valid = testTargets.filter((t) => validTargets.includes(t)); const invalid = testTargets.filter((t) => !validTargets.includes(t)); expect(valid).toEqual(['deb', 'appimage']); expect(invalid).toEqual(['invalid', 'msi']); }); }); describe('Architecture suffix handling', () => { it('should extract format from arm64 target', () => { const target = 'deb-arm64'; const format = target.replace('-arm64', ''); expect(format).toBe('deb'); }); it('should keep format without suffix', () => { const target = 'deb'; const format = target.replace('-arm64', ''); expect(format).toBe('deb'); }); it('should handle appimage-arm64', () => { const target = 'appimage-arm64'; const format = target.replace('-arm64', ''); expect(format).toBe('appimage'); }); }); }); ================================================ FILE: tests/unit/cli-options.test.ts ================================================ import { describe, expect, it } from 'vitest'; import { getCliProgram } from '../../bin/helpers/cli-program.js'; describe('CLI options', () => { const program = getCliProgram(); it('registers hidden --multi-window option', () => { const option = program.options.find( (item) => item.long === '--multi-window', ); expect(option).toBeDefined(); expect(option?.defaultValue).toBe(false); }); it('registers hidden --internal-url-regex option', () => { const option = program.options.find( (item) => item.long === '--internal-url-regex', ); expect(option).toBeDefined(); expect(option?.defaultValue).toBe(''); }); it('registers hidden --identifier option', () => { const option = program.options.find((item) => item.long === '--identifier'); expect(option).toBeDefined(); expect(option?.hidden).toBe(true); }); it('registers visible --install option', () => { const option = program.options.find((item) => item.long === '--install'); expect(option).toBeDefined(); expect(option?.defaultValue).toBe(false); expect(option?.hidden).toBe(false); }); }); ================================================ FILE: tests/unit/file-finding.test.js ================================================ /** * Cross-platform file finding tests * * These tests verify that file finding logic works correctly * across different platforms (Windows, macOS, Linux). */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import fs from "fs"; import path from "path"; import os from "os"; describe("Cross-platform file finding", () => { let tempDir; beforeEach(() => { // Create a temporary directory for testing tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pake-test-")); }); afterEach(() => { // Clean up temporary directory if (fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); describe("findFilesByPattern", () => { /** * Simulates the fixed findOutputFiles logic from tests/release.js */ function findFilesByPattern(dir, pattern) { const files = []; if (!fs.existsSync(dir)) { return files; } const items = fs.readdirSync(dir); // Convert glob pattern to regex const regex = new RegExp( "^" + pattern.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", ); const matching = items.filter((item) => regex.test(item)); matching.forEach((item) => { const fullPath = path.join(dir, item); try { const stat = fs.statSync(fullPath); if (stat.isFile() || item.endsWith(".app")) { files.push(fullPath); } } catch (error) { // Skip files we can't stat } }); return files; } it("should find exact filename matches", () => { const testFile = path.join(tempDir, "test.deb"); fs.writeFileSync(testFile, "test content"); const found = findFilesByPattern(tempDir, "test.deb"); expect(found).toHaveLength(1); expect(found[0]).toBe(testFile); }); it("should find files with wildcard patterns", () => { const files = [ "myapp_1.0.0_amd64.deb", "myapp_1.0.0_arm64.deb", "other.txt", ]; files.forEach((file) => { fs.writeFileSync(path.join(tempDir, file), "test"); }); const found = findFilesByPattern(tempDir, "myapp_*.deb"); expect(found).toHaveLength(2); expect(found.map((f) => path.basename(f)).sort()).toEqual([ "myapp_1.0.0_amd64.deb", "myapp_1.0.0_arm64.deb", ]); }); it("should handle question mark wildcards", () => { const files = ["app1.msi", "app2.msi", "app10.msi"]; files.forEach((file) => { fs.writeFileSync(path.join(tempDir, file), "test"); }); const found = findFilesByPattern(tempDir, "app?.msi"); expect(found).toHaveLength(2); expect(found.map((f) => path.basename(f)).sort()).toEqual([ "app1.msi", "app2.msi", ]); }); it("should return empty array for non-existent directory", () => { const nonExistent = path.join(tempDir, "does-not-exist"); const found = findFilesByPattern(nonExistent, "*.deb"); expect(found).toHaveLength(0); }); it("should work on Windows paths", () => { // Test with backslashes (Windows-style paths) const testFile = path.join(tempDir, "windows-test.msi"); fs.writeFileSync(testFile, "test content"); const found = findFilesByPattern(tempDir, "*.msi"); expect(found).toHaveLength(1); expect(path.basename(found[0])).toBe("windows-test.msi"); }); it("should handle .app bundles on macOS", () => { const appBundle = path.join(tempDir, "MyApp.app"); fs.mkdirSync(appBundle, { recursive: true }); const found = findFilesByPattern(tempDir, "MyApp.app"); expect(found).toHaveLength(1); expect(found[0]).toBe(appBundle); }); it("should ignore subdirectories when matching files", () => { const subdir = path.join(tempDir, "subdir.deb"); fs.mkdirSync(subdir); const file = path.join(tempDir, "app.deb"); fs.writeFileSync(file, "test"); const found = findFilesByPattern(tempDir, "*.deb"); // Should only find the file, not the directory expect(found).toHaveLength(1); expect(found[0]).toBe(file); }); }); describe("findInMultipleLocations", () => { /** * Simulates the pattern used in workflow files: * Check project root first, then fallback to bundle directory */ function findInMultipleLocations(locations) { for (const location of locations) { const fullPath = path.join(tempDir, location); if (fs.existsSync(fullPath)) { return fullPath; } } return null; } it("should find file in first location", () => { const file1 = path.join(tempDir, "test.dmg"); fs.writeFileSync(file1, "test"); const found = findInMultipleLocations([ "test.dmg", "src-tauri/target/release/bundle/dmg/test.dmg", ]); expect(found).toBe(file1); }); it("should fallback to second location", () => { const bundleDir = path.join( tempDir, "src-tauri/target/release/bundle/dmg", ); fs.mkdirSync(bundleDir, { recursive: true }); const file2 = path.join(bundleDir, "test.dmg"); fs.writeFileSync(file2, "test"); const found = findInMultipleLocations([ "test.dmg", "src-tauri/target/release/bundle/dmg/test.dmg", ]); expect(found).toBe(file2); }); it("should return null when file not found", () => { const found = findInMultipleLocations([ "test.dmg", "other/path/test.dmg", ]); expect(found).toBeNull(); }); it("should work with Windows paths", () => { const msiDir = path.join( tempDir, "src-tauri", "target", "x86_64-pc-windows-msvc", "release", "bundle", "msi", ); fs.mkdirSync(msiDir, { recursive: true }); const msiFile = path.join(msiDir, "test.msi"); fs.writeFileSync(msiFile, "test"); const found = findInMultipleLocations([ "test.msi", "src-tauri/target/x86_64-pc-windows-msvc/release/bundle/msi/test.msi", ]); expect(found).toBe(msiFile); }); }); describe("Path normalization", () => { it("should handle mixed slashes", () => { // This is important for cross-platform compatibility const mixedPath = "src-tauri\\target/release\\bundle/msi"; const normalized = path.normalize(mixedPath); expect(normalized).toBeTruthy(); // Should work regardless of platform }); it("should handle path.join correctly on all platforms", () => { const joined = path.join( "src-tauri", "target", "release", "bundle", "msi", ); // Should use platform-specific separator const parts = joined.split(path.sep); expect(parts).toHaveLength(5); expect(parts[0]).toBe("src-tauri"); expect(parts[4]).toBe("msi"); }); }); }); ================================================ FILE: tests/unit/identifier.test.ts ================================================ import { describe, expect, it } from 'vitest'; import { getIdentifier, resolveIdentifier } from '@/utils/info'; describe('identifier generation', () => { const url = 'https://gmail.com'; it('generates different identifiers for the same URL when app names differ', () => { expect(getIdentifier(url, 'Work Gmail')).not.toBe( getIdentifier(url, 'Personal Gmail'), ); }); it('generates stable identifiers for the same URL and app name', () => { expect(getIdentifier(url, 'Work Gmail')).toBe( getIdentifier(url, 'Work Gmail'), ); }); it('prefers a custom identifier when provided', () => { expect(resolveIdentifier(url, 'Work Gmail', 'com.example.work-gmail')).toBe( 'com.example.work-gmail', ); }); }); ================================================ FILE: tests/unit/name.test.ts ================================================ import { describe, it, expect } from 'vitest'; import { getSafeAppName, generateLinuxPackageName, generateIdentifierSafeName, } from '@/utils/name'; describe('getSafeAppName', () => { it('should handle simple names', () => { expect(getSafeAppName('MyApp')).toBe('myapp'); }); it('should handle names with spaces', () => { expect(getSafeAppName('My App')).toBe('my_app'); }); it('should handle names with hyphens', () => { expect(getSafeAppName('my-app')).toBe('my-app'); }); it('should handle Chinese names', () => { expect(getSafeAppName('我的应用')).toBe('我的应用'); }); it('should handle mixed Chinese and English', () => { expect(getSafeAppName('我的 App')).toBe('我的_app'); }); it('should preserve special characters like @', () => { expect(getSafeAppName('App@2024')).toBe('app@2024'); }); it('should replace forward slashes', () => { expect(getSafeAppName('My/App')).toBe('my_app'); }); it('should replace backslashes', () => { expect(getSafeAppName('My\\App')).toBe('my_app'); }); it('should replace colons', () => { expect(getSafeAppName('App:Name')).toBe('app_name'); }); it('should replace asterisks', () => { expect(getSafeAppName('App*Name')).toBe('app_name'); }); it('should replace question marks', () => { expect(getSafeAppName('App?Name')).toBe('app_name'); }); it('should replace double quotes', () => { expect(getSafeAppName('App"Name')).toBe('app_name'); }); it('should replace angle brackets', () => { expect(getSafeAppName('App')).toBe('app_name_'); }); it('should replace pipes', () => { expect(getSafeAppName('App|Name')).toBe('app_name'); }); it('should handle all uppercase names', () => { expect(getSafeAppName('APP')).toBe('app'); }); it('should handle single character names', () => { expect(getSafeAppName('a')).toBe('a'); }); it('should handle numeric names', () => { expect(getSafeAppName('123')).toBe('123'); }); it('should handle leading/trailing spaces', () => { expect(getSafeAppName(' App ')).toBe('_app_'); }); it('should handle trailing dots', () => { expect(getSafeAppName('App...')).toBe('app'); }); it('should truncate very long names', () => { const longName = 'A'.repeat(300); const expected = 'a'.repeat(255); expect(getSafeAppName(longName)).toBe(expected); }); }); describe('generateLinuxPackageName', () => { it('should handle simple names', () => { expect(generateLinuxPackageName('MyApp')).toBe('myapp'); }); it('should replace spaces and special characters with hyphens', () => { expect(generateLinuxPackageName('My App! @123')).toBe('my-app-123'); }); it('should handle multiple hyphens', () => { expect(generateLinuxPackageName('my--app')).toBe('my-app'); }); it('should handle Chinese characters', () => { expect(generateLinuxPackageName('我的应用')).toBe('我的应用'); }); it('should trim leading/trailing hyphens', () => { expect(generateLinuxPackageName('--my-app--')).toBe('my-app'); }); }); describe('generateIdentifierSafeName', () => { it('should handle alphanumeric names', () => { expect(generateIdentifierSafeName('MyApp123')).toBe('myapp123'); }); it('should remove special characters', () => { expect(generateIdentifierSafeName('My-App! @#')).toBe('myapp'); }); it('should handle Chinese characters', () => { expect(generateIdentifierSafeName('我的应用App')).toBe('我的应用app'); }); it('should provide fallback for names without alphanumeric/Chinese', () => { expect(generateIdentifierSafeName('!@#$')).not.toBe(''); expect(generateIdentifierSafeName('!@#$')).not.toBe('!@#$'); }); }); ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "ESNext", "target": "es2020", "types": ["node"], "lib": ["es2020", "dom"], "esModuleInterop": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "strict": true, "skipLibCheck": true, "noImplicitAny": true, "moduleResolution": "node", "sourceMap": true, "outDir": "dist", "baseUrl": ".", "paths": { "@/*": ["bin/*"] } }, "include": ["bin/**/*"] } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; import path from 'path'; export default defineConfig({ test: { environment: 'node', include: [ 'bin/**/*.{test,spec}.ts', 'tests/unit/**/*.{test,spec}.{ts,js}', 'tests/integration/**/*.{test,spec}.{ts,js}', ], }, resolve: { alias: { '@': path.resolve(__dirname, './bin'), }, }, });