From e3a6e7c57e3da953b4efcdd5440f42df6e3c0608 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Wed, 22 Apr 2026 11:44:52 +0800 Subject: [PATCH 001/120] feat: add incognito mode and window size options for app packaging --- .github/workflows/release.yml | 3 ++ .github/workflows/single-app.yaml | 48 +++++++++++++++++++++++++++++++ default_app_list.json | 5 +++- docs/faq.md | 8 ++++++ docs/faq_CN.md | 8 ++++++ 5 files changed, 71 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 259d9b6074..910f26db1a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -97,6 +97,9 @@ jobs: url: ${{ matrix.config.url }} icon: ${{ matrix.config.icon }} new_window: ${{ matrix.config.new_window || false }} + incognito: ${{ matrix.config.incognito || false }} + width: ${{ matrix.config.width || 1200 }} + height: ${{ matrix.config.height || 780 }} # Publish Docker image (runs in parallel with app builds) publish-docker: diff --git a/.github/workflows/single-app.yaml b/.github/workflows/single-app.yaml index 80ffd0c4b3..a3b0c5e6a6 100644 --- a/.github/workflows/single-app.yaml +++ b/.github/workflows/single-app.yaml @@ -30,6 +30,18 @@ on: description: "Allow sites to open new windows" required: false default: false + incognito: + description: "Enable incognito mode" + required: false + default: false + width: + description: "Window width" + required: false + default: 1200 + height: + description: "Window height" + required: false + default: 780 workflow_call: inputs: name: @@ -61,6 +73,21 @@ on: type: boolean required: false default: false + incognito: + description: "Enable incognito mode" + type: boolean + required: false + default: false + width: + description: "Window width" + type: number + required: false + default: 1200 + height: + description: "Window height" + type: number + required: false + default: 780 secrets: PAKE_SIGNING_IDENTITY: required: false @@ -187,6 +214,13 @@ jobs: ARGS+=("--new-window") fi + if [ "${{ inputs.incognito }}" = "true" ]; then + ARGS+=("--incognito") + fi + + ARGS+=("--width" "${{ inputs.width }}") + ARGS+=("--height" "${{ inputs.height }}") + # Build once with multiple targets (faster than separate builds) node dist/cli.js "${ARGS[@]}" --targets deb,appimage @@ -234,6 +268,13 @@ jobs: ARGS+=("--new-window") fi + if [ "${{ inputs.incognito }}" = "true" ]; then + ARGS+=("--incognito") + fi + + ARGS+=("--width" "${{ inputs.width }}") + ARGS+=("--height" "${{ inputs.height }}") + node dist/cli.js "${ARGS[@]}" --targets universal --multi-arch mkdir -p output/macos @@ -303,6 +344,13 @@ jobs: $args += "--new-window" } + if ("${{ inputs.incognito }}" -eq "true") { + $args += "--incognito" + } + + $args += "--width", "${{ inputs.width }}" + $args += "--height", "${{ inputs.height }}" + $args += "--targets", "x64" node dist/cli.js $args diff --git a/default_app_list.json b/default_app_list.json index e0e689833f..773b3c7593 100644 --- a/default_app_list.json +++ b/default_app_list.json @@ -3,7 +3,10 @@ "name": "wechat", "title": "WeChat", "name_zh": "微信", - "url": "https://wx.qq.com/" + "url": "https://wx.qq.com/", + "incognito": true, + "width": 1000, + "height": 720 }, { "name": "deepseek", diff --git a/docs/faq.md b/docs/faq.md index 6c9a8cbc3e..edbeda1bc8 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -385,6 +385,14 @@ This is usually due to web compatibility issues. Try: Some authentication providers, especially Google, may block sign-in inside embedded webviews. Because Pake packages sites into a desktop webview, Google properties or sites that rely on Google OAuth may still fail to sign in even when `--new-window` or `--multi-window` is enabled. This is provider policy, not a packaging bug. In those cases, use the normal browser, a browser-installed app, or a native desktop client. +5. **WeChat Web login environment error** + + WeChat detects the WebView and writes a flag cookie that blocks subsequent logins. Add `--incognito` when packaging to bypass it, at the cost of requiring a QR scan on every launch: + + ```bash + pake https://wx.qq.com --name WeChat --incognito + ``` + --- ## Installation Issues diff --git a/docs/faq_CN.md b/docs/faq_CN.md index ee08b8de1a..87085ec7b4 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -385,6 +385,14 @@ Pake 可以自动转换图标,但提供正确的格式更可靠。 某些认证提供方,尤其是 Google,可能会阻止在嵌入式 WebView 中完成登录。由于 Pake 是把网站包装进桌面 WebView,Google 自家站点或依赖 Google OAuth 的网站,即使启用了 `--new-window` 或 `--multi-window`,也仍然可能无法在应用内完成登录。这属于提供方策略限制,不是打包逻辑错误。遇到这种情况时,建议改用普通浏览器、浏览器安装版站点应用,或官方原生桌面客户端。 +5. **微信 Web 版登录环境异常** + + 微信检测到 WebView 后会写入标记 Cookie,导致后续持续被拦截。打包时加 `--incognito` 可解决,代价是每次启动都需要重新扫码登录: + + ```bash + pake https://wx.qq.com --name WeChat --incognito + ``` + --- ## 安装问题 From ed5c523344603f5168bf69573de582dc1002666d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 03:45:07 +0000 Subject: [PATCH 002/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 338 +++++++++++++++++++++++------------------------ 1 file changed, 169 insertions(+), 169 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index e4065f9bfa..9fdc2240bc 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -102,24 +102,24 @@ - + - - - AielloChan + + + YangguangZhou - + - - - YangguangZhou + + + AielloChan @@ -157,57 +157,57 @@ - + - - - exposir + + + GoodbyeNJN - + - - - lkieryan + + + eltociear - + - - - g1eny0ung + + + kittizz - + - - - xinyii + + + mattbajorek - + - - - Tianj0o + + + vaddisrinivas @@ -223,387 +223,387 @@ - + - - - vaddisrinivas + + + Tianj0o - + - - - mattbajorek + + + xinyii - + - - - kittizz + + + g1eny0ung - + - - - eltociear + + + lkieryan - + - - - GoodbyeNJN + + + exposir - + - - - princemaple + + + 2nthony - + - - - RoyRao2333 + + + ACGNnsj - + - - - sebastianbreguel + + + kidylee - + - - - youxi798 + + + nekomeowww - + - - - fulldecent + + + kuishou68 - + - - - beautifulrem + + + turkyden - + - - - bocanhcam + + + fvn-elmy - + - - - dbraendle + + + Fechin - + - - - geekvest + + + ImgBotApp - + - - - houhoz + + + droid-Q - + - - - lakca + + + JohannLai - + - - - liudonghua123 + + + Jason6987 - + - - - liusishan + + + Milo123459 - + - - - piaoyidage + + + pgoslatara - + - - - enihsyou + + + princemaple - + - - - hetz + + + RoyRao2333 - + - - - pgoslatara + + + sebastianbreguel - + - - - Milo123459 + + + youxi798 - + - - - Jason6987 + + + fulldecent - + - - - JohannLai + + + beautifulrem - + - - - droid-Q + + + bocanhcam - + - - - ImgBotApp + + + dbraendle - + - - - Fechin + + + geekvest - + - - - fvn-elmy + + + houhoz - + - - - turkyden + + + lakca - + - - - kuishou68 + + + liudonghua123 - + - - - nekomeowww + + + liusishan - + - - - kidylee + + + piaoyidage - + - - - ACGNnsj + + + enihsyou - + - - - 2nthony + + + hetz \ No newline at end of file From cb911ec7da8931cfac59c0c262da70920006071d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 27 Apr 2026 20:06:29 +0800 Subject: [PATCH 003/120] fix: disable WebKit compositing mode by default on Linux to fix blank screen on Wayland without GPU --- src-tauri/src/lib.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 62441120ae..1fc4cf75b9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -27,6 +27,9 @@ pub fn run_app() { if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); } + if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { + std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); + } } let (pake_config, tauri_config) = get_pake_config(); From e30f3250816e67bfdc496d38b3b27050611c5b46 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 3 May 2026 22:41:18 +0800 Subject: [PATCH 004/120] fix: make CN mirrors explicit opt-in --- bin/builders/BaseBuilder.ts | 136 +++++++++++++---------- bin/helpers/rust.ts | 13 +-- bin/utils/ip.ts | 57 ---------- bin/utils/mirror.ts | 7 ++ dist/cli.js | 187 +++++++++++++------------------- docs/faq.md | 16 ++- docs/faq_CN.md | 16 ++- package.json | 6 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- tests/unit/base-builder.test.ts | 120 +++++++++++++++++++- 11 files changed, 314 insertions(+), 248 deletions(-) delete mode 100644 bin/utils/ip.ts create mode 100644 bin/utils/mirror.ts diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index 1a48ce2264..d416557cdd 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -14,7 +14,7 @@ import { import { npmDirectory } from '@/utils/dir'; import { getSpinner } from '@/utils/info'; import { shellExec } from '@/utils/shell'; -import { isChinaDomain } from '@/utils/ip'; +import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; import { IS_MAC } from '@/utils/platform'; import logger from '@/options/logger'; @@ -102,6 +102,61 @@ export default abstract class BaseBuilder { } } + private getInstallCommand( + packageManager: string, + useCnMirror: boolean, + ): string { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = + packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; + } + + private isGeneratedCnMirrorConfig( + projectConfig: string, + cnMirrorConfig: string, + ): boolean { + return projectConfig.trim() === cnMirrorConfig.trim(); + } + + private async configureCargoRegistry( + tauriSrcPath: string, + useCnMirror: boolean, + ): Promise { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await this.copyFileWithSamePathGuard(projectCnConf, projectConf); + return; + } + + if (!(await fsExtra.pathExists(projectConf))) { + return; + } + + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + + if (this.isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + + if (projectConfig.includes('rsproxy.cn')) { + logger.warn( + `✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`, + ); + } + } + async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); const tauriTargetPath = path.join(tauriSrcPath, 'target'); @@ -129,17 +184,12 @@ export default abstract class BaseBuilder { } } - const isChina = await isChinaDomain('www.npmjs.com'); const spinner = getSpinner('Installing package...'); - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - await fsExtra.ensureDir(rustProjectDir); + const useCnMirror = isCnMirrorEnabled(); + await this.configureCargoRegistry(tauriSrcPath, useCnMirror); // 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(); @@ -153,64 +203,30 @@ export default abstract class BaseBuilder { ); } - let usedMirror = isChina; + if (useCnMirror) { + logger.info( + `✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`, + ); + } try { - if (isChina) { - logger.info( - `✺ Located in China, using ${packageManager}/rsProxy CN mirror.`, - ); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - } else { - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - } + await shellExec( + this.getInstallCommand(packageManager, useCnMirror), + timeout, + { + ...buildEnv, + CI: 'true', + }, + ); spinner.succeed(chalk.green('Package installed!')); - } catch (error: unknown) { - // If installation times out and we haven't tried the mirror yet, retry with mirror - if ( - error instanceof Error && - error.message.includes('timed out') && - !usedMirror - ) { - spinner.fail( - chalk.yellow('Installation timed out, retrying with CN mirror...'), - ); + } catch (error) { + spinner.fail(chalk.red('Installation failed')); + if (!useCnMirror) { logger.info( - '✺ Retrying installation with CN mirror for better speed...', + `✺ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`, ); - - const retrySpinner = getSpinner('Retrying installation...'); - usedMirror = true; - - try { - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec( - `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, - timeout, - { ...buildEnv, CI: 'true' }, - ); - retrySpinner.succeed( - chalk.green('Package installed with CN mirror!'), - ); - } catch (retryError) { - retrySpinner.fail(chalk.red('Installation failed')); - throw retryError; - } - } else { - spinner.fail(chalk.red('Installation failed')); - throw error; } + throw error; } if (!tauriTargetPathExists) { diff --git a/bin/helpers/rust.ts b/bin/helpers/rust.ts index 946408c87e..4a61f76ae2 100644 --- a/bin/helpers/rust.ts +++ b/bin/helpers/rust.ts @@ -5,9 +5,9 @@ import chalk from 'chalk'; import { execaSync } from 'execa'; import { getSpinner } from '@/utils/info'; +import { isCnMirrorEnabled } from '@/utils/mirror'; import { IS_WIN } from '@/utils/platform'; import { shellExec } from '@/utils/shell'; -import { isChinaDomain } from '@/utils/ip'; function normalizePathForComparison(targetPath: string) { const normalized = path.normalize(targetPath); @@ -68,19 +68,16 @@ export function ensureRustEnv() { } export async function installRust() { - const isActions = process.env.GITHUB_ACTIONS; - const isInChina = await isChinaDomain('sh.rustup.rs'); - const rustInstallScriptForMac = - isInChina && !isActions - ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' - : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; + const rustInstallScriptForUnix = isCnMirrorEnabled() + ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' + : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { await shellExec( - IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, + IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined, ); diff --git a/bin/utils/ip.ts b/bin/utils/ip.ts deleted file mode 100644 index daf299990f..0000000000 --- a/bin/utils/ip.ts +++ /dev/null @@ -1,57 +0,0 @@ -import dns from 'dns'; -import http from 'http'; -import { promisify } from 'util'; - -import logger from '@/options/logger'; - -const resolve = promisify(dns.resolve); - -const ping = async (host: string) => { - const lookup = promisify(dns.lookup); - const ip = await lookup(host); - const start = new Date(); - - // Prevent timeouts from affecting user experience. - const requestPromise = new Promise((resolve, reject) => { - const req = http.get(`http://${ip.address}`, (res) => { - const delay = new Date().getTime() - start.getTime(); - res.resume(); - resolve(delay); - }); - - req.on('error', (err) => { - reject(err); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Request timed out after 3 seconds')); - }, 1000); - }); - - return Promise.race([requestPromise, timeoutPromise]); -}; - -async function isChinaDomain(domain: string): Promise { - try { - const [ip] = await resolve(domain); - return await isChinaIP(ip, domain); - } catch (error) { - logger.debug(`${domain} can't be parse!`); - return true; - } -} - -async function isChinaIP(ip: string, domain: string): Promise { - try { - const delay = await ping(ip); - logger.debug(`${domain} latency is ${delay} ms`); - return delay > 1000; - } catch (error) { - logger.debug(`ping ${domain} failed!`); - return true; - } -} - -export { isChinaDomain, isChinaIP }; diff --git a/bin/utils/mirror.ts b/bin/utils/mirror.ts new file mode 100644 index 0000000000..aa4d589c44 --- /dev/null +++ b/bin/utils/mirror.ts @@ -0,0 +1,7 @@ +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); + +export const CN_MIRROR_ENV = 'PAKE_USE_CN_MIRROR'; + +export function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]): boolean { + return TRUE_VALUES.has((value ?? '').trim().toLowerCase()); +} diff --git a/dist/cli.js b/dist/cli.js index 9f4ddb8b59..076d2e4aef 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -10,9 +10,6 @@ import os from 'os'; import { execa, execaSync } from 'execa'; import crypto from 'crypto'; import ora from 'ora'; -import dns from 'dns'; -import http from 'http'; -import { promisify } from 'util'; import fs from 'fs/promises'; import { dir } from 'tmp-promise'; import { fileTypeFromBuffer } from 'file-type'; @@ -23,18 +20,18 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.3"; +var version = "3.11.4"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" }; var packageManager = "pnpm@10.26.2"; var bin = { - pake: "./dist/cli.js" + pake: "dist/cli.js" }; var repository = { type: "git", - url: "https://github.com/tw93/pake.git" + url: "git+https://github.com/tw93/pake.git" }; var author = { name: "Tw93", @@ -220,6 +217,12 @@ function getSpinner(text) { }).start(); } +const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']); +const CN_MIRROR_ENV = 'PAKE_USE_CN_MIRROR'; +function isCnMirrorEnabled(value = process.env[CN_MIRROR_ENV]) { + return TRUE_VALUES.has((value ?? '').trim().toLowerCase()); +} + const { platform: platform$1 } = process; const IS_MAC = platform$1 === 'darwin'; const IS_WIN = platform$1 === 'win32'; @@ -279,69 +282,6 @@ async function shellExec(command, timeout = 300000, env) { } } -const logger = { - info(...msg) { - log.info(...msg.map((m) => chalk.white(m))); - }, - debug(...msg) { - log.debug(...msg); - }, - error(...msg) { - log.error(...msg.map((m) => chalk.red(m))); - }, - warn(...msg) { - log.warn(...msg.map((m) => chalk.yellow(m))); - }, - success(...msg) { - log.info(...msg.map((m) => chalk.green(m))); - }, -}; - -const resolve = promisify(dns.resolve); -const ping = async (host) => { - const lookup = promisify(dns.lookup); - const ip = await lookup(host); - const start = new Date(); - // Prevent timeouts from affecting user experience. - const requestPromise = new Promise((resolve, reject) => { - const req = http.get(`http://${ip.address}`, (res) => { - const delay = new Date().getTime() - start.getTime(); - res.resume(); - resolve(delay); - }); - req.on('error', (err) => { - reject(err); - }); - }); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('Request timed out after 3 seconds')); - }, 1000); - }); - return Promise.race([requestPromise, timeoutPromise]); -}; -async function isChinaDomain(domain) { - try { - const [ip] = await resolve(domain); - return await isChinaIP(ip, domain); - } - catch (error) { - logger.debug(`${domain} can't be parse!`); - return true; - } -} -async function isChinaIP(ip, domain) { - try { - const delay = await ping(ip); - logger.debug(`${domain} latency is ${delay} ms`); - return delay > 1000; - } - catch (error) { - logger.debug(`ping ${domain} failed!`); - return true; - } -} - function normalizePathForComparison(targetPath) { const normalized = path.normalize(targetPath); return IS_WIN ? normalized.toLowerCase() : normalized; @@ -389,15 +329,13 @@ function ensureRustEnv() { ensureCargoBinOnPath(); } async function installRust() { - const isActions = process.env.GITHUB_ACTIONS; - const isInChina = await isChinaDomain('sh.rustup.rs'); - const rustInstallScriptForMac = isInChina && !isActions + const rustInstallScriptForUnix = isCnMirrorEnabled() ? 'export RUSTUP_DIST_SERVER="https://rsproxy.cn" && export RUSTUP_UPDATE_ROOT="https://rsproxy.cn/rustup" && curl --proto "=https" --tlsv1.2 -sSf https://rsproxy.cn/rustup-init.sh | sh' : "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y"; const rustInstallScriptForWindows = 'winget install --id Rustlang.Rustup'; const spinner = getSpinner('Downloading Rust...'); try { - await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForMac, 300000, undefined); + await shellExec(IS_WIN ? rustInstallScriptForWindows : rustInstallScriptForUnix, 300000, undefined); spinner.succeed(chalk.green('✔ Rust installed successfully!')); ensureRustEnv(); } @@ -443,6 +381,24 @@ async function combineFiles(files, output) { return files; } +const logger = { + info(...msg) { + log.info(...msg.map((m) => chalk.white(m))); + }, + debug(...msg) { + log.debug(...msg); + }, + error(...msg) { + log.error(...msg.map((m) => chalk.red(m))); + }, + warn(...msg) { + log.warn(...msg.map((m) => chalk.yellow(m))); + }, + success(...msg) { + log.info(...msg.map((m) => chalk.green(m))); + }, +}; + function generateSafeFilename(name) { return name .replace(/[<>:"/\\|?*]/g, '_') @@ -873,6 +829,40 @@ class BaseBuilder { throw error; } } + getInstallCommand(packageManager, useCnMirror) { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; + } + isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) { + return projectConfig.trim() === cnMirrorConfig.trim(); + } + async configureCargoRegistry(tauriSrcPath, useCnMirror) { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await this.copyFileWithSamePathGuard(projectCnConf, projectConf); + return; + } + if (!(await fsExtra.pathExists(projectConf))) { + return; + } + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + if (this.isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + if (projectConfig.includes('rsproxy.cn')) { + logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); + } + } async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); const tauriTargetPath = path.join(tauriSrcPath, 'target'); @@ -896,15 +886,11 @@ class BaseBuilder { process.exit(1); } } - const isChina = await isChinaDomain('www.npmjs.com'); const spinner = getSpinner('Installing package...'); - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - await fsExtra.ensureDir(rustProjectDir); + const useCnMirror = isCnMirrorEnabled(); + await this.configureCargoRegistry(tauriSrcPath, useCnMirror); // 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 @@ -913,43 +899,22 @@ class BaseBuilder { ? '✺ First-time setup may take 10-15 minutes on Windows (compiling dependencies)...' : '✺ First-time setup may take 5-10 minutes (installing dependencies)...'); } - let usedMirror = isChina; + if (useCnMirror) { + logger.info(`✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`); + } try { - if (isChina) { - logger.info(`✺ Located in China, using ${packageManager}/rsProxy CN mirror.`); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - } - else { - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - } + await shellExec(this.getInstallCommand(packageManager, useCnMirror), timeout, { + ...buildEnv, + CI: 'true', + }); spinner.succeed(chalk.green('Package installed!')); } catch (error) { - // If installation times out and we haven't tried the mirror yet, retry with mirror - if (error instanceof Error && - error.message.includes('timed out') && - !usedMirror) { - spinner.fail(chalk.yellow('Installation timed out, retrying with CN mirror...')); - logger.info('✺ Retrying installation with CN mirror for better speed...'); - const retrySpinner = getSpinner('Retrying installation...'); - usedMirror = true; - try { - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - await shellExec(`cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`, timeout, { ...buildEnv, CI: 'true' }); - retrySpinner.succeed(chalk.green('Package installed with CN mirror!')); - } - catch (retryError) { - retrySpinner.fail(chalk.red('Installation failed')); - throw retryError; - } - } - else { - spinner.fail(chalk.red('Installation failed')); - throw error; + spinner.fail(chalk.red('Installation failed')); + if (!useCnMirror) { + logger.info(`✺ If downloads are slow in China, retry with ${CN_MIRROR_ENV}=1 to use CN mirrors.`); } + throw error; } if (!tauriTargetPathExists) { logger.warn('✼ The first packaging may be slow, please be patient and wait, it will be faster afterwards.'); diff --git a/docs/faq.md b/docs/faq.md index edbeda1bc8..4c81dedf4f 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -219,13 +219,23 @@ First-time installation on Windows can be slow due to: - Windows Defender real-time scanning - Network connectivity issues -**Solution 1: Automatic Retry (Built-in)** +**Solution 1: Enable CN Mirror Explicitly** -Pake CLI now automatically retries with CN mirror if the initial installation times out. Simply wait for the retry to complete. +Pake CLI uses the official npm and Rust sources by default. If downloads are slow in China, opt in to CN mirrors: + +```bash +# macOS/Linux +PAKE_USE_CN_MIRROR=1 pake https://github.com --name GitHub +``` + +```powershell +# Windows PowerShell +$env:PAKE_USE_CN_MIRROR="1"; pake https://github.com --name GitHub +``` **Solution 2: Manual Installation** -If automatic retry fails, manually install dependencies: +If dependency installation still fails, manually install dependencies: ```bash # Navigate to pake-cli installation directory diff --git a/docs/faq_CN.md b/docs/faq_CN.md index 87085ec7b4..d1c56c6d1d 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -219,13 +219,23 @@ Windows 首次安装可能较慢,原因包括: - Windows Defender 实时扫描 - 网络连接问题 -**解决方案 1:自动重试(内置)** +**解决方案 1:显式启用国内镜像** -Pake CLI 现在会在初次安装超时后自动使用国内镜像重试。只需等待重试完成即可。 +Pake CLI 默认使用官方 npm 和 Rust 源。如果在国内下载较慢,可以显式启用国内镜像: + +```bash +# macOS/Linux +PAKE_USE_CN_MIRROR=1 pake https://github.com --name GitHub +``` + +```powershell +# Windows PowerShell +$env:PAKE_USE_CN_MIRROR="1"; pake https://github.com --name GitHub +``` **解决方案 2:手动安装依赖** -如果自动重试失败,可手动安装依赖: +如果依赖安装仍然失败,可手动安装依赖: ```bash # 进入 pake-cli 安装目录 diff --git a/package.json b/package.json index b9c66e6ee1..9e60160919 100644 --- a/package.json +++ b/package.json @@ -1,17 +1,17 @@ { "name": "pake-cli", - "version": "3.11.3", + "version": "3.11.4", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" }, "packageManager": "pnpm@10.26.2", "bin": { - "pake": "./dist/cli.js" + "pake": "dist/cli.js" }, "repository": { "type": "git", - "url": "https://github.com/tw93/pake.git" + "url": "git+https://github.com/tw93/pake.git" }, "author": { "name": "Tw93", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0c0d29bf1f..9ebec02dc0 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.3" +version = "3.11.4" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f41d38a22f..65c4b6b06e 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.3", + "version": "3.11.4", "app": { "withGlobalTauri": true, "trayIcon": { diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 61d50293b3..6f70a26bee 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -1,3 +1,4 @@ +import os from 'os'; import path from 'path'; import fsExtra from 'fs-extra'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -8,6 +9,8 @@ vi.mock('@/utils/dir', () => ({ })); import BaseBuilder from '@/builders/BaseBuilder'; +import logger from '@/options/logger'; +import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; class TestBuilder extends BaseBuilder { getFileName(): string { @@ -15,9 +18,50 @@ class TestBuilder extends BaseBuilder { } } +const originalCnMirrorEnv = process.env[CN_MIRROR_ENV]; +const tempDirs: string[] = []; + +const GENERATED_MIRROR_CONFIG = `[source.crates-io] +replace-with = 'rsproxy-sparse' +[source.rsproxy] +registry = "https://rsproxy.cn/crates.io-index" +[source.rsproxy-sparse] +registry = "sparse+https://rsproxy.cn/index/" +[registries.rsproxy] +index = "https://rsproxy.cn/crates.io-index" +[net] +git-fetch-with-cli = true +`; + +async function createCargoFixture(projectConfig?: string) { + const tempDir = await fsExtra.mkdtemp( + path.join(os.tmpdir(), 'pake-base-builder-'), + ); + tempDirs.push(tempDir); + + const tauriSrcPath = path.join(tempDir, 'src-tauri'); + const projectConf = path.join(tauriSrcPath, '.cargo', 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + + await fsExtra.outputFile(projectCnConf, GENERATED_MIRROR_CONFIG); + if (projectConfig !== undefined) { + await fsExtra.outputFile(projectConf, projectConfig); + } + + return { tauriSrcPath, projectConf, projectCnConf }; +} + describe('BaseBuilder guards', () => { - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + + if (originalCnMirrorEnv === undefined) { + delete process.env[CN_MIRROR_ENV]; + } else { + process.env[CN_MIRROR_ENV] = originalCnMirrorEnv; + } + + await Promise.all(tempDirs.splice(0).map((dir) => fsExtra.remove(dir))); }); it('prepends /usr/bin to PATH for macOS build environment', () => { @@ -70,4 +114,78 @@ describe('BaseBuilder guards', () => { (builder as any).copyFileWithSamePathGuard('/tmp/a', '/tmp/b'), ).rejects.toThrow('permission denied'); }); + + it('does not enable CN mirror by default', () => { + delete process.env[CN_MIRROR_ENV]; + + expect(isCnMirrorEnabled()).toBe(false); + expect(isCnMirrorEnabled('false')).toBe(false); + expect(isCnMirrorEnabled('0')).toBe(false); + }); + + it.each(['1', 'true', 'yes', 'on', ' TRUE '])( + 'enables CN mirror for %s', + (value) => { + process.env[CN_MIRROR_ENV] = value; + + expect(isCnMirrorEnabled()).toBe(true); + }, + ); + + it('uses official npm registry by default', () => { + const builder = new TestBuilder({} as any); + const command = (builder as any).getInstallCommand('pnpm', false); + + expect(command).toContain('pnpm install'); + expect(command).not.toContain('registry.npmmirror.com'); + }); + + it('uses npmmirror only when CN mirror is enabled', () => { + const builder = new TestBuilder({} as any); + const command = (builder as any).getInstallCommand('npm', true); + + expect(command).toContain( + 'npm install --registry=https://registry.npmmirror.com --legacy-peer-deps', + ); + }); + + it('copies Cargo mirror config only when CN mirror is enabled', async () => { + const builder = new TestBuilder({} as any); + const { tauriSrcPath, projectConf, projectCnConf } = + await createCargoFixture(); + + await (builder as any).configureCargoRegistry(tauriSrcPath, true); + + expect(await fsExtra.readFile(projectConf, 'utf8')).toBe( + await fsExtra.readFile(projectCnConf, 'utf8'), + ); + }); + + it('removes generated Cargo mirror config when CN mirror is disabled', async () => { + const builder = new TestBuilder({} as any); + const { tauriSrcPath, projectConf } = await createCargoFixture( + GENERATED_MIRROR_CONFIG, + ); + + await (builder as any).configureCargoRegistry(tauriSrcPath, false); + + expect(await fsExtra.pathExists(projectConf)).toBe(false); + }); + + it('keeps custom Cargo config when CN mirror is disabled', async () => { + const builder = new TestBuilder({} as any); + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + const customConfig = `${GENERATED_MIRROR_CONFIG} +# custom user setting +`; + const { tauriSrcPath, projectConf } = + await createCargoFixture(customConfig); + + await (builder as any).configureCargoRegistry(tauriSrcPath, false); + + expect(await fsExtra.readFile(projectConf, 'utf8')).toBe(customConfig); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('still references rsproxy.cn'), + ); + }); }); From 3247cf12511adcbf51517c587a68145bfba586a9 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 3 May 2026 23:03:42 +0800 Subject: [PATCH 005/120] chore: ignore local agent files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d02ef8b60a..65c93cacb4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ *.tmp # AI assistant docs (do not commit) # AI Assistant files +.agents/ # Editor directories and files # Logs .claude/ From ce00eab69b033307b447dd1cffedd544bb4aad4b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 15:05:13 +0000 Subject: [PATCH 006/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 9fdc2240bc..2afc2c3e75 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -530,17 +530,6 @@ - - - - - - - - houhoz - - - @@ -551,7 +540,7 @@ lakca - + @@ -562,7 +551,7 @@ liudonghua123 - + @@ -573,7 +562,7 @@ liusishan - + @@ -584,7 +573,7 @@ piaoyidage - + @@ -595,7 +584,7 @@ enihsyou - + From bd17f0e631d9914c13376b35ae1f164811673a0d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 11:20:39 +0800 Subject: [PATCH 007/120] fix: avoid macOS new-window WebKit crash --- src-tauri/Cargo.lock | 2 +- src-tauri/src/app/window.rs | 47 ++++++++++++++++++----------- tests/unit/new-window-macos.test.js | 28 +++++++++++++++++ 3 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 tests/unit/new-window-macos.test.js diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 817b3add8e..309fbd17d0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.3" +version = "3.11.4" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 4cdb48df26..d98cd65b9a 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -67,6 +67,7 @@ struct WindowBuildOptions<'a> { new_window_features: Option, } +#[cfg(not(target_os = "macos"))] fn open_requested_window( app: &AppHandle, config: &PakeConfig, @@ -253,24 +254,34 @@ fn build_window( } 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 - } - } - }); + #[cfg(target_os = "macos")] + { + window_builder = + window_builder.on_new_window(|_target_url, _features| NewWindowResponse::Allow); + } + + #[cfg(not(target_os = "macos"))] + { + 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 diff --git a/tests/unit/new-window-macos.test.js b/tests/unit/new-window-macos.test.js new file mode 100644 index 0000000000..00dea17b3b --- /dev/null +++ b/tests/unit/new-window-macos.test.js @@ -0,0 +1,28 @@ +import fs from "fs"; +import path from "path"; +import { describe, expect, it } from "vitest"; + +describe("macOS new-window handling", () => { + it("uses the default WebKit popup path instead of manually creating a Tauri window", () => { + const source = fs.readFileSync( + path.join(process.cwd(), "src-tauri/src/app/window.rs"), + "utf-8", + ); + + const blockStart = source.indexOf("if window_config.new_window"); + const blockEnd = source.indexOf("// Add initialization scripts", blockStart); + const newWindowBlock = source.slice(blockStart, blockEnd); + + expect(newWindowBlock).toMatch( + /#\[cfg\(target_os = "macos"\)\]\s*\{\s*window_builder\s*=\s*window_builder\.on_new_window\(\|_target_url, _features\| NewWindowResponse::Allow\);\s*\}/s, + ); + + const macosBranch = newWindowBlock.slice( + newWindowBlock.indexOf('#[cfg(target_os = "macos")]'), + newWindowBlock.indexOf('#[cfg(not(target_os = "macos"))]'), + ); + + expect(macosBranch).not.toContain("open_requested_window"); + expect(macosBranch).not.toContain("with_webview_configuration"); + }); +}); From 6fdce0ec09088025c6863d304960f74b0ff62c0d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 03:21:25 +0000 Subject: [PATCH 008/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 2afc2c3e75..1edc907b84 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -459,7 +459,7 @@ - + sebastianbreguel From 9ec19e0baceb2d4fdaa2d6d56a0ad4b8b9b987c3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 11:34:33 +0800 Subject: [PATCH 009/120] style: format macOS new-window test --- tests/unit/new-window-macos.test.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/unit/new-window-macos.test.js b/tests/unit/new-window-macos.test.js index 00dea17b3b..4016953e8b 100644 --- a/tests/unit/new-window-macos.test.js +++ b/tests/unit/new-window-macos.test.js @@ -10,7 +10,10 @@ describe("macOS new-window handling", () => { ); const blockStart = source.indexOf("if window_config.new_window"); - const blockEnd = source.indexOf("// Add initialization scripts", blockStart); + const blockEnd = source.indexOf( + "// Add initialization scripts", + blockStart, + ); const newWindowBlock = source.slice(blockStart, blockEnd); expect(newWindowBlock).toMatch( From aceb23f0379978b2f48044da2a8c39749ea9c36a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 4 May 2026 10:57:08 +0800 Subject: [PATCH 010/120] docs: add project agent guidance --- .agents/skills/code-review/SKILL.md | 48 ++++++++ .agents/skills/github-ops/SKILL.md | 100 ++++++++++++++++ .agents/skills/release/SKILL.md | 69 +++++++++++ .claude/skills/code-review/SKILL.md | 48 ++++++++ .claude/skills/github-ops/SKILL.md | 100 ++++++++++++++++ .claude/skills/release/SKILL.md | 69 +++++++++++ .gitignore | 11 +- AGENTS.md | 175 ++++++++++++++++++++++++++++ CLAUDE.md | 6 + 9 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 .agents/skills/code-review/SKILL.md create mode 100644 .agents/skills/github-ops/SKILL.md create mode 100644 .agents/skills/release/SKILL.md create mode 100644 .claude/skills/code-review/SKILL.md create mode 100644 .claude/skills/github-ops/SKILL.md create mode 100644 .claude/skills/release/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000000..c2f9491b35 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,48 @@ +--- +name: code-review +description: Pake project adapter for Waza check/code-review. Use for TypeScript CLI, Rust/Tauri, release artifact, and CI review. +version: 1.1.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Pake Code Review Adapter + +Use Waza `/check` for the generic review method. This adapter adds Pake-specific commands, hard stops, and artifact rules. + +## Pake-Specific Hard Stops + +- [ ] Changes under `bin/` rebuild and commit `dist/cli.js` with `pnpm run cli:build`. +- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` in sync. +- [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. +- [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. +- [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. +- [ ] New helper in `bin/utils/` or `bin/helpers/` has a matching `tests/unit/.test.ts`. +- [ ] Binary parsers have a round-trip test, not only builder assertions. + +## Quick Review Commands + +```bash +# Get PR diff +gh pr diff + +# Format check +pnpm run format:check + +# Run unit tests (fast, sub-second) +npx vitest run + +# Full suite without the slow real build +pnpm test -- --no-build + +# Build CLI and catch TypeScript errors +pnpm run cli:build +``` + +## Review Output Format + +Follow Waza `/check`: findings first, ordered by severity, with tight file/line references. Keep summaries brief. diff --git a/.agents/skills/github-ops/SKILL.md b/.agents/skills/github-ops/SKILL.md new file mode 100644 index 0000000000..0f61037158 --- /dev/null +++ b/.agents/skills/github-ops/SKILL.md @@ -0,0 +1,100 @@ +--- +name: github-ops +description: GitHub issue, PR, and release operations via gh CLI. Not for code review or release builds. +version: 1.0.0 +allowed-tools: + - Bash + - Read +--- + +# GitHub Operations Skill + +Use this skill when working with GitHub issues, PRs, and releases for Pake. + +## Golden Rule + +**ALWAYS use `gh` CLI** for GitHub operations. Never use the web UI or make assumptions about state — always query first. + +## Issue Operations + +```bash +# View a specific issue +gh issue view 123 + +# List open issues +gh issue list --state open + +# List issues with a label +gh issue list --label bug + +# Add a comment (only with explicit user request) +gh issue comment 123 --body "..." + +# Close an issue +gh issue close 123 +``` + +## PR Operations + +```bash +# List open PRs +gh pr list + +# View a PR +gh pr view 456 + +# Check PR status and CI checks +gh pr checks 456 + +# View PR diff +gh pr diff 456 + +# Read inline review comments on a PR +gh api repos/tw93/Pake/pulls/456/comments + +# Merge a PR (only with explicit user request) +gh pr merge 456 --squash + +# Create a PR +gh pr create --title "..." --body "..." +``` + +## Release Operations + +```bash +# List releases +gh release list + +# View a specific release +gh release view V3.10.0 + +# Check CI runs for a tag +gh run list --workflow=release.yml + +# Watch a running CI job +gh run watch + +# View CI run logs +gh run view --log +``` + +## CI / Workflow Operations + +```bash +# List recent workflow runs +gh run list + +# Filter by workflow +gh run list --workflow=release.yml +gh run list --workflow=quality-and-test.yml + +# Re-run failed jobs +gh run rerun --failed-only +``` + +## Safety Rules + +1. **ALWAYS** draft the reply first and show it to the user for approval before calling any write operation (`gh issue comment`, `gh pr comment`, `gh pr merge`, `gh issue close`, `gh release create`, etc.). Approval of one draft does not extend to future comments. +2. **NEVER** merge, close, or modify without explicit user request. +3. **ALWAYS** query current state before taking action — never assume. +4. Before replying to an issue or PR, read the body to confirm the author's language; match their language in the reply. This applies to the author, not to arbitrary thread commenters. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 0000000000..b184b3ad33 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,69 @@ +--- +name: release +description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. +version: 1.0.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Release Skill + +Use this skill when preparing or executing a Pake release. + +## Version Files + +Three files must be updated in sync — never update one without the others: + +- `package.json` → `"version"` +- `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/tauri.conf.json` → `"version"` + +## Release Checklist + +### Pre-Release + +1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) +2. [ ] Update all three version files above +3. [ ] Run `pnpm run format` — must pass cleanly +4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +6. [ ] No uncommitted changes: `git status` +7. [ ] Commit version bump with message: `chore: bump version to VX.X.X` + +### Tagging (triggers CI) + +```bash +git tag -a VX.X.X -m "Release VX.X.X" +git push origin VX.X.X +``` + +Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. + +### Post-Tag Verification + +1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` +2. [ ] Watch CI status: `gh run watch` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` +4. [ ] Publish to npm (manual): `npm publish` + +## Build Commands (local only) + +```bash +# Current platform +pnpm build + +# macOS universal binary +pnpm build:mac +``` + +Cross-platform builds (Windows/Linux) are handled by CI, not locally. + +## Safety Rules + +1. **NEVER** auto-commit or auto-push without explicit user request +2. **NEVER** tag before all checks pass +3. **ALWAYS** verify the three version files are in sync before tagging diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md new file mode 100644 index 0000000000..c2f9491b35 --- /dev/null +++ b/.claude/skills/code-review/SKILL.md @@ -0,0 +1,48 @@ +--- +name: code-review +description: Pake project adapter for Waza check/code-review. Use for TypeScript CLI, Rust/Tauri, release artifact, and CI review. +version: 1.1.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Pake Code Review Adapter + +Use Waza `/check` for the generic review method. This adapter adds Pake-specific commands, hard stops, and artifact rules. + +## Pake-Specific Hard Stops + +- [ ] Changes under `bin/` rebuild and commit `dist/cli.js` with `pnpm run cli:build`. +- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` in sync. +- [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. +- [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. +- [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. +- [ ] New helper in `bin/utils/` or `bin/helpers/` has a matching `tests/unit/.test.ts`. +- [ ] Binary parsers have a round-trip test, not only builder assertions. + +## Quick Review Commands + +```bash +# Get PR diff +gh pr diff + +# Format check +pnpm run format:check + +# Run unit tests (fast, sub-second) +npx vitest run + +# Full suite without the slow real build +pnpm test -- --no-build + +# Build CLI and catch TypeScript errors +pnpm run cli:build +``` + +## Review Output Format + +Follow Waza `/check`: findings first, ordered by severity, with tight file/line references. Keep summaries brief. diff --git a/.claude/skills/github-ops/SKILL.md b/.claude/skills/github-ops/SKILL.md new file mode 100644 index 0000000000..0f61037158 --- /dev/null +++ b/.claude/skills/github-ops/SKILL.md @@ -0,0 +1,100 @@ +--- +name: github-ops +description: GitHub issue, PR, and release operations via gh CLI. Not for code review or release builds. +version: 1.0.0 +allowed-tools: + - Bash + - Read +--- + +# GitHub Operations Skill + +Use this skill when working with GitHub issues, PRs, and releases for Pake. + +## Golden Rule + +**ALWAYS use `gh` CLI** for GitHub operations. Never use the web UI or make assumptions about state — always query first. + +## Issue Operations + +```bash +# View a specific issue +gh issue view 123 + +# List open issues +gh issue list --state open + +# List issues with a label +gh issue list --label bug + +# Add a comment (only with explicit user request) +gh issue comment 123 --body "..." + +# Close an issue +gh issue close 123 +``` + +## PR Operations + +```bash +# List open PRs +gh pr list + +# View a PR +gh pr view 456 + +# Check PR status and CI checks +gh pr checks 456 + +# View PR diff +gh pr diff 456 + +# Read inline review comments on a PR +gh api repos/tw93/Pake/pulls/456/comments + +# Merge a PR (only with explicit user request) +gh pr merge 456 --squash + +# Create a PR +gh pr create --title "..." --body "..." +``` + +## Release Operations + +```bash +# List releases +gh release list + +# View a specific release +gh release view V3.10.0 + +# Check CI runs for a tag +gh run list --workflow=release.yml + +# Watch a running CI job +gh run watch + +# View CI run logs +gh run view --log +``` + +## CI / Workflow Operations + +```bash +# List recent workflow runs +gh run list + +# Filter by workflow +gh run list --workflow=release.yml +gh run list --workflow=quality-and-test.yml + +# Re-run failed jobs +gh run rerun --failed-only +``` + +## Safety Rules + +1. **ALWAYS** draft the reply first and show it to the user for approval before calling any write operation (`gh issue comment`, `gh pr comment`, `gh pr merge`, `gh issue close`, `gh release create`, etc.). Approval of one draft does not extend to future comments. +2. **NEVER** merge, close, or modify without explicit user request. +3. **ALWAYS** query current state before taking action — never assume. +4. Before replying to an issue or PR, read the body to confirm the author's language; match their language in the reply. This applies to the author, not to arbitrary thread commenters. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000000..b184b3ad33 --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,69 @@ +--- +name: release +description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. +version: 1.0.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Release Skill + +Use this skill when preparing or executing a Pake release. + +## Version Files + +Three files must be updated in sync — never update one without the others: + +- `package.json` → `"version"` +- `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/tauri.conf.json` → `"version"` + +## Release Checklist + +### Pre-Release + +1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) +2. [ ] Update all three version files above +3. [ ] Run `pnpm run format` — must pass cleanly +4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +6. [ ] No uncommitted changes: `git status` +7. [ ] Commit version bump with message: `chore: bump version to VX.X.X` + +### Tagging (triggers CI) + +```bash +git tag -a VX.X.X -m "Release VX.X.X" +git push origin VX.X.X +``` + +Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. + +### Post-Tag Verification + +1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` +2. [ ] Watch CI status: `gh run watch` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` +4. [ ] Publish to npm (manual): `npm publish` + +## Build Commands (local only) + +```bash +# Current platform +pnpm build + +# macOS universal binary +pnpm build:mac +``` + +Cross-platform builds (Windows/Linux) are handled by CI, not locally. + +## Safety Rules + +1. **NEVER** auto-commit or auto-push without explicit user request +2. **NEVER** tag before all checks pass +3. **ALWAYS** verify the three version files are in sync before tagging diff --git a/.gitignore b/.gitignore index 65c93cacb4..f3ef36fd54 100644 --- a/.gitignore +++ b/.gitignore @@ -20,15 +20,14 @@ *.suo *.sw? *.tmp -# AI assistant docs (do not commit) -# AI Assistant files -.agents/ +# Local AI assistant docs +AGENTS.override.md +CLAUDE.local.md +.agents/settings.local.json # Editor directories and files # Logs -.claude/ +.claude/settings.local.json AGENT.md -AGENTS.md -CLAUDE.md dist dist-ssr journal/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..642913edf1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,175 @@ +# AGENTS.md - Pake Project Knowledge Base + +## Project Identity + +**Pake** - Turn any webpage into a lightweight desktop app with one command. + +- **Purpose**: Package any website into a ~5MB desktop app (20x smaller than Electron) +- **Stack**: Tauri v2 (Rust) + TypeScript CLI +- **Platforms**: macOS, Windows, Linux +- **Mechanism**: Uses system webview (WebKit on macOS/Linux, WebView2 on Windows) + +## Repository Structure + +``` +Pake/ +├── bin/ # CLI source code (TypeScript) +│ └── cli.ts # Main CLI entry (Commander.js) +├── src-tauri/ # Tauri Rust application +│ ├── src/ # Rust source code +│ ├── src/app/ # window creation, setup, menu, config, and invokes +│ ├── src/inject/ # injected JS/CSS behavior +│ ├── Cargo.toml # Rust dependencies and version +│ ├── tauri.conf.json # Tauri configuration and version +│ └── .cargo/ # Cargo configuration (gitignored) +├── dist/ # Compiled CLI output +├── docs/ # Documentation +│ ├── cli-usage.md # CLI parameters +│ ├── advanced-usage.md # Customization guide +│ └── faq.md # Troubleshooting +├── scripts/ # Utility scripts +├── tests/ # Unit, integration, and release-flow tests +├── .github/workflows/ # quality/test and release automation +├── default_app_list.json # Popular apps config for release builds +├── package.json # Node.js dependencies and version +└── rollup.config.js # CLI build configuration +``` + +## Development Commands + +| Command | Purpose | +|---------|---------| +| `pnpm install` | Install dependencies | +| `pnpm run dev` | Tauri development mode | +| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | +| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | +| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | +| `pnpm run build` | Build for current platform | +| `pnpm run build:mac` | macOS universal binary | +| `pnpm run format` | Format code (prettier + cargo fmt) | +| `npx vitest run` | Unit and integration tests only (sub-second) | +| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | +| `pnpm test` | Full suite including release workflow | + +Keep shared project facts in this file so Codex, Claude Code, and other agents use the same public source of truth. Tool-specific local skills or overrides must remain optional and ignored. + +## Task Intake And Investigation + +Prefer requests with: + +- `Goal`: exact bug, feature, refactor, or review target +- `Scope`: files, directories, or subsystem boundaries to inspect first +- `Repro`: command, input, fixture, or failing test +- `Expected`: expected behavior +- `Actual`: current behavior, error text, or regression note +- `Constraints`: what must not change +- `Verify`: minimum command or test that proves the result + +When task scope is incomplete, inspect in this order: + +1. CLI entry and option parsing under `bin/cli.ts`, `bin/options/`, and `bin/helpers/` +2. Target TypeScript module under `bin/` +3. Tauri runtime or packaging files under `src-tauri/src/` and `src-tauri/tauri*.conf.json` +4. Narrow tests under `tests/unit/` or `tests/integration/` +5. Release workflow files under `.github/workflows/` only for CI or release issues +6. Docs only if behavior, ownership, or expected usage is still unclear + +Execution rules: + +- Start with the smallest plausible file set +- Prefer targeted search (`rg `) over repository-wide scans +- Ignore generated or output-heavy areas unless the task directly targets them, especially `dist/`, `node_modules/`, `src-tauri/target/`, `.app/`, `src-tauri/icons/`, and `src-tauri/png/`. Exception: `dist/cli.js` is the shipped CLI build artifact (see `package.json` `files`); when you change anything under `bin/`, rebuild it via `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change +- Keep changes local to one subsystem when possible +- Run the narrowest relevant verification first, expand only if needed +- If key context is missing, make one reasonable assumption and proceed + +## Current Risk Areas + +- CLI options are user-facing and must stay synchronized across `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`, generated `dist/cli.js`, and `docs/cli-usage*.md`. +- Recent window/runtime options include `--incognito`, `--new-window`, `--min-width`, `--min-height`, `--maximize`, multi-window behavior, notification click handling, and Linux/Wayland WebKit compositing defaults. +- `--incognito` intentionally trades persistence for clean private sessions; be careful around login, cookies, local storage, and WeChat-style WebView detection. +- `--new-window` and `--multi-window` do not bypass every provider policy. Google OAuth and similar embedded-WebView restrictions may still require a normal browser or native client. +- Notification flows cross injected JS, Tauri invokes, capabilities, and native notification plugins. Verify the Rust capability and JS caller together. +- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Do not change defaults without testing the affected platform path or documenting the risk. + +## Code Quality Standards + +- Chinese comments are forbidden. + +## Branch Strategy + +- `main` - Only branch. All development and releases happen here directly. + +## Version Management + +Three files must be updated in sync for every release: + +| File | Field | +|------|-------| +| `package.json` | `"version"` | +| `src-tauri/Cargo.toml` | `version` under `[package]` | +| `src-tauri/tauri.conf.json` | `"version"` | + +Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. + +## Release Workflow (CI) + +Pushing a `V*` tag triggers `.github/workflows/release.yml`: + +1. **release-apps** - reads `default_app_list.json` for app list +2. **create-release** - creates the GitHub Release placeholder +3. **build-cli** - builds and uploads the `dist/` CLI artifact +4. **build-popular-apps** - builds all apps in parallel across macOS/Windows/Linux +5. **publish-docker** - builds and pushes Docker image to GHCR + +The workflow can also be triggered manually via `workflow_dispatch` with options to build popular apps or publish Docker independently. + +After tagging, npm publish is done manually: `npm publish`. + +`.github/workflows/quality-and-test.yml` runs auto-format on push, Rust quality checks, and CLI/build validation across Linux, Windows, and macOS. + +### Network Mirror Behavior + +Pake uses official npm and Rust sources by default. CN mirrors are explicit opt-in only: + +- Set `PAKE_USE_CN_MIRROR=1` only when the user or CI environment intentionally wants npmmirror/rsProxy. +- Do not reintroduce automatic China-domain mirror switching. +- If an install fails against a CN mirror, retry the same install command to separate network availability from a product regression. +- `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts` own this behavior; keep docs and tests aligned when changing it. + +## CLI Usage Example + +```bash +# Install CLI +pnpm install -g pake-cli + +# Basic usage +pake https://github.com --name GitHub + +# Advanced usage +pake https://weekly.tw93.fun --name Weekly --width 1200 --height 800 +``` + +## Troubleshooting + +See `docs/faq.md` for common issues and solutions. + +### macOS SDK / Compile Errors + +If compilation errors occur (e.g. on macOS beta), create `src-tauri/.cargo/config.toml`: + +```toml +[env] +MACOSX_DEPLOYMENT_TARGET = "15.0" +SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" +``` + +This file is already in `.gitignore`. + +### `dist/cli.js` out of sync with `bin/` + +Symptom: tests or release builds use stale CLI behavior after a `bin/` edit. Fix with `pnpm run cli:build` and commit the regenerated `dist/cli.js`. + +### First Tauri build is slow + +The first `cargo build` on a fresh clone takes 10+ minutes as Cargo compiles every Tauri dependency from source. Subsequent builds reuse the `src-tauri/target/` cache. This is expected, not a bug. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..f326ea10c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,6 @@ +@AGENTS.md + +## Claude Code + +- Treat `AGENTS.md` as the shared source of truth for this repository. +- Keep personal notes and machine-specific workflow in `CLAUDE.local.md`. From d3d1aa279f70fdfa72a0f07ca35c5f3714719527 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 12:11:12 +0800 Subject: [PATCH 011/120] chore: update sponsor wall URL to cats.tw93.fun --- .github/FUNDING.yml | 2 +- README.md | 4 ++-- README_CN.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e7dad43930..b626d367e8 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ["tw93"] -custom: ["https://miaoyan.app/cats.html?name=Pake"] +custom: ["https://cats.tw93.fun?name=Pake"] diff --git a/README.md b/README.md index af2500ff88..7d1889e798 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,6 @@ Pake's development can not be without these Hackers. They contributed a lot of c - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. -- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. +- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. - + diff --git a/README_CN.md b/README_CN.md index 921d11a525..5562c13948 100644 --- a/README_CN.md +++ b/README_CN.md @@ -203,9 +203,9 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 支持 - + -1. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 +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 的网页也欢迎告诉我。 From 8a81f71b10e7df96cc16da6f526e23b43205e520 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 5 May 2026 05:17:20 +0000 Subject: [PATCH 012/120] Auto-fix formatting issues --- AGENTS.md | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 642913edf1..d84458f1bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,19 +37,19 @@ Pake/ ## Development Commands -| Command | Purpose | -|---------|---------| -| `pnpm install` | Install dependencies | -| `pnpm run dev` | Tauri development mode | -| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | -| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | -| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | -| `pnpm run build` | Build for current platform | -| `pnpm run build:mac` | macOS universal binary | -| `pnpm run format` | Format code (prettier + cargo fmt) | -| `npx vitest run` | Unit and integration tests only (sub-second) | -| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | -| `pnpm test` | Full suite including release workflow | +| Command | Purpose | +| ------------------------------------ | --------------------------------------------------------------- | +| `pnpm install` | Install dependencies | +| `pnpm run dev` | Tauri development mode | +| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | +| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | +| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | +| `pnpm run build` | Build for current platform | +| `pnpm run build:mac` | macOS universal binary | +| `pnpm run format` | Format code (prettier + cargo fmt) | +| `npx vitest run` | Unit and integration tests only (sub-second) | +| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | +| `pnpm test` | Full suite including release workflow | Keep shared project facts in this file so Codex, Claude Code, and other agents use the same public source of truth. Tool-specific local skills or overrides must remain optional and ignored. @@ -104,11 +104,11 @@ Execution rules: Three files must be updated in sync for every release: -| File | Field | -|------|-------| -| `package.json` | `"version"` | -| `src-tauri/Cargo.toml` | `version` under `[package]` | -| `src-tauri/tauri.conf.json` | `"version"` | +| File | Field | +| --------------------------- | --------------------------- | +| `package.json` | `"version"` | +| `src-tauri/Cargo.toml` | `version` under `[package]` | +| `src-tauri/tauri.conf.json` | `"version"` | Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. From 847154ee0b0d04925902becda1bd172ef9d61f84 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 16:25:09 +0800 Subject: [PATCH 013/120] fix: macOS --new-window crash and Rust panic safety (#1194) LinkedIn (and any site that opens a popup via window.open or SOAuthorization) was abort()-ing on macOS 26 with a WKUserContentController _addScriptMessageHandler: NSException because NewWindowResponse::Allow let WebKit clone the parent's WKWebViewConfiguration, which already had Tauri's IPC handlers registered. Unify all platforms behind open_requested_window so popups get a fresh Pake WebviewWindow with a new label, and stop reusing features.opener().target_configuration on macOS. Reposition/resize hints from the opener are still honored. Also tighten panic surfaces along the way: - show_toast logs eval errors instead of unwrap() - get_data_dir returns Result and propagates IO errors - check_file_or_append guards against u32 overflow in the suffix loop - set_global_shortcut logs plugin / register failures and degrades - set_window/build_window return Result instead of expect() - pake.json windows[].first() yields a readable error - top-level Tauri build failure exits cleanly instead of panicking Split inject/component.js into inject/toast.js and inject/fullscreen.js so the polyfill can be opted out per-app later, and update new-window-macos.test.js to lock in the fix and prevent regressions. Co-authored-by: Cursor --- src-tauri/src/app/setup.rs | 73 +++++---- src-tauri/src/app/window.rs | 127 ++++++++------ .../inject/{component.js => fullscreen.js} | 48 +----- src-tauri/src/inject/toast.js | 22 +++ src-tauri/src/lib.rs | 7 +- src-tauri/src/util.rs | 155 +++++++++++++++--- tests/unit/new-window-macos.test.js | 42 +++-- 7 files changed, 311 insertions(+), 163 deletions(-) rename src-tauri/src/inject/{component.js => fullscreen.js} (83%) create mode 100644 src-tauri/src/inject/toast.js diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index ff6e5e6ae0..87f27bb8e5 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -119,49 +119,54 @@ pub fn set_global_shortcut( let app_handle = app.clone(); let shortcut_hotkey = match Shortcut::from_str(&shortcut) { Ok(s) => s, - Err(_) => return Ok(()), + Err(error) => { + eprintln!("[Pake] Invalid activation shortcut '{shortcut}': {error}"); + return Ok(()); + } }; let last_triggered = Arc::new(Mutex::new(Instant::now())); - app_handle - .plugin( - tauri_plugin_global_shortcut::Builder::new() - .with_handler({ - let last_triggered = Arc::clone(&last_triggered); - move |app, event, _shortcut| { - let Ok(mut last_triggered) = last_triggered.lock() else { - return; - }; - if Instant::now().duration_since(*last_triggered) - < Duration::from_millis(300) - { - return; - } - *last_triggered = Instant::now(); + if let Err(error) = app_handle.plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler({ + let last_triggered = Arc::clone(&last_triggered); + move |app, event, _shortcut| { + let Ok(mut last_triggered) = last_triggered.lock() else { + return; + }; + if Instant::now().duration_since(*last_triggered) < Duration::from_millis(300) { + return; + } + *last_triggered = Instant::now(); - if shortcut_hotkey.eq(event) { - if let Some(window) = app.get_webview_window("pake") { - let is_visible = window.is_visible().unwrap_or(false); - if is_visible { - let _ = window.hide(); - } else { - let _ = window.show(); - let _ = window.set_focus(); - #[cfg(target_os = "linux")] - if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) - { - let _ = window.set_fullscreen(true); - } + if shortcut_hotkey.eq(event) { + if let Some(window) = app.get_webview_window("pake") { + let is_visible = window.is_visible().unwrap_or(false); + if is_visible { + let _ = window.hide(); + } else { + let _ = window.show(); + let _ = window.set_focus(); + #[cfg(target_os = "linux")] + if _init_fullscreen && !window.is_fullscreen().unwrap_or(false) { + let _ = window.set_fullscreen(true); } } } } - }) - .build(), - ) - .expect("Failed to set global shortcut"); + } + }) + .build(), + ) { + eprintln!( + "[Pake] Failed to register global shortcut plugin '{shortcut}': {error}; continuing without it." + ); + return Ok(()); + } - let _ = app.global_shortcut().register(shortcut_hotkey); + if let Err(error) = app.global_shortcut().register(shortcut_hotkey) { + eprintln!("[Pake] Failed to bind global shortcut '{shortcut}': {error}"); + } Ok(()) } diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index d98cd65b9a..2535d981ca 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -50,8 +50,12 @@ impl MultiWindowState { } } -pub fn set_window(app: &AppHandle, config: &PakeConfig, tauri_config: &Config) -> WebviewWindow { - build_window_with_label(app, config, tauri_config, "pake").expect("Failed to build window") +pub fn set_window( + app: &AppHandle, + config: &PakeConfig, + tauri_config: &Config, +) -> tauri::Result { + build_window_with_label(app, config, tauri_config, "pake") } pub fn open_additional_window(app: &AppHandle) -> tauri::Result { @@ -67,7 +71,6 @@ struct WindowBuildOptions<'a> { new_window_features: Option, } -#[cfg(not(target_os = "macos"))] fn open_requested_window( app: &AppHandle, config: &PakeConfig, @@ -123,10 +126,12 @@ fn build_window_with_label( tauri_config: &Config, label: &str, ) -> tauri::Result { - let window_config = config - .windows - .first() - .expect("At least one window configuration is required"); + let window_config = config.windows.first().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "pake.json must define at least one window configuration", + )) + })?; let url = match window_config.url_type.as_str() { "web" => { let parsed = window_config.url.parse().map_err(|err| { @@ -178,12 +183,14 @@ fn build_window( .product_name .clone() .unwrap_or_else(|| "pake".to_string()); - let _data_dir = get_data_dir(app, package_name); + let _data_dir = get_data_dir(app, package_name).map_err(tauri::Error::Io)?; - let window_config = config - .windows - .first() - .expect("At least one window configuration is required"); + let window_config = config.windows.first().ok_or_else(|| { + tauri::Error::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "pake.json must define at least one window configuration", + )) + })?; let user_agent = config.user_agent.get(); @@ -254,40 +261,34 @@ fn build_window( } if window_config.new_window { - #[cfg(target_os = "macos")] - { - window_builder = - window_builder.on_new_window(|_target_url, _features| NewWindowResponse::Allow); - } - - #[cfg(not(target_os = "macos"))] - { - 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 - } - }, - ); - } + 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 + // Add initialization scripts. Order matters: pakeConfig must land before + // any script that reads it (e.g. fullscreen polyfill checks for an opt-out + // flag), and toast must register `window.pakeToast` before Rust code + // calls show_toast(). window_builder = window_builder .initialization_script(&config_script) - .initialization_script(include_str!("../inject/component.js")) + .initialization_script(include_str!("../inject/toast.js")) + .initialization_script(include_str!("../inject/fullscreen.js")) .initialization_script(include_str!("../inject/event.js")) .initialization_script(include_str!("../inject/style.js")) .initialization_script(include_str!("../inject/theme_refresh.js")) @@ -402,7 +403,9 @@ fn build_window( } if let Some(features) = new_window_features { - // macOS popup webviews must reuse the opener webview configuration. + // Reuse only opener-provided position/size on macOS; sharing the opener + // WKWebViewConfiguration triggers duplicate WKScriptMessageHandler + // registrations on macOS 26+ and crashes the app (issue #1194). #[cfg(target_os = "macos")] { if let Some(position) = features.position() { @@ -413,9 +416,7 @@ fn build_window( window_builder = window_builder.inner_size(size.width, size.height); } - window_builder = window_builder - .with_webview_configuration(features.opener().target_configuration.clone()) - .focused(true); + window_builder = window_builder.focused(true); } #[cfg(not(target_os = "macos"))] @@ -428,3 +429,37 @@ fn build_window( window_builder.build() } + +#[cfg(all(test, target_os = "windows"))] +mod proxy_arg_tests { + use super::*; + + fn parse(url: &str) -> Url { + Url::from_str(url).unwrap() + } + + #[test] + fn http_url_with_explicit_port() { + let arg = build_proxy_browser_arg(&parse("http://127.0.0.1:7890")).unwrap(); + assert_eq!(arg, "--proxy-server=http://127.0.0.1:7890"); + } + + #[test] + fn http_url_uses_default_port_when_missing() { + let arg = build_proxy_browser_arg(&parse("http://proxy.local")).unwrap(); + assert_eq!(arg, "--proxy-server=http://proxy.local:80"); + } + + #[test] + fn socks5_url_uses_default_port_when_missing() { + let arg = build_proxy_browser_arg(&parse("socks5://proxy.local")).unwrap(); + assert_eq!(arg, "--proxy-server=socks5://proxy.local:1080"); + } + + #[test] + fn https_scheme_is_not_supported_yet() { + // https proxies fall back to platform proxy_url; we only emit a CLI arg + // for http/socks5 today. + assert!(build_proxy_browser_arg(&parse("https://proxy.local:8443")).is_none()); + } +} diff --git a/src-tauri/src/inject/component.js b/src-tauri/src/inject/fullscreen.js similarity index 83% rename from src-tauri/src/inject/component.js rename to src-tauri/src/inject/fullscreen.js index 8a068fcd2d..c9f2485a63 100644 --- a/src-tauri/src/inject/component.js +++ b/src-tauri/src/inject/fullscreen.js @@ -1,28 +1,10 @@ -document.addEventListener("DOMContentLoaded", () => { - // Toast - function pakeToast(msg) { - const m = document.createElement("div"); - m.innerHTML = msg; - m.style.cssText = - "max-width:60%;min-width: 80px;padding:0 12px;height: 32px;color: rgb(255, 255, 255);line-height: 32px;text-align: center;border-radius: 8px;position: fixed; bottom:24px;right: 28px;z-index: 999999;background: rgba(0, 0, 0,.8);font-size: 13px;"; - document.body.appendChild(m); - setTimeout(function () { - const d = 0.5; - m.style.transition = - "transform " + d + "s ease-in, opacity " + d + "s ease-in"; - m.style.opacity = "0"; - setTimeout(function () { - document.body.removeChild(m); - }, d * 1000); - }, 3000); - } - - window.pakeToast = pakeToast; -}); - -// Polyfill for HTML5 Fullscreen API in Tauri webview -// This bridges the HTML5 Fullscreen API to Tauri's native window fullscreen -// Works for all video sites (YouTube, Vimeo, Bilibili, etc.) +// Polyfill for HTML5 Fullscreen API in Tauri webview. +// Bridges the standard requestFullscreen / exitFullscreen DOM API to Tauri's +// native window fullscreen so video sites (YouTube, Vimeo, Bilibili, etc.) can +// go true fullscreen on their player buttons. +// +// Split out from component.js so a future CLI flag (or custom.js override) +// can short-circuit the polyfill for apps that don't need video fullscreen. (function () { if (window.__PAKE_FULLSCREEN_POLYFILL__) return; window.__PAKE_FULLSCREEN_POLYFILL__ = true; @@ -42,7 +24,6 @@ document.addEventListener("DOMContentLoaded", () => { let wasInBody = false; let monitorId = null; - // Inject fullscreen styles if (!document.getElementById("pake-fullscreen-style")) { const styleEl = document.createElement("style"); styleEl.id = "pake-fullscreen-style"; @@ -93,7 +74,6 @@ document.addEventListener("DOMContentLoaded", () => { monitorId = null; } - // Find the actual video element function findMediaElement() { const videos = document.querySelectorAll("video"); if (videos.length > 0) { @@ -112,11 +92,9 @@ document.addEventListener("DOMContentLoaded", () => { return null; } - // Enter fullscreen function enterFullscreen(element) { fullscreenElement = element; - // If html/body element, find the video instead let targetElement = element; if (element === document.documentElement || element === document.body) { const mediaElement = findMediaElement(); @@ -130,7 +108,6 @@ document.addEventListener("DOMContentLoaded", () => { actualFullscreenElement = element; } - // Save original state originalStyles = { position: targetElement.style.position, top: targetElement.style.top, @@ -152,7 +129,6 @@ document.addEventListener("DOMContentLoaded", () => { originalNextSibling = targetElement.nextSibling; } - // Apply fullscreen targetElement.classList.add("pake-fullscreen-element"); document.body.classList.add("pake-fullscreen-active"); @@ -160,7 +136,6 @@ document.addEventListener("DOMContentLoaded", () => { document.body.appendChild(targetElement); } - // Fullscreen window appWindow.setFullscreen(true).then(() => { startFullscreenMonitor(); const event = new Event("fullscreenchange", { bubbles: true }); @@ -177,7 +152,6 @@ document.addEventListener("DOMContentLoaded", () => { return Promise.resolve(); } - // Exit fullscreen function exitFullscreen() { if (!fullscreenElement) { return Promise.resolve(); @@ -188,7 +162,6 @@ document.addEventListener("DOMContentLoaded", () => { const exitingElement = fullscreenElement; const targetElement = actualFullscreenElement; - // Restore styles and position targetElement.classList.remove("pake-fullscreen-element"); document.body.classList.remove("pake-fullscreen-active"); @@ -209,7 +182,6 @@ document.addEventListener("DOMContentLoaded", () => { } } - // Reset state fullscreenElement = null; actualFullscreenElement = null; originalStyles = null; @@ -217,7 +189,6 @@ document.addEventListener("DOMContentLoaded", () => { originalNextSibling = null; wasInBody = false; - // Exit window fullscreen return appWindow.setFullscreen(false).then(() => { const event = new Event("fullscreenchange", { bubbles: true }); document.dispatchEvent(event); @@ -231,7 +202,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // Override fullscreenEnabled Object.defineProperty(document, "fullscreenEnabled", { get: () => true, configurable: true, @@ -241,7 +211,6 @@ document.addEventListener("DOMContentLoaded", () => { configurable: true, }); - // Override fullscreenElement Object.defineProperty(document, "fullscreenElement", { get: () => fullscreenElement, configurable: true, @@ -255,7 +224,6 @@ document.addEventListener("DOMContentLoaded", () => { configurable: true, }); - // Override requestFullscreen Element.prototype.requestFullscreen = function () { return enterFullscreen(this); }; @@ -266,12 +234,10 @@ document.addEventListener("DOMContentLoaded", () => { return enterFullscreen(this); }; - // Override exitFullscreen document.exitFullscreen = exitFullscreen; document.webkitExitFullscreen = exitFullscreen; document.webkitCancelFullScreen = exitFullscreen; - // Handle Escape key document.addEventListener( "keydown", (e) => { diff --git a/src-tauri/src/inject/toast.js b/src-tauri/src/inject/toast.js new file mode 100644 index 0000000000..b0758e44a6 --- /dev/null +++ b/src-tauri/src/inject/toast.js @@ -0,0 +1,22 @@ +// Lightweight in-page toast used by Rust `show_toast` (download status, etc). +// Kept tiny and always loaded so the Rust side can rely on `window.pakeToast`. +document.addEventListener("DOMContentLoaded", () => { + function pakeToast(msg) { + const m = document.createElement("div"); + m.innerHTML = msg; + m.style.cssText = + "max-width:60%;min-width: 80px;padding:0 12px;height: 32px;color: rgb(255, 255, 255);line-height: 32px;text-align: center;border-radius: 8px;position: fixed; bottom:24px;right: 28px;z-index: 999999;background: rgba(0, 0, 0,.8);font-size: 13px;"; + document.body.appendChild(m); + setTimeout(function () { + const d = 0.5; + m.style.transition = + "transform " + d + "s ease-in, opacity " + d + "s ease-in"; + m.style.opacity = "0"; + setTimeout(function () { + document.body.removeChild(m); + }, d * 1000); + }, 3000); + } + + window.pakeToast = pakeToast; +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1fc4cf75b9..d4272cb3d2 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,7 +103,7 @@ pub fn run_app() { } // --- Menu Construction End --- - let window = set_window(app.app_handle(), &pake_config, &tauri_config); + let window = set_window(app.app_handle(), &pake_config, &tauri_config)?; set_system_tray( app.app_handle(), show_system_tray, @@ -174,7 +174,10 @@ pub fn run_app() { } }) .build(tauri::generate_context!()) - .expect("error while building tauri application") + .unwrap_or_else(|error| { + eprintln!("[Pake] Fatal error while building Tauri application: {error}"); + std::process::exit(1); + }) .run(|_app, _event| { // Handle macOS dock icon click to reopen hidden window #[cfg(target_os = "macos")] diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs index 22b48d2367..3e178572ed 100644 --- a/src-tauri/src/util.rs +++ b/src-tauri/src/util.rs @@ -23,25 +23,35 @@ pub fn get_pake_config() -> (PakeConfig, Config) { (pake_config, tauri_config) } -pub fn get_data_dir(app: &AppHandle, package_name: String) -> PathBuf { - { - let data_dir = app - .path() - .config_dir() - .expect("Failed to get data dirname") - .join(package_name); - - if !data_dir.exists() { - std::fs::create_dir(&data_dir) - .unwrap_or_else(|_| panic!("Can't create dir {}", data_dir.display())); - } - data_dir +pub fn get_data_dir(app: &AppHandle, package_name: String) -> std::io::Result { + let data_dir = app + .path() + .config_dir() + .map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Failed to resolve config dir: {err}"), + ) + })? + .join(package_name); + + if !data_dir.exists() { + std::fs::create_dir_all(&data_dir).map_err(|err| { + std::io::Error::new( + err.kind(), + format!("Can't create dir {}: {err}", data_dir.display()), + ) + })?; } + + Ok(data_dir) } pub fn show_toast(window: &WebviewWindow, message: &str) { let script = format!(r#"pakeToast("{message}");"#); - window.eval(&script).unwrap(); + if let Err(error) = window.eval(&script) { + eprintln!("[Pake] Failed to show toast: {error}"); + } } pub enum MessageType { @@ -101,10 +111,16 @@ pub fn get_download_message_with_lang( .to_string() } -// Check if the file exists, if it exists, add a number to file name +/// Check if the file exists. If it does, append `-N` to the stem until a free +/// path is found. +/// +/// Robustness notes: +/// - Files without an extension are handled (we keep them extensionless). +/// - If the numeric suffix would overflow `u32::MAX` we fall back to the +/// original file_path so the caller never enters an infinite loop on +/// pathologically large filenames (regression guard for #1183). pub fn check_file_or_append(file_path: &str) -> String { let mut new_path = PathBuf::from(file_path); - let mut counter = 0; while new_path.exists() { let file_stem = new_path @@ -116,16 +132,24 @@ pub fn check_file_or_append(file_path: &str) -> String { .map(|e| e.to_string_lossy().to_string()); let parent_dir = new_path.parent().unwrap_or(Path::new("")); - let new_file_stem = match file_stem.rfind('-') { - Some(index) if file_stem[index + 1..].parse::().is_ok() => { + let parsed_suffix = file_stem.rfind('-').and_then(|index| { + file_stem[index + 1..] + .parse::() + .ok() + .map(|n| (index, n)) + }); + + let new_file_stem = match parsed_suffix { + Some((index, current)) => { + let Some(next) = current.checked_add(1) else { + // u32::MAX collisions are a sign of something pathological; + // bail with the original path instead of looping forever. + return file_path.to_string(); + }; let base_name = &file_stem[..index]; - counter = file_stem[index + 1..].parse::().unwrap() + 1; - format!("{base_name}-{counter}") - } - _ => { - counter += 1; - format!("{file_stem}-{counter}") + format!("{base_name}-{next}") } + None => format!("{file_stem}-1"), }; new_path = match &extension { @@ -136,3 +160,86 @@ pub fn check_file_or_append(file_path: &str) -> String { new_path.to_string_lossy().into_owned() } + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + use std::path::PathBuf; + + fn temp_path(name: &str) -> PathBuf { + let mut dir = env::temp_dir(); + dir.push(format!( + "pake-util-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + fs::create_dir_all(&dir).unwrap(); + dir.push(name); + dir + } + + #[test] + fn check_file_or_append_returns_input_when_missing() { + let path = temp_path("ghost.txt"); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert_eq!(resolved, path.to_string_lossy()); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_increments_suffix() { + let path = temp_path("dup.txt"); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.ends_with("dup-1.txt"), "got {resolved}"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_handles_files_without_extension() { + let path = temp_path("README"); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.ends_with("README-1"), "got {resolved}"); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn check_file_or_append_does_not_panic_on_huge_suffix() { + let path = temp_path(&format!("huge-{}.txt", u32::MAX)); + fs::write(&path, b"existing").unwrap(); + let resolved = check_file_or_append(path.to_str().unwrap()); + assert!(resolved.contains("huge-")); + let _ = fs::remove_dir_all(path.parent().unwrap()); + } + + #[test] + fn download_message_falls_back_to_english_for_unknown_locale() { + let msg = get_download_message_with_lang(MessageType::Start, Some("fr-FR".to_string())); + assert_eq!(msg, "Start downloading~"); + } + + #[test] + fn download_message_picks_chinese_for_zh_locales() { + for tag in ["zh", "zh-CN", "zh-TW", "en-CN", "en-HK"] { + let msg = get_download_message_with_lang(MessageType::Success, Some(tag.to_string())); + assert_eq!( + msg, "下载成功,已保存到下载目录~", + "expected Chinese for {tag}" + ); + } + } + + #[test] + fn download_message_failure_localized() { + let en = get_download_message_with_lang(MessageType::Failure, Some("en".into())); + let zh = get_download_message_with_lang(MessageType::Failure, Some("zh".into())); + assert!(en.contains("Download failed")); + assert!(zh.contains("下载失败")); + } +} diff --git a/tests/unit/new-window-macos.test.js b/tests/unit/new-window-macos.test.js index 4016953e8b..eefc6e7483 100644 --- a/tests/unit/new-window-macos.test.js +++ b/tests/unit/new-window-macos.test.js @@ -2,30 +2,40 @@ import fs from "fs"; import path from "path"; import { describe, expect, it } from "vitest"; -describe("macOS new-window handling", () => { - it("uses the default WebKit popup path instead of manually creating a Tauri window", () => { - const source = fs.readFileSync( - path.join(process.cwd(), "src-tauri/src/app/window.rs"), - "utf-8", - ); +const sourcePath = path.join(process.cwd(), "src-tauri/src/app/window.rs"); + +describe("macOS new-window handling (regression: #1194)", () => { + it("creates popups via open_requested_window on every platform", () => { + const source = fs.readFileSync(sourcePath, "utf-8"); const blockStart = source.indexOf("if window_config.new_window"); const blockEnd = source.indexOf( "// Add initialization scripts", blockStart, ); - const newWindowBlock = source.slice(blockStart, blockEnd); + expect(blockStart).toBeGreaterThan(-1); + expect(blockEnd).toBeGreaterThan(blockStart); - expect(newWindowBlock).toMatch( - /#\[cfg\(target_os = "macos"\)\]\s*\{\s*window_builder\s*=\s*window_builder\.on_new_window\(\|_target_url, _features\| NewWindowResponse::Allow\);\s*\}/s, - ); + const newWindowBlock = source.slice(blockStart, blockEnd); - const macosBranch = newWindowBlock.slice( - newWindowBlock.indexOf('#[cfg(target_os = "macos")]'), - newWindowBlock.indexOf('#[cfg(not(target_os = "macos"))]'), - ); + // The fix for #1194 unifies all platforms behind open_requested_window so + // popups never reuse the opener WKWebViewConfiguration. Guard against + // accidental reintroduction of NewWindowResponse::Allow which crashes + // macOS 26 with WKUserContentController duplicate handler errors. + expect(newWindowBlock).toContain("open_requested_window"); + expect(newWindowBlock).toContain("NewWindowResponse::Create"); + expect(newWindowBlock).not.toMatch(/NewWindowResponse::Allow\b/); + expect(newWindowBlock).not.toMatch(/#\[cfg\(target_os = "macos"\)\]/); + }); - expect(macosBranch).not.toContain("open_requested_window"); - expect(macosBranch).not.toContain("with_webview_configuration"); + it("does not clone the opener WKWebViewConfiguration on macOS popup features", () => { + // The popup-features handler in build_window must never call + // .with_webview_configuration(features.opener().target_configuration) + // because the cloned configuration carries the parent's + // WKScriptMessageHandler set, which WebKit refuses to register twice and + // aborts the process on macOS 26. + const source = fs.readFileSync(sourcePath, "utf-8"); + expect(source).not.toContain("with_webview_configuration"); + expect(source).not.toContain("target_configuration.clone()"); }); }); From e87bea44f4435385b2d1e6abe701f9fdfe827478 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 16:25:45 +0800 Subject: [PATCH 014/120] chore: optimization audit batch (ICO, BaseBuilder, CLI errors, CI lanes) A grab-bag of follow-ups from the project audit: feat(icon): generate full multi-resolution Windows ICO (#1190) ensureMultiResolutionIco re-renders user-supplied ICOs through sharp so every Windows standard size (16/24/32/48/64/128/256) is present in the output, and exact-size PNG frames already in the source are preserved. Avoids the Windows shell having to downsample 256x256 to 16x16 for the tray, which produced the blurry tray icons reported in #1190. refactor(builders): extract env helpers from BaseBuilder Move package-manager detection, build env, install command, Cargo mirror toggling, and the linuxdeploy strip-error matcher into a new bin/builders/env.ts module. BaseBuilder drops from 626 to 481 lines and becomes pure orchestration. Tests migrate from accessing private methods on BaseBuilder to calling the module-level functions directly. refactor(cli): top-level error boundary with PakeError Wrap program.action in try/catch, route user-facing failures through a PakeError class for short messages, and only print stack traces in debug mode. Replace the lingering process.exit(1) inside options/index.ts with a thrown PakeError so behavior stays consistent with the rest of the pipeline. test: snapshot CLI options to window config contract Extract buildWindowConfigOverrides from mergeConfig as a pure function and snapshot it across darwin/linux/win32. Catches CLI option drift early instead of waiting for a real build. ci: split PR fast lane from full Tauri build quality-and-test now runs vitest + tests/index.js --no-build on every PR/push (about 5 minutes per OS) and only runs the full real Tauri build on push to main or manual dispatch. Replaces the broken hashFiles('~/.cargo/bin/cargo-hack') cache with taiki-e/install-action so cargo-hack downloads a precompiled binary. Plus type-gate hardening (rollup noEmitOnError: production) and Node version doc alignment in both READMEs (>=22 recommended LTS, >=18 also works), matching package.json engines. Co-authored-by: Cursor --- .github/workflows/quality-and-test.yml | 61 ++- README.md | 2 +- README_CN.md | 2 +- bin/builders/BaseBuilder.ts | 189 +------ bin/builders/env.ts | 175 +++++++ bin/cli.ts | 61 ++- bin/helpers/merge.ts | 89 ++-- bin/options/icon.ts | 26 +- bin/options/index.ts | 5 +- bin/utils/error.ts | 25 + bin/utils/ico.ts | 120 +++++ dist/cli.js | 489 ++++++++++++------ rollup.config.js | 3 +- .../merge-window-options.test.ts.snap | 85 +++ tests/unit/base-builder.test.ts | 72 ++- tests/unit/ico.test.ts | 95 +++- tests/unit/merge-window-options.test.ts | 107 ++++ 17 files changed, 1140 insertions(+), 466 deletions(-) create mode 100644 bin/builders/env.ts create mode 100644 bin/utils/error.ts create mode 100644 tests/unit/__snapshots__/merge-window-options.test.ts.snap create mode 100644 tests/unit/merge-window-options.test.ts diff --git a/.github/workflows/quality-and-test.yml b/.github/workflows/quality-and-test.yml index 6800dedb9c..b537e4d017 100644 --- a/.github/workflows/quality-and-test.yml +++ b/.github/workflows/quality-and-test.yml @@ -79,18 +79,10 @@ jobs: - uses: rui314/setup-mold@v1 - - name: Cache cargo-hack - uses: actions/cache@v5 - id: cargo-hack-cache - with: - path: ~/.cargo/bin/cargo-hack - key: ${{ runner.os }}-cargo-hack-${{ hashFiles('~/.cargo/bin/cargo-hack') }} - restore-keys: | - ${{ runner.os }}-cargo-hack- - - name: Install cargo-hack - if: steps.cargo-hack-cache.outputs.cache-hit != 'true' - run: cargo install cargo-hack --force + uses: taiki-e/install-action@v2 + with: + tool: cargo-hack - name: Check Rust formatting run: cargo fmt --all -- --color=always --check @@ -98,8 +90,11 @@ jobs: - name: Run Clippy lints run: cargo hack --feature-powerset --exclude-features cli-build --no-dev-deps clippy # cspell:disable-line - validation: - name: CLI & Build Validation (${{ matrix.os }}) + # Fast lane: runs on every PR and push. Skips the heavy real Tauri build + # (kept under tests/index.js) but still covers Vitest + integration suites + # so feedback stays under ~5 minutes per OS instead of 20+. + validation-fast: + name: CLI Fast Validation (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: matrix: @@ -117,14 +112,15 @@ jobs: - name: Build CLI run: pnpm run cli:build - - name: Run CI Test Suite - run: pnpm test - timeout-minutes: 30 + - name: Run Fast Test Suite + shell: bash + run: PAKE_CREATE_APP=1 node tests/index.js --no-build + timeout-minutes: 15 env: CI: true NODE_ENV: test - - name: Test CLI Integration + - name: Test CLI Integration (smoke) shell: bash run: | echo "Testing CLI integration..." @@ -134,10 +130,36 @@ jobs: timeout 30s PAKE_CREATE_APP=1 node dist/cli.js https://weekly.tw93.fun --name "CITest" --debug --iterative-build || true fi + # Full lane: only runs after merge to main (push event) or manual dispatch. + # Keeps the real Tauri build coverage off the PR critical path. + validation-full: + name: Full Tauri Build (${{ matrix.os }}) + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + fail-fast: false + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Build Environment + uses: ./.github/actions/setup-env + with: + mode: build + + - name: Run Full Test Suite (with real build) + run: pnpm test + timeout-minutes: 30 + env: + CI: true + NODE_ENV: test + summary: name: Quality Summary runs-on: ubuntu-latest - needs: [auto-format, rust-quality, validation] + needs: [auto-format, rust-quality, validation-fast, validation-full] if: always() steps: - name: Generate Summary @@ -149,5 +171,6 @@ jobs: echo "|-------|--------|" echo "| Auto Formatting | ${{ needs.auto-format.result == 'success' && 'PASSED' || needs.auto-format.result == 'skipped' && 'SKIPPED' || 'FAILED' }} |" echo "| Rust Quality | ${{ needs.rust-quality.result == 'success' && 'PASSED' || 'FAILED' }} |" - echo "| CLI & Build Validation | ${{ needs.validation.result == 'success' && 'PASSED' || 'FAILED' }} |" + echo "| CLI Fast Validation | ${{ needs.validation-fast.result == 'success' && 'PASSED' || 'FAILED' }} |" + echo "| Full Tauri Build | ${{ needs.validation-full.result == 'success' && 'PASSED' || needs.validation-full.result == 'skipped' && 'SKIPPED (PR fast lane)' || 'FAILED' }} |" } >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 7d1889e798..2e421e1201 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ First-time packaging requires environment setup and may be slower, subsequent bu ## Development -Requires Rust `>=1.85` and Node `>=22`. For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead. +Requires Rust `>=1.85` and Node `>=22` (recommended LTS; `>=18` also works). For detailed installation guide, see [Tauri documentation](https://v2.tauri.app/start/prerequisites/). If unfamiliar with development environment, use the CLI tool instead. ```bash # Install dependencies diff --git a/README_CN.md b/README_CN.md index 5562c13948..4f4ddfa3aa 100644 --- a/README_CN.md +++ b/README_CN.md @@ -178,7 +178,7 @@ pake https://weekly.tw93.fun --name Weekly --icon https://cdn.tw93.fun/pake/week ## 定制开发 -需要 Rust `>=1.85` 和 Node `>=22`,详细安装指南参考 [Tauri 文档](https://tauri.app/start/prerequisites/)。不熟悉开发环境建议直接使用命令行工具。 +需要 Rust `>=1.85` 和 Node `>=22`(推荐 LTS,较旧的 `>=18` 也可使用),详细安装指南参考 [Tauri 文档](https://tauri.app/start/prerequisites/)。不熟悉开发环境建议直接使用命令行工具。 ```bash # 安装依赖 diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index d416557cdd..c8a6f27c6b 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -17,146 +17,23 @@ import { shellExec } from '@/utils/shell'; import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; import { IS_MAC } from '@/utils/platform'; import logger from '@/options/logger'; +import { + configureCargoRegistry, + detectPackageManager, + getBuildEnvironment, + getBuildTimeout, + getInstallCommand, + getInstallTimeout, + isLinuxDeployStripError, +} from './env'; export default abstract class BaseBuilder { protected options: PakeAppOptions; - private static packageManagerCache: string | null = null; protected constructor(options: PakeAppOptions) { this.options = options; } - private getBuildEnvironment() { - if (!IS_MAC) { - return undefined; - } - - const currentPath = process.env.PATH || ''; - const systemToolsPath = '/usr/bin'; - const buildPath = currentPath.startsWith(`${systemToolsPath}:`) - ? currentPath - : `${systemToolsPath}:${currentPath}`; - - return { - CFLAGS: '-fno-modules', - CXXFLAGS: '-fno-modules', - MACOSX_DEPLOYMENT_TARGET: '14.0', - PATH: buildPath, - }; - } - - private getInstallTimeout(): number { - // Windows needs more time due to native compilation and antivirus scanning - return process.platform === 'win32' ? 900000 : 600000; - } - - private getBuildTimeout(): number { - return 900000; - } - - private async detectPackageManager(): Promise { - if (BaseBuilder.packageManagerCache) { - return BaseBuilder.packageManagerCache; - } - - const { execa } = await import('execa'); - - try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ Using pnpm for package management.'); - BaseBuilder.packageManagerCache = 'pnpm'; - return 'pnpm'; - } catch { - try { - await execa('npm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ pnpm not available, using npm for package management.'); - BaseBuilder.packageManagerCache = 'npm'; - return 'npm'; - } catch { - throw new Error( - 'Neither pnpm nor npm is available. Please install a package manager.', - ); - } - } - } - - private async copyFileWithSamePathGuard( - sourcePath: string, - destinationPath: string, - ): Promise { - if (path.resolve(sourcePath) === path.resolve(destinationPath)) { - return; - } - - try { - await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); - } catch (error) { - if ( - error instanceof Error && - error.message.includes('Source and destination must not be the same') - ) { - return; - } - - throw error; - } - } - - private getInstallCommand( - packageManager: string, - useCnMirror: boolean, - ): string { - const registryOption = useCnMirror - ? ' --registry=https://registry.npmmirror.com' - : ''; - const peerDepsOption = - packageManager === 'npm' ? ' --legacy-peer-deps' : ''; - - return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; - } - - private isGeneratedCnMirrorConfig( - projectConfig: string, - cnMirrorConfig: string, - ): boolean { - return projectConfig.trim() === cnMirrorConfig.trim(); - } - - private async configureCargoRegistry( - tauriSrcPath: string, - useCnMirror: boolean, - ): Promise { - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - - if (useCnMirror) { - await fsExtra.ensureDir(rustProjectDir); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - return; - } - - if (!(await fsExtra.pathExists(projectConf))) { - return; - } - - const [projectConfig, cnMirrorConfig] = await Promise.all([ - fsExtra.readFile(projectConf, 'utf8'), - fsExtra.readFile(projectCnConf, 'utf8'), - ]); - - if (this.isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { - await fsExtra.remove(projectConf); - return; - } - - if (projectConfig.includes('rsproxy.cn')) { - logger.warn( - `✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`, - ); - } - } - async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); const tauriTargetPath = path.join(tauriSrcPath, 'target'); @@ -186,13 +63,11 @@ export default abstract class BaseBuilder { const spinner = getSpinner('Installing package...'); const useCnMirror = isCnMirrorEnabled(); - await this.configureCargoRegistry(tauriSrcPath, useCnMirror); - - // Detect available package manager - const packageManager = await this.detectPackageManager(); + await configureCargoRegistry(tauriSrcPath, useCnMirror); - const timeout = this.getInstallTimeout(); - const buildEnv = this.getBuildEnvironment(); + const packageManager = await detectPackageManager(); + const timeout = getInstallTimeout(); + const buildEnv = getBuildEnvironment(); // Show helpful message for first-time users if (!tauriTargetPathExists) { @@ -210,14 +85,10 @@ export default abstract class BaseBuilder { } try { - await shellExec( - this.getInstallCommand(packageManager, useCnMirror), - timeout, - { - ...buildEnv, - CI: 'true', - }, - ); + await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, { + ...buildEnv, + CI: 'true', + }); spinner.succeed(chalk.green('Package installed!')); } catch (error) { spinner.fail(chalk.red('Installation failed')); @@ -244,7 +115,7 @@ export default abstract class BaseBuilder { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); const configPath = path.join( npmDirectory, 'src-tauri', @@ -266,8 +137,7 @@ export default abstract class BaseBuilder { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); - // Detect available package manager - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); // Build app const buildSpinner = getSpinner('Building app...'); @@ -277,7 +147,7 @@ export default abstract class BaseBuilder { // Show static message to keep the status visible logger.warn('✸ Building app...'); - const baseEnv = this.getBuildEnvironment(); + const baseEnv = getBuildEnvironment(); let buildEnv: Record = { ...(baseEnv ?? {}), ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}), @@ -301,7 +171,7 @@ export default abstract class BaseBuilder { } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; - const buildTimeout = this.getBuildTimeout(); + const buildTimeout = getBuildTimeout(); try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); @@ -310,7 +180,7 @@ export default abstract class BaseBuilder { process.platform === 'linux' && target === 'appimage' && !buildEnv.NO_STRIP && - this.isLinuxDeployStripError(error); + isLinuxDeployStripError(error); if (shouldRetryWithoutStrip) { logger.warn( @@ -388,21 +258,6 @@ export default abstract class BaseBuilder { abstract getFileName(): string; - private isLinuxDeployStripError(error: unknown): boolean { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - return ( - message.includes('linuxdeploy') || - message.includes('failed to run linuxdeploy') || - message.includes('strip:') || - message.includes('unable to recognise the format of the input file') || - message.includes('appimage tool failed') || - message.includes('strip tool') - ); - } - protected static readonly ARCH_MAPPINGS: Record< string, Record diff --git a/bin/builders/env.ts b/bin/builders/env.ts new file mode 100644 index 0000000000..bb4000078d --- /dev/null +++ b/bin/builders/env.ts @@ -0,0 +1,175 @@ +import path from 'path'; +import fsExtra from 'fs-extra'; + +import { CN_MIRROR_ENV } from '@/utils/mirror'; +import { IS_MAC } from '@/utils/platform'; +import { npmDirectory } from '@/utils/dir'; +import logger from '@/options/logger'; + +/** + * Returns build environment variables overrides for macOS, where Rust crates + * sometimes need explicit C/C++ flags and a deterministic SDK target. Other + * platforms inherit `process.env` unchanged. + */ +export function getBuildEnvironment(): Record | undefined { + if (!IS_MAC) { + return undefined; + } + + const currentPath = process.env.PATH || ''; + const systemToolsPath = '/usr/bin'; + const buildPath = currentPath.startsWith(`${systemToolsPath}:`) + ? currentPath + : `${systemToolsPath}:${currentPath}`; + + return { + CFLAGS: '-fno-modules', + CXXFLAGS: '-fno-modules', + MACOSX_DEPLOYMENT_TARGET: '14.0', + PATH: buildPath, + }; +} + +/** + * Windows needs more time due to native compilation and antivirus scanning. + */ +export function getInstallTimeout(): number { + return process.platform === 'win32' ? 900_000 : 600_000; +} + +export function getBuildTimeout(): number { + return 900_000; +} + +let packageManagerCache: 'pnpm' | 'npm' | null = null; + +/** Resets the cached package manager. Exported for tests. */ +export function _resetPackageManagerCache(): void { + packageManagerCache = null; +} + +/** + * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. + * Cached after the first successful detection so tests can call repeatedly. + */ +export async function detectPackageManager(): Promise<'pnpm' | 'npm'> { + if (packageManagerCache) { + return packageManagerCache; + } + + const { execa } = await import('execa'); + try { + await execa('pnpm', ['--version'], { stdio: 'ignore' }); + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; + } catch { + try { + await execa('npm', ['--version'], { stdio: 'ignore' }); + logger.info('✺ pnpm not available, using npm for package management.'); + packageManagerCache = 'npm'; + return 'npm'; + } catch { + throw new Error( + 'Neither pnpm nor npm is available. Please install a package manager.', + ); + } + } +} + +export function getInstallCommand( + packageManager: string, + useCnMirror: boolean, +): string { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; +} + +async function copyFileWithSamePathGuard( + sourcePath: string, + destinationPath: string, +): Promise { + if (path.resolve(sourcePath) === path.resolve(destinationPath)) { + return; + } + try { + await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); + } catch (error) { + if ( + error instanceof Error && + error.message.includes('Source and destination must not be the same') + ) { + return; + } + throw error; + } +} + +function isGeneratedCnMirrorConfig( + projectConfig: string, + cnMirrorConfig: string, +): boolean { + return projectConfig.trim() === cnMirrorConfig.trim(); +} + +/** + * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in + * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config + * (or warns about a manual one) when they opt out. + */ +export async function configureCargoRegistry( + tauriSrcPath: string, + useCnMirror: boolean, +): Promise { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await copyFileWithSamePathGuard(projectCnConf, projectConf); + return; + } + + if (!(await fsExtra.pathExists(projectConf))) { + return; + } + + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + + if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + + if (projectConfig.includes('rsproxy.cn')) { + logger.warn( + `✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`, + ); + } +} + +/** + * Returns true when an error string looks like the well-known Tauri+linuxdeploy + * strip failure that we automatically retry with NO_STRIP=1. + */ +export function 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') + ); +} diff --git a/bin/cli.ts b/bin/cli.ts index 69f4238169..8fe6738c28 100644 --- a/bin/cli.ts +++ b/bin/cli.ts @@ -1,9 +1,11 @@ import log from 'loglevel'; +import chalk from 'chalk'; import updateNotifier from 'update-notifier'; import packageJson from '../package.json'; import BuilderProvider from './builders/BuilderProvider'; import handleInputOptions from './options/index'; import { getCliProgram } from './helpers/cli-program'; +import { isPakeError } from './utils/error'; import { PakeCliOptions } from './types'; const program = getCliProgram(); @@ -15,26 +17,47 @@ async function checkUpdateTips() { } program.action(async (url: string, options: PakeCliOptions) => { - await checkUpdateTips(); - - if (!url) { - program.help({ - error: false, - }); - return; + try { + await checkUpdateTips(); + + if (!url) { + program.help({ + error: false, + }); + return; + } + + log.setDefaultLevel('info'); + log.setLevel('info'); + if (options.debug) { + log.setLevel('debug'); + } + + const appOptions = await handleInputOptions(options, url); + + const builder = BuilderProvider.create(appOptions); + await builder.prepare(); + await builder.build(url); + } catch (error) { + if (isPakeError(error)) { + console.error(chalk.red(error.message)); + } else if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + if (options?.debug && error.stack) { + console.error(chalk.gray(error.stack)); + } + } else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); + } + process.exit(1); } +}); - log.setDefaultLevel('info'); - log.setLevel('info'); - if (options.debug) { - log.setLevel('debug'); +program.parseAsync().catch((error: unknown) => { + if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + } else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); } - - const appOptions = await handleInputOptions(options, url); - - const builder = BuilderProvider.create(appOptions); - await builder.prepare(); - await builder.build(url); + process.exit(1); }); - -program.parse(); diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index fa6503786f..0425e9d8d5 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -18,6 +18,45 @@ import { } from '@/types'; import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; +/** + * Pure transform from CLI options to the window-config slice that gets + * merged into pake.json. Exposed for snapshot testing so option drift + * (e.g. a new flag added in cli-program.ts but forgotten here) is caught. + * + * Keep this function side-effect free. + */ +export function buildWindowConfigOverrides( + options: PakeAppOptions, + platform: SupportedPlatform = asSupportedPlatform(process.platform), +): Partial { + const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + return { + width: options.width, + height: options.height, + fullscreen: options.fullscreen, + maximize: options.maximize, + resizable: options.resizable ?? true, + hide_title_bar: options.hideTitleBar, + activation_shortcut: options.activationShortcut, + always_on_top: options.alwaysOnTop, + dark_mode: options.darkMode, + disabled_web_shortcuts: options.disabledWebShortcuts, + hide_on_close: platformHideOnClose, + incognito: options.incognito, + title: options.title, + enable_wasm: options.wasm, + enable_drag_drop: options.enableDragDrop, + start_to_tray: options.startToTray && options.showSystemTray, + force_internal_navigation: options.forceInternalNavigation, + internal_url_regex: options.internalUrlRegex, + zoom: options.zoom, + min_width: options.minWidth, + min_height: options.minHeight, + ignore_certificate_errors: options.ignoreCertificateErrors, + new_window: options.newWindow, + }; +} + type PlatformIconInfo = { fileExt: string; path: string; @@ -388,68 +427,20 @@ export async function mergeConfig( await copyTemplateConfigs(); const { - width, - height, - fullscreen, - maximize, - hideTitleBar, - alwaysOnTop, appVersion, - darkMode, - disabledWebShortcuts, - activationShortcut, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', - resizable = true, installerLanguage, - hideOnClose, - incognito, - title, wasm, - enableDragDrop, - startToTray, - forceInternalNavigation, - internalUrlRegex, - zoom, - minWidth, - minHeight, - ignoreCertificateErrors, - newWindow, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); - const platformHideOnClose = hideOnClose ?? platform === 'darwin'; - - const tauriConfWindowOptions: Partial = { - width, - height, - fullscreen, - maximize, - resizable, - hide_title_bar: hideTitleBar, - activation_shortcut: activationShortcut, - always_on_top: alwaysOnTop, - dark_mode: darkMode, - disabled_web_shortcuts: disabledWebShortcuts, - hide_on_close: platformHideOnClose, - incognito, - title, - enable_wasm: wasm, - enable_drag_drop: enableDragDrop, - start_to_tray: startToTray && showSystemTray, - force_internal_navigation: forceInternalNavigation, - internal_url_regex: internalUrlRegex, - zoom, - min_width: minWidth, - min_height: minHeight, - ignore_certificate_errors: ignoreCertificateErrors, - new_window: newWindow, - }; + const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; diff --git a/bin/options/icon.ts b/bin/options/icon.ts index a068fb87b3..ca0c29664d 100644 --- a/bin/options/icon.ts +++ b/bin/options/icon.ts @@ -17,7 +17,12 @@ import { } from '@/utils/icon-source'; import { generateLinuxPackageName, getSafeAppName } from '@/utils/name'; import { PakeAppOptions } from '@/types'; -import { writeIcoWithPreferredSize, buildIcoFromPngBuffers } from '@/utils/ico'; +import { + ensureMultiResolutionIco, + writeIcoWithPreferredSize, + buildIcoFromPngBuffers, + WIN_STANDARD_ICO_SIZES, +} from '@/utils/ico'; type PlatformIconConfig = { format: string; @@ -43,7 +48,7 @@ const ICON_CONFIG = { } as const; const PLATFORM_CONFIG: Record<'win' | 'linux' | 'macos', PlatformIconConfig> = { - win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] }, + win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] }, linux: { format: '.png', size: 512 }, macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] }, }; @@ -89,14 +94,23 @@ async function copyWindowsIconIfNeeded( try { const finalIconPath = generateIconPath(appName); await fsExtra.ensureDir(path.dirname(finalIconPath)); - // Reorder ICO to prioritize 256px icons for better Windows display - const reordered = await writeIcoWithPreferredSize( + // Re-render ICO so every Windows standard size is present and prefer the + // 256px frame as the leading entry; falls back to plain reordering if the + // ICO is non-decodable, then to a raw copy. (Issue #1190) + const upgraded = await ensureMultiResolutionIco( convertedPath, finalIconPath, 256, ); - if (!reordered) { - await fsExtra.copy(convertedPath, finalIconPath); + if (!upgraded) { + const reordered = await writeIcoWithPreferredSize( + convertedPath, + finalIconPath, + 256, + ); + if (!reordered) { + await fsExtra.copy(convertedPath, finalIconPath); + } } return finalIconPath; } catch (error) { diff --git a/bin/options/index.ts b/bin/options/index.ts index 43618aaed5..63945874d3 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -10,6 +10,7 @@ import { resolveIdentifier, } from '@/utils/info'; import { generateLinuxPackageName } from '@/utils/name'; +import { PakeError } from '@/utils/error'; import { PakeAppOptions, PakeCliOptions } from '@/types'; function resolveAppName(name: string, platform: NodeJS.Platform): string { @@ -68,12 +69,12 @@ export default async function handleOptions( 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) { + logger.error(errorMsg); name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); } else { - process.exit(1); + throw new PakeError(errorMsg); } } diff --git a/bin/utils/error.ts b/bin/utils/error.ts new file mode 100644 index 0000000000..db6412065a --- /dev/null +++ b/bin/utils/error.ts @@ -0,0 +1,25 @@ +/** + * Error class used for user-facing CLI errors. + * + * The top-level catch in `bin/cli.ts` prints `message` directly without a + * stack trace and exits with code 1. Use this for predictable failures + * (invalid names, missing files, etc.) so users see a clean message instead + * of a Node.js stack dump. + */ +export class PakeError extends Error { + readonly isUserError = true; + + constructor(message: string) { + super(message); + this.name = 'PakeError'; + } +} + +export function isPakeError(error: unknown): error is PakeError { + return ( + error instanceof PakeError || + (typeof error === 'object' && + error !== null && + (error as { isUserError?: boolean }).isUserError === true) + ); +} diff --git a/bin/utils/ico.ts b/bin/utils/ico.ts index c082cba8c0..182d51cf4d 100644 --- a/bin/utils/ico.ts +++ b/bin/utils/ico.ts @@ -1,9 +1,13 @@ import path from 'path'; import fsExtra from 'fs-extra'; +import sharp from 'sharp'; const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; const ICO_TYPE_ICON = 1; +// Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48), +// shell (48/256) and high-DPI (128/256). Issue #1190. +export const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; type IcoEntry = { index: number; @@ -142,6 +146,122 @@ export async function writeIcoWithPreferredSize( } } +/** + * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an + * embedded PNG payload (PNG-in-ICO, supported since Windows Vista). + */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + +function frameLooksLikePng(entry: IcoEntry): boolean { + return ( + entry.data.length >= PNG_SIGNATURE.length && + entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) + ); +} + +async function decodeFrameToPng(entry: IcoEntry): Promise { + if (frameLooksLikePng(entry)) { + return Buffer.from(entry.data); + } + // BMP DIB frames need to go through sharp's ico-to-PNG path, which only + // works on the full ICO container. Fall back to letting the caller use a + // sharp pipeline against the original ICO for the missing source. + return null; +} + +async function pickLargestFrameAsPng( + buffer: Buffer, + entries: IcoEntry[], +): Promise { + const largest = [...entries].sort( + (a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height), + )[0]; + if (largest) { + const decoded = await decodeFrameToPng(largest); + if (decoded) { + return decoded; + } + } + + // Fallback: let sharp render directly from the ICO buffer. sharp picks the + // largest embedded frame on its own. + try { + return await sharp(buffer).png().toBuffer(); + } catch { + return null; + } +} + +/** + * Ensures the produced ICO carries every Windows standard size so the OS + * never has to downsample a 256x256 frame to 16x16 for the tray. + * Falls back to `writeIcoWithPreferredSize` if rendering fails. + * + * Issue #1190. + */ +export async function ensureMultiResolutionIco( + sourcePath: string, + outputPath: string, + preferredSize: number = 256, + desiredSizes: readonly number[] = WIN_STANDARD_ICO_SIZES, +): Promise { + try { + const sourceBuffer = await fsExtra.readFile(sourcePath); + const entries = parseIcoBuffer(sourceBuffer); + + const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries); + if (!sourcePng) { + return await writeIcoWithPreferredSize( + sourcePath, + outputPath, + preferredSize, + ); + } + + const frames = await Promise.all( + desiredSizes.map(async (size) => { + // Reuse an existing exact-size PNG frame when possible to keep any + // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting). + const exact = entries.find( + (entry) => entry.width === size && entry.height === size, + ); + if (exact && frameLooksLikePng(exact)) { + return { size, png: Buffer.from(exact.data) }; + } + const png = await sharp(sourcePng) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .png() + .toBuffer(); + return { size, png }; + }), + ); + + // Order frames so the preferred size lands first (Windows shell uses the + // first-listed frame as a quality hint when choosing which to display). + frames.sort((a, b) => { + const aExact = a.size === preferredSize ? 0 : 1; + const bExact = b.size === preferredSize ? 0 : 1; + if (aExact !== bExact) return aExact - bExact; + return b.size - a.size; + }); + + const icoBuffer = buildIcoFromPngBuffers(frames); + await fsExtra.ensureDir(path.dirname(outputPath)); + await fsExtra.outputFile(outputPath, icoBuffer); + return true; + } catch { + return await writeIcoWithPreferredSize( + sourcePath, + outputPath, + preferredSize, + ); + } +} + /** * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format * (supported since Windows Vista). This preserves alpha transparency. diff --git a/dist/cli.js b/dist/cli.js index 076d2e4aef..82e1a1c3ab 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1,10 +1,10 @@ #!/usr/bin/env node import log from 'loglevel'; +import chalk from 'chalk'; import updateNotifier from 'update-notifier'; import path from 'path'; import fsExtra from 'fs-extra'; import { fileURLToPath } from 'url'; -import chalk from 'chalk'; import prompts from 'prompts'; import os from 'os'; import { execa, execaSync } from 'execa'; @@ -436,6 +436,41 @@ function generateIdentifierSafeName(name) { return cleaned; } +/** + * Pure transform from CLI options to the window-config slice that gets + * merged into pake.json. Exposed for snapshot testing so option drift + * (e.g. a new flag added in cli-program.ts but forgotten here) is caught. + * + * Keep this function side-effect free. + */ +function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) { + const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + return { + width: options.width, + height: options.height, + fullscreen: options.fullscreen, + maximize: options.maximize, + resizable: options.resizable ?? true, + hide_title_bar: options.hideTitleBar, + activation_shortcut: options.activationShortcut, + always_on_top: options.alwaysOnTop, + dark_mode: options.darkMode, + disabled_web_shortcuts: options.disabledWebShortcuts, + hide_on_close: platformHideOnClose, + incognito: options.incognito, + title: options.title, + enable_wasm: options.wasm, + enable_drag_drop: options.enableDragDrop, + start_to_tray: options.startToTray && options.showSystemTray, + force_internal_navigation: options.forceInternalNavigation, + internal_url_regex: options.internalUrlRegex, + zoom: options.zoom, + min_width: options.minWidth, + min_height: options.minHeight, + ignore_certificate_errors: options.ignoreCertificateErrors, + new_window: options.newWindow, + }; +} function asSupportedPlatform(platform) { if (platform !== 'win32' && platform !== 'darwin' && platform !== 'linux') { throw new Error(`Pake only supports win32, darwin, and linux; detected '${platform}'.`); @@ -691,34 +726,9 @@ async function writeAllConfigs(tauriConf, platform) { } async function mergeConfig(url, options, tauriConf) { await copyTemplateConfigs(); - const { width, height, fullscreen, maximize, hideTitleBar, alwaysOnTop, appVersion, darkMode, disabledWebShortcuts, activationShortcut, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', resizable = true, installerLanguage, hideOnClose, incognito, title, wasm, enableDragDrop, startToTray, forceInternalNavigation, internalUrlRegex, zoom, minWidth, minHeight, ignoreCertificateErrors, newWindow, camera, microphone, } = options; + const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); - const platformHideOnClose = hideOnClose ?? platform === 'darwin'; - const tauriConfWindowOptions = { - width, - height, - fullscreen, - maximize, - resizable, - hide_title_bar: hideTitleBar, - activation_shortcut: activationShortcut, - always_on_top: alwaysOnTop, - dark_mode: darkMode, - disabled_web_shortcuts: disabledWebShortcuts, - hide_on_close: platformHideOnClose, - incognito, - title, - enable_wasm: wasm, - enable_drag_drop: enableDragDrop, - start_to_tray: startToTray && showSystemTray, - force_internal_navigation: forceInternalNavigation, - internal_url_regex: internalUrlRegex, - zoom, - min_width: minWidth, - min_height: minHeight, - ignore_certificate_errors: ignoreCertificateErrors, - new_window: newWindow, - }; + const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; @@ -764,104 +774,138 @@ async function mergeConfig(url, options, tauriConf) { await writeAllConfigs(tauriConf, platform); } -class BaseBuilder { - constructor(options) { - this.options = options; - } - getBuildEnvironment() { - if (!IS_MAC) { - return undefined; - } - const currentPath = process.env.PATH || ''; - const systemToolsPath = '/usr/bin'; - const buildPath = currentPath.startsWith(`${systemToolsPath}:`) - ? currentPath - : `${systemToolsPath}:${currentPath}`; - return { - CFLAGS: '-fno-modules', - CXXFLAGS: '-fno-modules', - MACOSX_DEPLOYMENT_TARGET: '14.0', - PATH: buildPath, - }; +/** + * Returns build environment variables overrides for macOS, where Rust crates + * sometimes need explicit C/C++ flags and a deterministic SDK target. Other + * platforms inherit `process.env` unchanged. + */ +function getBuildEnvironment() { + if (!IS_MAC) { + return undefined; } - getInstallTimeout() { - // Windows needs more time due to native compilation and antivirus scanning - return process.platform === 'win32' ? 900000 : 600000; + const currentPath = process.env.PATH || ''; + const systemToolsPath = '/usr/bin'; + const buildPath = currentPath.startsWith(`${systemToolsPath}:`) + ? currentPath + : `${systemToolsPath}:${currentPath}`; + return { + CFLAGS: '-fno-modules', + CXXFLAGS: '-fno-modules', + MACOSX_DEPLOYMENT_TARGET: '14.0', + PATH: buildPath, + }; +} +/** + * Windows needs more time due to native compilation and antivirus scanning. + */ +function getInstallTimeout() { + return process.platform === 'win32' ? 900000 : 600000; +} +function getBuildTimeout() { + return 900000; +} +let packageManagerCache = null; +/** + * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. + * Cached after the first successful detection so tests can call repeatedly. + */ +async function detectPackageManager() { + if (packageManagerCache) { + return packageManagerCache; } - getBuildTimeout() { - return 900000; + const { execa } = await import('execa'); + try { + await execa('pnpm', ['--version'], { stdio: 'ignore' }); + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; } - async detectPackageManager() { - if (BaseBuilder.packageManagerCache) { - return BaseBuilder.packageManagerCache; - } - const { execa } = await import('execa'); + catch { try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); - logger.info('✺ Using pnpm for package management.'); - BaseBuilder.packageManagerCache = 'pnpm'; - return 'pnpm'; + await execa('npm', ['--version'], { stdio: 'ignore' }); + logger.info('✺ pnpm not available, using npm for package management.'); + packageManagerCache = 'npm'; + return 'npm'; } 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.'); - } + throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); } } - async copyFileWithSamePathGuard(sourcePath, destinationPath) { - if (path.resolve(sourcePath) === path.resolve(destinationPath)) { +} +function getInstallCommand(packageManager, useCnMirror) { + const registryOption = useCnMirror + ? ' --registry=https://registry.npmmirror.com' + : ''; + const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; + return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; +} +async function copyFileWithSamePathGuard(sourcePath, destinationPath) { + if (path.resolve(sourcePath) === path.resolve(destinationPath)) { + return; + } + try { + await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); + } + catch (error) { + if (error instanceof Error && + error.message.includes('Source and destination must not be the same')) { return; } - try { - await fsExtra.copy(sourcePath, destinationPath, { overwrite: true }); - } - catch (error) { - if (error instanceof Error && - error.message.includes('Source and destination must not be the same')) { - return; - } - throw error; - } + throw error; } - getInstallCommand(packageManager, useCnMirror) { - const registryOption = useCnMirror - ? ' --registry=https://registry.npmmirror.com' - : ''; - const peerDepsOption = packageManager === 'npm' ? ' --legacy-peer-deps' : ''; - return `cd "${npmDirectory}" && ${packageManager} install${registryOption}${peerDepsOption}`; +} +function isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) { + return projectConfig.trim() === cnMirrorConfig.trim(); +} +/** + * Toggles `.cargo/config.toml` to point at rsproxy.cn when the user opts in + * via `PAKE_USE_CN_MIRROR=1`, and removes the auto-generated mirror config + * (or warns about a manual one) when they opt out. + */ +async function configureCargoRegistry(tauriSrcPath, useCnMirror) { + const rustProjectDir = path.join(tauriSrcPath, '.cargo'); + const projectConf = path.join(rustProjectDir, 'config.toml'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + if (useCnMirror) { + await fsExtra.ensureDir(rustProjectDir); + await copyFileWithSamePathGuard(projectCnConf, projectConf); + return; } - isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig) { - return projectConfig.trim() === cnMirrorConfig.trim(); + if (!(await fsExtra.pathExists(projectConf))) { + return; } - async configureCargoRegistry(tauriSrcPath, useCnMirror) { - const rustProjectDir = path.join(tauriSrcPath, '.cargo'); - const projectConf = path.join(rustProjectDir, 'config.toml'); - const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); - if (useCnMirror) { - await fsExtra.ensureDir(rustProjectDir); - await this.copyFileWithSamePathGuard(projectCnConf, projectConf); - return; - } - if (!(await fsExtra.pathExists(projectConf))) { - return; - } - const [projectConfig, cnMirrorConfig] = await Promise.all([ - fsExtra.readFile(projectConf, 'utf8'), - fsExtra.readFile(projectCnConf, 'utf8'), - ]); - if (this.isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { - await fsExtra.remove(projectConf); - return; - } - if (projectConfig.includes('rsproxy.cn')) { - logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); - } + const [projectConfig, cnMirrorConfig] = await Promise.all([ + fsExtra.readFile(projectConf, 'utf8'), + fsExtra.readFile(projectCnConf, 'utf8'), + ]); + if (isGeneratedCnMirrorConfig(projectConfig, cnMirrorConfig)) { + await fsExtra.remove(projectConf); + return; + } + if (projectConfig.includes('rsproxy.cn')) { + logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); + } +} +/** + * Returns true when an error string looks like the well-known Tauri+linuxdeploy + * strip failure that we automatically retry with NO_STRIP=1. + */ +function isLinuxDeployStripError(error) { + if (!(error instanceof Error) || !error.message) { + return false; + } + const message = error.message.toLowerCase(); + return (message.includes('linuxdeploy') || + message.includes('failed to run linuxdeploy') || + message.includes('strip:') || + message.includes('unable to recognise the format of the input file') || + message.includes('appimage tool failed') || + message.includes('strip tool')); +} + +class BaseBuilder { + constructor(options) { + this.options = options; } async prepare() { const tauriSrcPath = path.join(npmDirectory, 'src-tauri'); @@ -888,11 +932,10 @@ class BaseBuilder { } const spinner = getSpinner('Installing package...'); const useCnMirror = isCnMirrorEnabled(); - await this.configureCargoRegistry(tauriSrcPath, useCnMirror); - // Detect available package manager - const packageManager = await this.detectPackageManager(); - const timeout = this.getInstallTimeout(); - const buildEnv = this.getBuildEnvironment(); + await configureCargoRegistry(tauriSrcPath, useCnMirror); + const packageManager = await detectPackageManager(); + const timeout = getInstallTimeout(); + const buildEnv = getBuildEnvironment(); // Show helpful message for first-time users if (!tauriTargetPathExists) { logger.info(process.platform === 'win32' @@ -903,7 +946,7 @@ class BaseBuilder { logger.info(`✺ ${CN_MIRROR_ENV}=1 detected, using ${packageManager}/rsProxy CN mirror.`); } try { - await shellExec(this.getInstallCommand(packageManager, useCnMirror), timeout, { + await shellExec(getInstallCommand(packageManager, useCnMirror), timeout, { ...buildEnv, CI: 'true', }); @@ -926,7 +969,7 @@ class BaseBuilder { async start(url) { logger.info('Pake dev server starting...'); await mergeConfig(url, this.options, tauriConfig); - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); const configPath = path.join(npmDirectory, 'src-tauri', '.pake', 'tauri.conf.json'); const features = this.getBuildFeatures(); const featureArgs = features.length > 0 ? `--features ${features.join(',')}` : ''; @@ -937,8 +980,7 @@ class BaseBuilder { async buildAndCopy(url, target) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); - // Detect available package manager - const packageManager = await this.detectPackageManager(); + const packageManager = await detectPackageManager(); // Build app const buildSpinner = getSpinner('Building app...'); // Let spinner run for a moment so user can see it, then stop before package manager command @@ -946,7 +988,7 @@ class BaseBuilder { buildSpinner.stop(); // Show static message to keep the status visible logger.warn('✸ Building app...'); - const baseEnv = this.getBuildEnvironment(); + const baseEnv = getBuildEnvironment(); let buildEnv = { ...(baseEnv ?? {}), ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}), @@ -962,7 +1004,7 @@ class BaseBuilder { } } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; - const buildTimeout = this.getBuildTimeout(); + const buildTimeout = getBuildTimeout(); try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } @@ -970,7 +1012,7 @@ class BaseBuilder { const shouldRetryWithoutStrip = process.platform === 'linux' && target === 'appimage' && !buildEnv.NO_STRIP && - this.isLinuxDeployStripError(error); + isLinuxDeployStripError(error); if (shouldRetryWithoutStrip) { logger.warn('⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.'); buildEnv = { @@ -1026,18 +1068,6 @@ class BaseBuilder { getFileType(target) { return target; } - isLinuxDeployStripError(error) { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - return (message.includes('linuxdeploy') || - message.includes('failed to run linuxdeploy') || - message.includes('strip:') || - message.includes('unable to recognise the format of the input file') || - message.includes('appimage tool failed') || - message.includes('strip tool')); - } resolveTargetArch(requestedArch) { if (requestedArch === 'auto' || !requestedArch) { return process.arch; @@ -1175,7 +1205,6 @@ class BaseBuilder { return 'src-tauri/target'; // Override in subclasses if needed } } -BaseBuilder.packageManagerCache = null; BaseBuilder.ARCH_MAPPINGS = { darwin: { arm64: 'aarch64-apple-darwin', @@ -1511,6 +1540,9 @@ function getIconSourcePriority(url, appName) { const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; const ICO_TYPE_ICON = 1; +// Standard Windows icon sizes covering tray (16/24/32), taskbar (32/48), +// shell (48/256) and high-DPI (128/256). Issue #1190. +const WIN_STANDARD_ICO_SIZES = [16, 24, 32, 48, 64, 128, 256]; function decodeDimension(value) { return value === 0 ? 256 : value; } @@ -1609,6 +1641,91 @@ async function writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize) return false; } } +/** + * PNG signature `\x89PNG`. ICO frames may carry either a BMP DIB or an + * embedded PNG payload (PNG-in-ICO, supported since Windows Vista). + */ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47]); +function frameLooksLikePng(entry) { + return (entry.data.length >= PNG_SIGNATURE.length && + entry.data.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)); +} +async function decodeFrameToPng(entry) { + if (frameLooksLikePng(entry)) { + return Buffer.from(entry.data); + } + // BMP DIB frames need to go through sharp's ico-to-PNG path, which only + // works on the full ICO container. Fall back to letting the caller use a + // sharp pipeline against the original ICO for the missing source. + return null; +} +async function pickLargestFrameAsPng(buffer, entries) { + const largest = [...entries].sort((a, b) => Math.max(b.width, b.height) - Math.max(a.width, a.height))[0]; + if (largest) { + const decoded = await decodeFrameToPng(largest); + if (decoded) { + return decoded; + } + } + // Fallback: let sharp render directly from the ICO buffer. sharp picks the + // largest embedded frame on its own. + try { + return await sharp(buffer).png().toBuffer(); + } + catch { + return null; + } +} +/** + * Ensures the produced ICO carries every Windows standard size so the OS + * never has to downsample a 256x256 frame to 16x16 for the tray. + * Falls back to `writeIcoWithPreferredSize` if rendering fails. + * + * Issue #1190. + */ +async function ensureMultiResolutionIco(sourcePath, outputPath, preferredSize = 256, desiredSizes = WIN_STANDARD_ICO_SIZES) { + try { + const sourceBuffer = await fsExtra.readFile(sourcePath); + const entries = parseIcoBuffer(sourceBuffer); + const sourcePng = await pickLargestFrameAsPng(sourceBuffer, entries); + if (!sourcePng) { + return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize); + } + const frames = await Promise.all(desiredSizes.map(async (size) => { + // Reuse an existing exact-size PNG frame when possible to keep any + // hand-tuned small icon (e.g. a 16x16 with deliberate pixel hinting). + const exact = entries.find((entry) => entry.width === size && entry.height === size); + if (exact && frameLooksLikePng(exact)) { + return { size, png: Buffer.from(exact.data) }; + } + const png = await sharp(sourcePng) + .resize(size, size, { + fit: 'contain', + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .ensureAlpha() + .png() + .toBuffer(); + return { size, png }; + })); + // Order frames so the preferred size lands first (Windows shell uses the + // first-listed frame as a quality hint when choosing which to display). + frames.sort((a, b) => { + const aExact = a.size === preferredSize ? 0 : 1; + const bExact = b.size === preferredSize ? 0 : 1; + if (aExact !== bExact) + return aExact - bExact; + return b.size - a.size; + }); + const icoBuffer = buildIcoFromPngBuffers(frames); + await fsExtra.ensureDir(path.dirname(outputPath)); + await fsExtra.outputFile(outputPath, icoBuffer); + return true; + } + catch { + return await writeIcoWithPreferredSize(sourcePath, outputPath, preferredSize); + } +} /** * Builds an ICO file from an array of PNG buffers using the PNG-in-ICO format * (supported since Windows Vista). This preserves alpha transparency. @@ -1658,7 +1775,7 @@ const ICON_CONFIG = { }, }; const PLATFORM_CONFIG = { - win: { format: '.ico', sizes: [16, 32, 48, 64, 128, 256] }, + win: { format: '.ico', sizes: [...WIN_STANDARD_ICO_SIZES] }, linux: { format: '.png', size: 512 }, macos: { format: '.icns', sizes: [16, 32, 64, 128, 256, 512, 1024] }, }; @@ -1693,10 +1810,15 @@ async function copyWindowsIconIfNeeded(convertedPath, appName) { try { const finalIconPath = generateIconPath(appName); await fsExtra.ensureDir(path.dirname(finalIconPath)); - // Reorder ICO to prioritize 256px icons for better Windows display - const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256); - if (!reordered) { - await fsExtra.copy(convertedPath, finalIconPath); + // Re-render ICO so every Windows standard size is present and prefer the + // 256px frame as the leading entry; falls back to plain reordering if the + // ICO is non-decodable, then to a raw copy. (Issue #1190) + const upgraded = await ensureMultiResolutionIco(convertedPath, finalIconPath, 256); + if (!upgraded) { + const reordered = await writeIcoWithPreferredSize(convertedPath, finalIconPath, 256); + if (!reordered) { + await fsExtra.copy(convertedPath, finalIconPath); + } } return finalIconPath; } @@ -2153,6 +2275,28 @@ function normalizeUrl(urlToNormalize) { } } +/** + * Error class used for user-facing CLI errors. + * + * The top-level catch in `bin/cli.ts` prints `message` directly without a + * stack trace and exits with code 1. Use this for predictable failures + * (invalid names, missing files, etc.) so users see a clean message instead + * of a Node.js stack dump. + */ +class PakeError extends Error { + constructor(message) { + super(message); + this.isUserError = true; + this.name = 'PakeError'; + } +} +function isPakeError(error) { + return (error instanceof PakeError || + (typeof error === 'object' && + error !== null && + error.isUserError === true)); +} + function resolveAppName(name, platform) { const domain = getDomain(name) || 'pake'; return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain; @@ -2195,13 +2339,13 @@ async function handleOptions(options, url) { 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) { + logger.error(errorMsg); name = resolveAppName(url, platform); logger.warn(`✼ Inside github actions, use the default name: ${name}`); } else { - process.exit(1); + throw new PakeError(errorMsg); } } const resolvedName = name || 'pake-app'; @@ -2459,21 +2603,46 @@ async function checkUpdateTips() { }); } program.action(async (url, options) => { - await checkUpdateTips(); - if (!url) { - program.help({ - error: false, - }); - return; + try { + await checkUpdateTips(); + if (!url) { + program.help({ + error: false, + }); + return; + } + log.setDefaultLevel('info'); + log.setLevel('info'); + if (options.debug) { + log.setLevel('debug'); + } + const appOptions = await handleOptions(options, url); + const builder = BuilderProvider.create(appOptions); + await builder.prepare(); + await builder.build(url); + } + catch (error) { + if (isPakeError(error)) { + console.error(chalk.red(error.message)); + } + else if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); + if (options?.debug && error.stack) { + console.error(chalk.gray(error.stack)); + } + } + else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); + } + process.exit(1); + } +}); +program.parseAsync().catch((error) => { + if (error instanceof Error) { + console.error(chalk.red(`✕ ${error.message}`)); } - log.setDefaultLevel('info'); - log.setLevel('info'); - if (options.debug) { - log.setLevel('debug'); + else { + console.error(chalk.red(`✕ Unexpected error: ${String(error)}`)); } - const appOptions = await handleOptions(options, url); - const builder = BuilderProvider.create(appOptions); - await builder.prepare(); - await builder.build(url); + process.exit(1); }); -program.parse(); diff --git a/rollup.config.js b/rollup.config.js index 8cc8cc3e30..d5fa2b75f5 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -49,7 +49,7 @@ export default { tsconfig: "./tsconfig.json", sourceMap: !isProduction, inlineSources: !isProduction, - noEmitOnError: false, + noEmitOnError: isProduction, compilerOptions: { target: "es2020", module: "esnext", @@ -77,7 +77,6 @@ function pakeCliDevPlugin() { let devHasStarted = false; - // 智能检测包管理器 const detectPackageManager = () => { if (fs.existsSync("pnpm-lock.yaml")) return "pnpm"; if (fs.existsSync("yarn.lock")) return "yarn"; diff --git a/tests/unit/__snapshots__/merge-window-options.test.ts.snap b/tests/unit/__snapshots__/merge-window-options.test.ts.snap new file mode 100644 index 0000000000..ca9f8e9da2 --- /dev/null +++ b/tests/unit/__snapshots__/merge-window-options.test.ts.snap @@ -0,0 +1,85 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`buildWindowConfigOverrides > matches the default snapshot on Linux 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": false, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; + +exports[`buildWindowConfigOverrides > matches the default snapshot on Windows 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": false, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; + +exports[`buildWindowConfigOverrides > matches the default snapshot on macOS 1`] = ` +{ + "activation_shortcut": "", + "always_on_top": false, + "dark_mode": false, + "disabled_web_shortcuts": false, + "enable_drag_drop": false, + "enable_wasm": false, + "force_internal_navigation": false, + "fullscreen": false, + "height": 780, + "hide_on_close": true, + "hide_title_bar": false, + "ignore_certificate_errors": false, + "incognito": false, + "internal_url_regex": "", + "maximize": false, + "min_height": 0, + "min_width": 0, + "new_window": false, + "resizable": true, + "start_to_tray": false, + "title": undefined, + "width": 1200, + "zoom": 100, +} +`; diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 6f70a26bee..bf9742fa13 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -9,6 +9,11 @@ vi.mock('@/utils/dir', () => ({ })); import BaseBuilder from '@/builders/BaseBuilder'; +import { + configureCargoRegistry, + getBuildEnvironment, + getInstallCommand, +} from '@/builders/env'; import logger from '@/options/logger'; import { CN_MIRROR_ENV, isCnMirrorEnabled } from '@/utils/mirror'; @@ -65,16 +70,15 @@ describe('BaseBuilder guards', () => { }); it('prepends /usr/bin to PATH for macOS build environment', () => { - const builder = new TestBuilder({} as any); const originalPath = process.env.PATH; process.env.PATH = '/opt/homebrew/bin:/usr/local/bin'; try { - const env = (builder as any).getBuildEnvironment(); + const env = getBuildEnvironment(); if (process.platform === 'darwin') { expect(env).toBeDefined(); - expect(env.PATH.startsWith('/usr/bin:')).toBe(true); + expect(env!.PATH.startsWith('/usr/bin:')).toBe(true); } else { expect(env).toBeUndefined(); } @@ -83,38 +87,25 @@ describe('BaseBuilder guards', () => { } }); - it('skips copy when source and destination are the same path', async () => { - const builder = new TestBuilder({} as any); - const copySpy = vi - .spyOn(fsExtra, 'copy') - .mockResolvedValue(undefined as any); - - await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/same', '/tmp/same'), - ).resolves.toBeUndefined(); - expect(copySpy).not.toHaveBeenCalled(); - }); - - it('suppresses same-path fs-extra copy errors', async () => { - const builder = new TestBuilder({} as any); - vi.spyOn(fsExtra, 'copy').mockRejectedValue( - new Error('Source and destination must not be the same.'), + it('skips Cargo registry copy when source and destination resolve to the same path', async () => { + // configureCargoRegistry uses a same-path guard internally; if the + // CN-mirror file and the project config end up identical we should not + // crash with "source and destination must not be the same". + const tempDir = await fsExtra.mkdtemp( + path.join(os.tmpdir(), 'pake-base-builder-same-'), ); + tempDirs.push(tempDir); + const tauriSrcPath = path.join(tempDir, 'src-tauri'); + const projectCnConf = path.join(tauriSrcPath, 'rust_proxy.toml'); + const projectConf = path.join(tauriSrcPath, '.cargo', 'config.toml'); + await fsExtra.outputFile(projectCnConf, GENERATED_MIRROR_CONFIG); + await fsExtra.outputFile(projectConf, GENERATED_MIRROR_CONFIG); await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/a', '/tmp/b'), + configureCargoRegistry(tauriSrcPath, true), ).resolves.toBeUndefined(); }); - it('rethrows non-same-path copy errors', async () => { - const builder = new TestBuilder({} as any); - vi.spyOn(fsExtra, 'copy').mockRejectedValue(new Error('permission denied')); - - await expect( - (builder as any).copyFileWithSamePathGuard('/tmp/a', '/tmp/b'), - ).rejects.toThrow('permission denied'); - }); - it('does not enable CN mirror by default', () => { delete process.env[CN_MIRROR_ENV]; @@ -133,16 +124,14 @@ describe('BaseBuilder guards', () => { ); it('uses official npm registry by default', () => { - const builder = new TestBuilder({} as any); - const command = (builder as any).getInstallCommand('pnpm', false); + const command = getInstallCommand('pnpm', false); expect(command).toContain('pnpm install'); expect(command).not.toContain('registry.npmmirror.com'); }); it('uses npmmirror only when CN mirror is enabled', () => { - const builder = new TestBuilder({} as any); - const command = (builder as any).getInstallCommand('npm', true); + const command = getInstallCommand('npm', true); expect(command).toContain( 'npm install --registry=https://registry.npmmirror.com --legacy-peer-deps', @@ -150,11 +139,10 @@ describe('BaseBuilder guards', () => { }); it('copies Cargo mirror config only when CN mirror is enabled', async () => { - const builder = new TestBuilder({} as any); const { tauriSrcPath, projectConf, projectCnConf } = await createCargoFixture(); - await (builder as any).configureCargoRegistry(tauriSrcPath, true); + await configureCargoRegistry(tauriSrcPath, true); expect(await fsExtra.readFile(projectConf, 'utf8')).toBe( await fsExtra.readFile(projectCnConf, 'utf8'), @@ -162,18 +150,16 @@ describe('BaseBuilder guards', () => { }); it('removes generated Cargo mirror config when CN mirror is disabled', async () => { - const builder = new TestBuilder({} as any); const { tauriSrcPath, projectConf } = await createCargoFixture( GENERATED_MIRROR_CONFIG, ); - await (builder as any).configureCargoRegistry(tauriSrcPath, false); + await configureCargoRegistry(tauriSrcPath, false); expect(await fsExtra.pathExists(projectConf)).toBe(false); }); it('keeps custom Cargo config when CN mirror is disabled', async () => { - const builder = new TestBuilder({} as any); const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); const customConfig = `${GENERATED_MIRROR_CONFIG} # custom user setting @@ -181,11 +167,19 @@ describe('BaseBuilder guards', () => { const { tauriSrcPath, projectConf } = await createCargoFixture(customConfig); - await (builder as any).configureCargoRegistry(tauriSrcPath, false); + await configureCargoRegistry(tauriSrcPath, false); expect(await fsExtra.readFile(projectConf, 'utf8')).toBe(customConfig); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('still references rsproxy.cn'), ); }); + + it('keeps the BaseBuilder hierarchy intact', () => { + // Sanity check that subclasses can still construct against the slimmer + // BaseBuilder after env helpers were extracted. + const builder = new TestBuilder({} as any); + expect(builder).toBeInstanceOf(BaseBuilder); + expect(builder.getFileName()).toBe('test-app'); + }); }); diff --git a/tests/unit/ico.test.ts b/tests/unit/ico.test.ts index 318f08315a..c473c415cd 100644 --- a/tests/unit/ico.test.ts +++ b/tests/unit/ico.test.ts @@ -2,8 +2,14 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; +import sharp from 'sharp'; -import { buildIcoFromPngBuffers, writeIcoWithPreferredSize } from '@/utils/ico'; +import { + buildIcoFromPngBuffers, + ensureMultiResolutionIco, + writeIcoWithPreferredSize, + WIN_STANDARD_ICO_SIZES, +} from '@/utils/ico'; const ICO_HEADER_SIZE = 6; const ICO_DIR_ENTRY_SIZE = 16; @@ -166,3 +172,90 @@ describe('writeIcoWithPreferredSize', () => { expect(ok).toBe(false); }); }); + +describe('ensureMultiResolutionIco', () => { + async function realPng(size: number, fillColor = 'red'): Promise { + return sharp({ + create: { + width: size, + height: size, + channels: 4, + background: fillColor, + }, + }) + .png() + .toBuffer(); + } + + it('expands a single-frame 256x256 ICO into every Windows standard size (#1190)', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const big = await realPng(256, 'blue'); + fs.writeFileSync(source, buildIcoFromPngBuffers([{ size: 256, png: big }])); + + const ok = await ensureMultiResolutionIco(source, output); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + const sizes = entries.map((entry) => Math.max(entry.width, entry.height)); + for (const expected of WIN_STANDARD_ICO_SIZES) { + expect(sizes).toContain(expected); + } + }); + + it('places the preferred size as the first directory entry', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const big = await realPng(256, 'green'); + fs.writeFileSync(source, buildIcoFromPngBuffers([{ size: 256, png: big }])); + + const ok = await ensureMultiResolutionIco(source, output, 32); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + expect(entries[0].width).toBe(32); + expect(entries[0].height).toBe(32); + }); + + it('preserves any exact-size PNG frame already in the ICO', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'source.ico'); + const output = path.join(dir, 'multi.ico'); + + const tiny = await realPng(16, 'magenta'); + const big = await realPng(256, 'cyan'); + fs.writeFileSync( + source, + buildIcoFromPngBuffers([ + { size: 16, png: tiny }, + { size: 256, png: big }, + ]), + ); + + const ok = await ensureMultiResolutionIco(source, output); + expect(ok).toBe(true); + + const entries = parseIcoHeader(fs.readFileSync(output)); + const sixteen = entries.find( + (entry) => entry.width === 16 && entry.height === 16, + ); + expect(sixteen).toBeDefined(); + expect(sixteen!.data.equals(tiny)).toBe(true); + }); + + it('falls back gracefully when the source ICO is malformed', async () => { + const dir = makeTempDir(); + const source = path.join(dir, 'bad.ico'); + fs.writeFileSync(source, Buffer.from([0, 0])); + + const ok = await ensureMultiResolutionIco( + source, + path.join(dir, 'out.ico'), + ); + expect(ok).toBe(false); + }); +}); diff --git a/tests/unit/merge-window-options.test.ts b/tests/unit/merge-window-options.test.ts new file mode 100644 index 0000000000..2e2f98cdba --- /dev/null +++ b/tests/unit/merge-window-options.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { buildWindowConfigOverrides } from '../../bin/helpers/merge'; +import { DEFAULT_PAKE_OPTIONS } from '../../bin/defaults'; +import type { PakeAppOptions } from '../../bin/types'; + +function makeOptions(overrides: Partial = {}): PakeAppOptions { + return { + ...DEFAULT_PAKE_OPTIONS, + identifier: 'com.pake.test', + ...overrides, + }; +} + +describe('buildWindowConfigOverrides', () => { + it('matches the default snapshot on macOS', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'darwin'); + expect(result).toMatchSnapshot(); + }); + + it('matches the default snapshot on Windows', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'win32'); + expect(result).toMatchSnapshot(); + }); + + it('matches the default snapshot on Linux', () => { + const result = buildWindowConfigOverrides(makeOptions(), 'linux'); + expect(result).toMatchSnapshot(); + }); + + it('respects explicit hideOnClose=false on macOS', () => { + const result = buildWindowConfigOverrides( + makeOptions({ hideOnClose: false }), + 'darwin', + ); + expect(result.hide_on_close).toBe(false); + }); + + it('defaults hideOnClose to true on macOS when undefined', () => { + const result = buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'darwin', + ); + expect(result.hide_on_close).toBe(true); + }); + + it('defaults hideOnClose to false on Linux/Windows when undefined', () => { + expect( + buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'linux', + ).hide_on_close, + ).toBe(false); + expect( + buildWindowConfigOverrides( + makeOptions({ hideOnClose: undefined }), + 'win32', + ).hide_on_close, + ).toBe(false); + }); + + it('only enables start_to_tray when both flag and tray are on', () => { + expect( + buildWindowConfigOverrides( + makeOptions({ startToTray: true, showSystemTray: false }), + 'darwin', + ).start_to_tray, + ).toBe(false); + expect( + buildWindowConfigOverrides( + makeOptions({ startToTray: true, showSystemTray: true }), + 'darwin', + ).start_to_tray, + ).toBe(true); + }); + + it('forwards window/zoom/wasm/new_window flags verbatim', () => { + const result = buildWindowConfigOverrides( + makeOptions({ + width: 1400, + height: 900, + zoom: 120, + minWidth: 800, + minHeight: 600, + wasm: true, + enableDragDrop: true, + ignoreCertificateErrors: true, + newWindow: true, + forceInternalNavigation: true, + internalUrlRegex: '^https://example\\.com', + }), + 'darwin', + ); + expect(result).toMatchObject({ + width: 1400, + height: 900, + zoom: 120, + min_width: 800, + min_height: 600, + enable_wasm: true, + enable_drag_drop: true, + ignore_certificate_errors: true, + new_window: true, + force_internal_navigation: true, + internal_url_regex: '^https://example\\.com', + }); + }); +}); From 6487c74c34c29ca556e9cb3404c3340bddede82d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Tue, 5 May 2026 17:07:40 +0800 Subject: [PATCH 015/120] chore: bump version to 3.11.5 Co-authored-by: Cursor --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index 82e1a1c3ab..85e4ab97eb 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.4"; +var version = "3.11.5"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index 9e60160919..4fbdb8c169 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.4", + "version": "3.11.5", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 309fbd17d0..21acfb6ba3 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.4" +version = "3.11.5" dependencies = [ "serde", "serde_json", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ebec02dc0..8988c1440d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.4" +version = "3.11.5" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 65c4b6b06e..8d399afec9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.4", + "version": "3.11.5", "app": { "withGlobalTauri": true, "trayIcon": { From 6572dfacff42f646849b2e1940694db540c6e443 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 7 May 2026 23:17:40 +0800 Subject: [PATCH 016/120] chore: clean up local-only docs --- AGENTS.md | 175 ------------------------------------------------------ CLAUDE.md | 6 -- 2 files changed, 181 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index d84458f1bb..0000000000 --- a/AGENTS.md +++ /dev/null @@ -1,175 +0,0 @@ -# AGENTS.md - Pake Project Knowledge Base - -## Project Identity - -**Pake** - Turn any webpage into a lightweight desktop app with one command. - -- **Purpose**: Package any website into a ~5MB desktop app (20x smaller than Electron) -- **Stack**: Tauri v2 (Rust) + TypeScript CLI -- **Platforms**: macOS, Windows, Linux -- **Mechanism**: Uses system webview (WebKit on macOS/Linux, WebView2 on Windows) - -## Repository Structure - -``` -Pake/ -├── bin/ # CLI source code (TypeScript) -│ └── cli.ts # Main CLI entry (Commander.js) -├── src-tauri/ # Tauri Rust application -│ ├── src/ # Rust source code -│ ├── src/app/ # window creation, setup, menu, config, and invokes -│ ├── src/inject/ # injected JS/CSS behavior -│ ├── Cargo.toml # Rust dependencies and version -│ ├── tauri.conf.json # Tauri configuration and version -│ └── .cargo/ # Cargo configuration (gitignored) -├── dist/ # Compiled CLI output -├── docs/ # Documentation -│ ├── cli-usage.md # CLI parameters -│ ├── advanced-usage.md # Customization guide -│ └── faq.md # Troubleshooting -├── scripts/ # Utility scripts -├── tests/ # Unit, integration, and release-flow tests -├── .github/workflows/ # quality/test and release automation -├── default_app_list.json # Popular apps config for release builds -├── package.json # Node.js dependencies and version -└── rollup.config.js # CLI build configuration -``` - -## Development Commands - -| Command | Purpose | -| ------------------------------------ | --------------------------------------------------------------- | -| `pnpm install` | Install dependencies | -| `pnpm run dev` | Tauri development mode | -| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | -| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | -| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | -| `pnpm run build` | Build for current platform | -| `pnpm run build:mac` | macOS universal binary | -| `pnpm run format` | Format code (prettier + cargo fmt) | -| `npx vitest run` | Unit and integration tests only (sub-second) | -| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | -| `pnpm test` | Full suite including release workflow | - -Keep shared project facts in this file so Codex, Claude Code, and other agents use the same public source of truth. Tool-specific local skills or overrides must remain optional and ignored. - -## Task Intake And Investigation - -Prefer requests with: - -- `Goal`: exact bug, feature, refactor, or review target -- `Scope`: files, directories, or subsystem boundaries to inspect first -- `Repro`: command, input, fixture, or failing test -- `Expected`: expected behavior -- `Actual`: current behavior, error text, or regression note -- `Constraints`: what must not change -- `Verify`: minimum command or test that proves the result - -When task scope is incomplete, inspect in this order: - -1. CLI entry and option parsing under `bin/cli.ts`, `bin/options/`, and `bin/helpers/` -2. Target TypeScript module under `bin/` -3. Tauri runtime or packaging files under `src-tauri/src/` and `src-tauri/tauri*.conf.json` -4. Narrow tests under `tests/unit/` or `tests/integration/` -5. Release workflow files under `.github/workflows/` only for CI or release issues -6. Docs only if behavior, ownership, or expected usage is still unclear - -Execution rules: - -- Start with the smallest plausible file set -- Prefer targeted search (`rg `) over repository-wide scans -- Ignore generated or output-heavy areas unless the task directly targets them, especially `dist/`, `node_modules/`, `src-tauri/target/`, `.app/`, `src-tauri/icons/`, and `src-tauri/png/`. Exception: `dist/cli.js` is the shipped CLI build artifact (see `package.json` `files`); when you change anything under `bin/`, rebuild it via `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change -- Keep changes local to one subsystem when possible -- Run the narrowest relevant verification first, expand only if needed -- If key context is missing, make one reasonable assumption and proceed - -## Current Risk Areas - -- CLI options are user-facing and must stay synchronized across `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`, generated `dist/cli.js`, and `docs/cli-usage*.md`. -- Recent window/runtime options include `--incognito`, `--new-window`, `--min-width`, `--min-height`, `--maximize`, multi-window behavior, notification click handling, and Linux/Wayland WebKit compositing defaults. -- `--incognito` intentionally trades persistence for clean private sessions; be careful around login, cookies, local storage, and WeChat-style WebView detection. -- `--new-window` and `--multi-window` do not bypass every provider policy. Google OAuth and similar embedded-WebView restrictions may still require a normal browser or native client. -- Notification flows cross injected JS, Tauri invokes, capabilities, and native notification plugins. Verify the Rust capability and JS caller together. -- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Do not change defaults without testing the affected platform path or documenting the risk. - -## Code Quality Standards - -- Chinese comments are forbidden. - -## Branch Strategy - -- `main` - Only branch. All development and releases happen here directly. - -## Version Management - -Three files must be updated in sync for every release: - -| File | Field | -| --------------------------- | --------------------------- | -| `package.json` | `"version"` | -| `src-tauri/Cargo.toml` | `version` under `[package]` | -| `src-tauri/tauri.conf.json` | `"version"` | - -Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. - -## Release Workflow (CI) - -Pushing a `V*` tag triggers `.github/workflows/release.yml`: - -1. **release-apps** - reads `default_app_list.json` for app list -2. **create-release** - creates the GitHub Release placeholder -3. **build-cli** - builds and uploads the `dist/` CLI artifact -4. **build-popular-apps** - builds all apps in parallel across macOS/Windows/Linux -5. **publish-docker** - builds and pushes Docker image to GHCR - -The workflow can also be triggered manually via `workflow_dispatch` with options to build popular apps or publish Docker independently. - -After tagging, npm publish is done manually: `npm publish`. - -`.github/workflows/quality-and-test.yml` runs auto-format on push, Rust quality checks, and CLI/build validation across Linux, Windows, and macOS. - -### Network Mirror Behavior - -Pake uses official npm and Rust sources by default. CN mirrors are explicit opt-in only: - -- Set `PAKE_USE_CN_MIRROR=1` only when the user or CI environment intentionally wants npmmirror/rsProxy. -- Do not reintroduce automatic China-domain mirror switching. -- If an install fails against a CN mirror, retry the same install command to separate network availability from a product regression. -- `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts` own this behavior; keep docs and tests aligned when changing it. - -## CLI Usage Example - -```bash -# Install CLI -pnpm install -g pake-cli - -# Basic usage -pake https://github.com --name GitHub - -# Advanced usage -pake https://weekly.tw93.fun --name Weekly --width 1200 --height 800 -``` - -## Troubleshooting - -See `docs/faq.md` for common issues and solutions. - -### macOS SDK / Compile Errors - -If compilation errors occur (e.g. on macOS beta), create `src-tauri/.cargo/config.toml`: - -```toml -[env] -MACOSX_DEPLOYMENT_TARGET = "15.0" -SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" -``` - -This file is already in `.gitignore`. - -### `dist/cli.js` out of sync with `bin/` - -Symptom: tests or release builds use stale CLI behavior after a `bin/` edit. Fix with `pnpm run cli:build` and commit the regenerated `dist/cli.js`. - -### First Tauri build is slow - -The first `cargo build` on a fresh clone takes 10+ minutes as Cargo compiles every Tauri dependency from source. Subsequent builds reuse the `src-tauri/target/` cache. This is expected, not a bug. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index f326ea10c0..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,6 +0,0 @@ -@AGENTS.md - -## Claude Code - -- Treat `AGENTS.md` as the shared source of truth for this repository. -- Keep personal notes and machine-specific workflow in `CLAUDE.local.md`. From 774716dfbc0d34d0d098bb77d470116c305df108 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 7 May 2026 23:18:46 +0800 Subject: [PATCH 017/120] chore: clean up local-only docs --- .claude/skills/code-review/SKILL.md | 48 ------------- .claude/skills/github-ops/SKILL.md | 100 ---------------------------- .claude/skills/release/SKILL.md | 69 ------------------- .gitignore | 6 +- 4 files changed, 4 insertions(+), 219 deletions(-) delete mode 100644 .claude/skills/code-review/SKILL.md delete mode 100644 .claude/skills/github-ops/SKILL.md delete mode 100644 .claude/skills/release/SKILL.md diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md deleted file mode 100644 index c2f9491b35..0000000000 --- a/.claude/skills/code-review/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: code-review -description: Pake project adapter for Waza check/code-review. Use for TypeScript CLI, Rust/Tauri, release artifact, and CI review. -version: 1.1.0 -allowed-tools: - - Bash - - Read - - Grep - - Glob -disable-model-invocation: true ---- - -# Pake Code Review Adapter - -Use Waza `/check` for the generic review method. This adapter adds Pake-specific commands, hard stops, and artifact rules. - -## Pake-Specific Hard Stops - -- [ ] Changes under `bin/` rebuild and commit `dist/cli.js` with `pnpm run cli:build`. -- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` in sync. -- [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. -- [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. -- [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. -- [ ] New helper in `bin/utils/` or `bin/helpers/` has a matching `tests/unit/.test.ts`. -- [ ] Binary parsers have a round-trip test, not only builder assertions. - -## Quick Review Commands - -```bash -# Get PR diff -gh pr diff - -# Format check -pnpm run format:check - -# Run unit tests (fast, sub-second) -npx vitest run - -# Full suite without the slow real build -pnpm test -- --no-build - -# Build CLI and catch TypeScript errors -pnpm run cli:build -``` - -## Review Output Format - -Follow Waza `/check`: findings first, ordered by severity, with tight file/line references. Keep summaries brief. diff --git a/.claude/skills/github-ops/SKILL.md b/.claude/skills/github-ops/SKILL.md deleted file mode 100644 index 0f61037158..0000000000 --- a/.claude/skills/github-ops/SKILL.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -name: github-ops -description: GitHub issue, PR, and release operations via gh CLI. Not for code review or release builds. -version: 1.0.0 -allowed-tools: - - Bash - - Read ---- - -# GitHub Operations Skill - -Use this skill when working with GitHub issues, PRs, and releases for Pake. - -## Golden Rule - -**ALWAYS use `gh` CLI** for GitHub operations. Never use the web UI or make assumptions about state — always query first. - -## Issue Operations - -```bash -# View a specific issue -gh issue view 123 - -# List open issues -gh issue list --state open - -# List issues with a label -gh issue list --label bug - -# Add a comment (only with explicit user request) -gh issue comment 123 --body "..." - -# Close an issue -gh issue close 123 -``` - -## PR Operations - -```bash -# List open PRs -gh pr list - -# View a PR -gh pr view 456 - -# Check PR status and CI checks -gh pr checks 456 - -# View PR diff -gh pr diff 456 - -# Read inline review comments on a PR -gh api repos/tw93/Pake/pulls/456/comments - -# Merge a PR (only with explicit user request) -gh pr merge 456 --squash - -# Create a PR -gh pr create --title "..." --body "..." -``` - -## Release Operations - -```bash -# List releases -gh release list - -# View a specific release -gh release view V3.10.0 - -# Check CI runs for a tag -gh run list --workflow=release.yml - -# Watch a running CI job -gh run watch - -# View CI run logs -gh run view --log -``` - -## CI / Workflow Operations - -```bash -# List recent workflow runs -gh run list - -# Filter by workflow -gh run list --workflow=release.yml -gh run list --workflow=quality-and-test.yml - -# Re-run failed jobs -gh run rerun --failed-only -``` - -## Safety Rules - -1. **ALWAYS** draft the reply first and show it to the user for approval before calling any write operation (`gh issue comment`, `gh pr comment`, `gh pr merge`, `gh issue close`, `gh release create`, etc.). Approval of one draft does not extend to future comments. -2. **NEVER** merge, close, or modify without explicit user request. -3. **ALWAYS** query current state before taking action — never assume. -4. Before replying to an issue or PR, read the body to confirm the author's language; match their language in the reply. This applies to the author, not to arbitrary thread commenters. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md deleted file mode 100644 index b184b3ad33..0000000000 --- a/.claude/skills/release/SKILL.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -name: release -description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. -version: 1.0.0 -allowed-tools: - - Bash - - Read - - Grep - - Glob -disable-model-invocation: true ---- - -# Release Skill - -Use this skill when preparing or executing a Pake release. - -## Version Files - -Three files must be updated in sync — never update one without the others: - -- `package.json` → `"version"` -- `src-tauri/Cargo.toml` → `version` under `[package]` -- `src-tauri/tauri.conf.json` → `"version"` - -## Release Checklist - -### Pre-Release - -1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) -2. [ ] Update all three version files above -3. [ ] Run `pnpm run format` — must pass cleanly -4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. -5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). -6. [ ] No uncommitted changes: `git status` -7. [ ] Commit version bump with message: `chore: bump version to VX.X.X` - -### Tagging (triggers CI) - -```bash -git tag -a VX.X.X -m "Release VX.X.X" -git push origin VX.X.X -``` - -Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. - -### Post-Tag Verification - -1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` -2. [ ] Watch CI status: `gh run watch` -3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` -4. [ ] Publish to npm (manual): `npm publish` - -## Build Commands (local only) - -```bash -# Current platform -pnpm build - -# macOS universal binary -pnpm build:mac -``` - -Cross-platform builds (Windows/Linux) are handled by CI, not locally. - -## Safety Rules - -1. **NEVER** auto-commit or auto-push without explicit user request -2. **NEVER** tag before all checks pass -3. **ALWAYS** verify the three version files are in sync before tagging diff --git a/.gitignore b/.gitignore index f3ef36fd54..28597bc3f7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,12 +21,14 @@ *.sw? *.tmp # Local AI assistant docs -AGENTS.override.md +CLAUDE.md CLAUDE.local.md +AGENTS.md +AGENTS.override.md +.claude/ .agents/settings.local.json # Editor directories and files # Logs -.claude/settings.local.json AGENT.md dist dist-ssr From e4b1670e34d9654a6370851a206ec07d8a90b90c Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea <203157049+AllDaGearNoIdea@users.noreply.github.com> Date: Thu, 7 May 2026 14:09:27 +0100 Subject: [PATCH 018/120] feat: macOS dock badge with Web Badging API support --- src-tauri/Cargo.lock | 3 + src-tauri/Cargo.toml | 5 ++ src-tauri/src/app/invoke.rs | 61 ++++++++++++++++++++ src-tauri/src/inject/event.js | 105 +++++++++++++++++++++++++++++++--- src-tauri/src/lib.rs | 7 ++- 5 files changed, 171 insertions(+), 10 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 21acfb6ba3..b5b8a0a4c2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2566,6 +2566,9 @@ dependencies = [ name = "pake" version = "3.11.5" dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", "serde", "serde_json", "tauri", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8988c1440d..a09125c896 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,11 @@ tauri-plugin-opener = { version = "2.5.3" } tauri-plugin-single-instance = "2.4.0" tauri-plugin-notification = "2.3.3" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSDockTile"] } +objc2-foundation = { version = "0.3", features = ["NSString"] } + [features] # this feature is used for development builds from development cli cli-build = [] diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index b74d66247a..28d8b3f1af 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -2,6 +2,7 @@ use crate::util::{check_file_or_append, get_download_message_with_lang, show_toa use std::fs::{self, File}; use std::io::Write; use std::str::FromStr; +use std::sync::atomic::{AtomicI64, Ordering}; use tauri::http::Method; use tauri::{command, AppHandle, Manager, Url, WebviewWindow}; use tauri_plugin_http::reqwest::{ClientBuilder, Request}; @@ -9,6 +10,42 @@ use tauri_plugin_http::reqwest::{ClientBuilder, Request}; #[cfg(target_os = "macos")] use tauri::Theme; +static BADGE_COUNT: AtomicI64 = AtomicI64::new(0); + +fn apply_badge(app: &AppHandle, count: Option) -> Result<(), String> { + let label = count.filter(|n| *n > 0).map(|n| n.to_string()); + apply_badge_label(app, label.as_deref()) +} + +#[cfg(target_os = "macos")] +fn apply_badge_label(app: &AppHandle, label: Option<&str>) -> Result<(), String> { + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + use objc2_foundation::NSString; + + let label = label.map(str::to_owned); + app.run_on_main_thread(move || { + let Some(mtm) = MainThreadMarker::new() else { + return; + }; + let dock_tile = NSApplication::sharedApplication(mtm).dockTile(); + let ns_label = label.as_deref().map(NSString::from_str); + dock_tile.setBadgeLabel(ns_label.as_deref()); + }) + .map_err(|e| format!("Failed to dispatch dock badge update: {e}")) +} + +#[cfg(not(target_os = "macos"))] +fn apply_badge_label(app: &AppHandle, label: Option<&str>) -> Result<(), String> { + let window = app + .get_webview_window("pake") + .ok_or("Main window not found")?; + let count = label.and_then(|s| s.parse::().ok()); + window + .set_badge_count(count) + .map_err(|e| format!("Failed to set badge count: {e}")) +} + #[derive(serde::Deserialize)] pub struct DownloadFileParams { url: String, @@ -141,9 +178,33 @@ pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<( .icon(¶ms.icon) .show() .map_err(|e| format!("Failed to show notification: {}", e))?; + + let next = BADGE_COUNT.fetch_add(1, Ordering::SeqCst) + 1; + if let Err(error) = apply_badge(&app, Some(next)) { + eprintln!("[Pake] Failed to update dock badge: {error}"); + } Ok(()) } +#[command] +pub fn set_dock_badge(app: AppHandle, count: Option) -> Result<(), String> { + let normalized = count.filter(|n| *n > 0); + BADGE_COUNT.store(normalized.unwrap_or(0), Ordering::SeqCst); + apply_badge(&app, normalized) +} + +#[command] +pub fn clear_dock_badge(app: AppHandle) -> Result<(), String> { + BADGE_COUNT.store(0, Ordering::SeqCst); + apply_badge(&app, None) +} + +#[command] +pub fn set_dock_badge_label(app: AppHandle, label: Option) -> Result<(), String> { + BADGE_COUNT.store(0, Ordering::SeqCst); + apply_badge_label(&app, label.as_deref().filter(|s| !s.is_empty())) +} + #[command] pub async fn update_theme_mode(app: AppHandle, mode: String) { #[cfg(target_os = "macos")] diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 870c959212..bd5bf88a71 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -1025,10 +1025,32 @@ document.addEventListener("DOMContentLoaded", () => { }); }); -document.addEventListener("DOMContentLoaded", function () { +// Bridge the Web Notification + Web Badging APIs to Pake's Rust commands so +// pages running inside the webview can drive the macOS dock badge (and +// taskbar badge on Linux/Windows). Installs synchronously instead of waiting +// for DOMContentLoaded so feature-detection on Notification/setAppBadge +// returns the polyfill before site scripts run. +(function () { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) return; + let permVal = "granted"; let lastNotifTime = 0; let lastNotif = null; + // Pages that drive the badge directly via setAppBadge own its lifecycle; + // notifications-driven counts auto-clear on the next user interaction. + let pageManagedBadge = false; + let autoBadgeActive = false; + + const setBadge = (count) => { + pageManagedBadge = true; + return invoke("set_dock_badge", { count }).catch(() => {}); + }; + const clearBadge = () => invoke("clear_dock_badge").catch(() => {}); + const setLabel = (label) => { + pageManagedBadge = true; + return invoke("set_dock_badge_label", { label }).catch(() => {}); + }; window.addEventListener("focus", () => { if (lastNotif?.onclick && Date.now() - lastNotifTime < 5000) { @@ -1037,11 +1059,17 @@ document.addEventListener("DOMContentLoaded", function () { } }); - window.Notification = function (title, options) { - const { invoke } = window.__TAURI__.core; + const clearAutoBadge = () => { + if (pageManagedBadge || !autoBadgeActive) return; + autoBadgeActive = false; + clearBadge(); + }; + document.addEventListener("click", clearAutoBadge, true); + document.addEventListener("keydown", clearAutoBadge, true); + + const wrappedNotification = function (title, options) { const body = options?.body || ""; let icon = options?.icon || ""; - if (icon.startsWith("/")) { icon = window.location.origin + icon; } @@ -1056,6 +1084,7 @@ document.addEventListener("DOMContentLoaded", function () { lastNotifTime = Date.now(); lastNotif = notif; + autoBadgeActive = true; invoke("send_notification", { params: { title, body, icon } }).then(() => { if (notif.onshow) notif.onshow(new Event("show")); @@ -1064,16 +1093,76 @@ document.addEventListener("DOMContentLoaded", function () { return notif; }; - window.Notification.requestPermission = async () => "granted"; - - Object.defineProperty(window.Notification, "permission", { + wrappedNotification.requestPermission = async () => "granted"; + Object.defineProperty(wrappedNotification, "permission", { enumerable: true, get: () => permVal, set: (v) => { permVal = v; }, }); -}); + + try { + Object.defineProperty(window, "Notification", { + configurable: true, + writable: true, + value: wrappedNotification, + }); + } catch (_) {} + + // Web Badging API: https://wicg.github.io/badging/ + // setAppBadge() with no argument shows an indicator dot; with a number, + // shows the count (0 clears). clearAppBadge() removes the badge entirely. + const setAppBadge = (count) => { + if (count === undefined) return setLabel("•"); + const n = typeof count === "number" && count > 0 ? Math.floor(count) : null; + return n === null ? clearBadge() : setBadge(n); + }; + const clearAppBadge = () => { + pageManagedBadge = false; + autoBadgeActive = false; + return clearBadge(); + }; + try { + Object.defineProperty(navigator, "setAppBadge", { + configurable: true, + writable: true, + value: setAppBadge, + }); + Object.defineProperty(navigator, "clearAppBadge", { + configurable: true, + writable: true, + value: clearAppBadge, + }); + } catch (_) {} + + // Service worker notifications: forward to the same Rust command so badge + // bookkeeping stays consistent. Fall through to the original implementation + // (if any) so push subscriptions still work. + if (typeof ServiceWorkerRegistration !== "undefined") { + try { + const orig = ServiceWorkerRegistration.prototype.showNotification; + ServiceWorkerRegistration.prototype.showNotification = function ( + title, + options, + ) { + const body = options?.body || ""; + let icon = options?.icon || ""; + if (icon.startsWith("/")) icon = window.location.origin + icon; + autoBadgeActive = true; + invoke("send_notification", { params: { title, body, icon } }).catch( + () => {}, + ); + if (orig) { + try { + return orig.call(this, title, options); + } catch (_) {} + } + return Promise.resolve(); + }; + } catch (_) {} + } +})(); function setDefaultZoom() { const htmlZoom = window.localStorage.getItem("htmlZoom"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4272cb3d2..6fec4c0ecd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,8 +13,8 @@ const WINDOW_SHOW_DELAY: u64 = 50; use app::{ invoke::{ - clear_cache_and_restart, download_file, download_file_by_binary, send_notification, - update_theme_mode, + clear_cache_and_restart, clear_dock_badge, download_file, download_file_by_binary, + send_notification, set_dock_badge, set_dock_badge_label, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, @@ -81,6 +81,9 @@ pub fn run_app() { download_file, download_file_by_binary, send_notification, + set_dock_badge, + set_dock_badge_label, + clear_dock_badge, update_theme_mode, clear_cache_and_restart, ]) From d3c2ec3b8410b49606a80cbc775de0d245d568eb Mon Sep 17 00:00:00 2001 From: AllDaGearNoIdea <203157049+AllDaGearNoIdea@users.noreply.github.com> Date: Thu, 7 May 2026 16:33:32 +0100 Subject: [PATCH 019/120] fix: macOS - surface native Move & Resize options Register the Window submenu as the macOS windows menu via set_as_windows_menu_for_nsapp(), surfacing native Move & Resize, Fill, Center, and Full Screen Tile options plus system-wide shortcuts. --- src-tauri/Cargo.lock | 6 +++--- src-tauri/src/app/menu.rs | 14 +++++++++++--- src-tauri/src/lib.rs | 3 +-- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 21acfb6ba3..717bbe8c4f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2194,9 +2194,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" dependencies = [ "crossbeam-channel", "dpi", @@ -3066,7 +3066,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/src-tauri/src/app/menu.rs b/src-tauri/src/app/menu.rs index cc6f498aeb..704d35a122 100644 --- a/src-tauri/src/app/menu.rs +++ b/src-tauri/src/app/menu.rs @@ -6,10 +6,12 @@ use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, Wry}; use tauri_plugin_opener::OpenerExt; -pub fn get_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result> { +pub fn set_app_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result<()> { let pake_version = env!("CARGO_PKG_VERSION"); let pake_menu_item_title = format!("Built with Pake V{}", pake_version); + let window_submenu = window_menu(app)?; + let menu = Menu::with_items( app, &[ @@ -18,12 +20,18 @@ pub fn get_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result &edit_menu(app)?, &view_menu(app)?, &navigation_menu(app)?, - &window_menu(app)?, + &window_submenu, &help_menu(app, &pake_menu_item_title)?, ], )?; - Ok(menu) + app.set_menu(menu)?; + + // AppKit injects Move & Resize, Fill, Center, Full Screen Tile, and + // window-cycling once the submenu is registered as the windows menu. + window_submenu.set_as_windows_menu_for_nsapp()?; + + Ok(()) } fn app_menu(app: &AppHandle) -> tauri::Result> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4272cb3d2..4e35b75c89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -93,8 +93,7 @@ pub fn run_app() { // --- Menu Construction Start --- #[cfg(target_os = "macos")] { - let menu = app::menu::get_menu(app.app_handle(), multi_window)?; - app.set_menu(menu)?; + app::menu::set_app_menu(app.app_handle(), multi_window)?; // Event Handling for Custom Menu Item app.on_menu_event(move |app_handle, event| { From 35ffd54e5667f22cde2f878e2392b65a81a2104b Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 9 May 2026 21:58:24 +0800 Subject: [PATCH 020/120] fix: track generated Pake build config --- src-tauri/build.rs | 2 ++ tests/unit/base-builder.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index d860e1e6a7..70cec8cf82 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,5 @@ fn main() { + println!("cargo:rerun-if-changed=.pake/pake.json"); + println!("cargo:rerun-if-changed=.pake/tauri.conf.json"); tauri_build::build() } diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index bf9742fa13..ffb0a886ce 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -182,4 +182,28 @@ describe('BaseBuilder guards', () => { expect(builder).toBeInstanceOf(BaseBuilder); expect(builder.getFileName()).toBe('test-app'); }); + + it('builds with generated .pake config and cli-build feature', () => { + const builder = new TestBuilder({ + debug: false, + targets: 'deb', + } as any); + + const command = (builder as any).getBuildCommand('pnpm'); + + expect(command).toContain('src-tauri/.pake/tauri.conf.json'); + expect(command).toContain('--features cli-build'); + }); + + it('tracks generated Pake config files in the Cargo build script', async () => { + const buildScript = await fsExtra.readFile( + path.join(process.cwd(), 'src-tauri', 'build.rs'), + 'utf8', + ); + + expect(buildScript).toContain('cargo:rerun-if-changed=.pake/pake.json'); + expect(buildScript).toContain( + 'cargo:rerun-if-changed=.pake/tauri.conf.json', + ); + }); }); From 3436979ca34a93750b5feb10ffab591bac0275ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 13:58:46 +0000 Subject: [PATCH 021/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 85 +++++++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 1edc907b84..db789ec6fe 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -30,7 +30,7 @@ - + jeasonnow @@ -107,7 +107,7 @@ - + YangguangZhou @@ -118,7 +118,7 @@ - + AielloChan @@ -162,7 +162,7 @@ - + GoodbyeNJN @@ -184,7 +184,7 @@ - + kittizz @@ -299,6 +299,17 @@ + + + + + + + + AllDaGearNoIdea + + + @@ -309,7 +320,7 @@ kidylee - + @@ -320,18 +331,18 @@ nekomeowww - + - + kuishou68 - + @@ -342,18 +353,18 @@ turkyden - + - + fvn-elmy - + @@ -364,7 +375,7 @@ Fechin - + @@ -375,7 +386,7 @@ ImgBotApp - + @@ -386,7 +397,7 @@ droid-Q - + @@ -397,7 +408,7 @@ JohannLai - + @@ -408,7 +419,7 @@ Jason6987 - + @@ -419,18 +430,18 @@ Milo123459 - + - + pgoslatara - + @@ -441,7 +452,7 @@ princemaple - + @@ -452,7 +463,7 @@ RoyRao2333 - + @@ -463,7 +474,7 @@ sebastianbreguel - + @@ -474,7 +485,7 @@ youxi798 - + @@ -485,7 +496,7 @@ fulldecent - + @@ -496,7 +507,7 @@ beautifulrem - + @@ -507,7 +518,7 @@ bocanhcam - + @@ -518,7 +529,7 @@ dbraendle - + @@ -529,7 +540,7 @@ geekvest - + @@ -540,18 +551,18 @@ lakca - + - + liudonghua123 - + @@ -562,7 +573,7 @@ liusishan - + @@ -573,18 +584,18 @@ piaoyidage - + - + enihsyou - + From ddb9faa80819d25debdd356f23e7072f93ec781d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 9 May 2026 22:06:52 +0800 Subject: [PATCH 022/120] fix: harden dock badge bridge --- src-tauri/src/app/invoke.rs | 40 ++++++++++++++---- src-tauri/src/inject/event.js | 65 ++++++++++++++--------------- src-tauri/src/lib.rs | 4 +- tests/unit/event-link-guard.test.js | 52 ++++++++++++++++++++++- 4 files changed, 116 insertions(+), 45 deletions(-) diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index 28d8b3f1af..82022f4246 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -11,9 +11,29 @@ use tauri_plugin_http::reqwest::{ClientBuilder, Request}; use tauri::Theme; static BADGE_COUNT: AtomicI64 = AtomicI64::new(0); +const MAX_BADGE_COUNT: i64 = 99_999; +const MAX_BADGE_LABEL_CHARS: usize = 16; + +fn normalize_badge_count(count: Option) -> Option { + count.filter(|n| (1..=MAX_BADGE_COUNT).contains(n)) +} + +fn normalize_badge_label(label: Option<&str>) -> Result, String> { + let Some(label) = label.map(str::trim).filter(|label| !label.is_empty()) else { + return Ok(None); + }; + + if label.chars().count() > MAX_BADGE_LABEL_CHARS { + return Err(format!( + "Badge label must be {MAX_BADGE_LABEL_CHARS} characters or fewer" + )); + } + + Ok(Some(label.to_string())) +} fn apply_badge(app: &AppHandle, count: Option) -> Result<(), String> { - let label = count.filter(|n| *n > 0).map(|n| n.to_string()); + let label = normalize_badge_count(count).map(|n| n.to_string()); apply_badge_label(app, label.as_deref()) } @@ -178,21 +198,24 @@ pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<( .icon(¶ms.icon) .show() .map_err(|e| format!("Failed to show notification: {}", e))?; - - let next = BADGE_COUNT.fetch_add(1, Ordering::SeqCst) + 1; - if let Err(error) = apply_badge(&app, Some(next)) { - eprintln!("[Pake] Failed to update dock badge: {error}"); - } Ok(()) } #[command] pub fn set_dock_badge(app: AppHandle, count: Option) -> Result<(), String> { - let normalized = count.filter(|n| *n > 0); + let normalized = normalize_badge_count(count); BADGE_COUNT.store(normalized.unwrap_or(0), Ordering::SeqCst); apply_badge(&app, normalized) } +#[command] +pub fn increment_dock_badge(app: AppHandle) -> Result<(), String> { + let current = BADGE_COUNT.load(Ordering::SeqCst); + let next = current.saturating_add(1).clamp(1, MAX_BADGE_COUNT); + BADGE_COUNT.store(next, Ordering::SeqCst); + apply_badge(&app, Some(next)) +} + #[command] pub fn clear_dock_badge(app: AppHandle) -> Result<(), String> { BADGE_COUNT.store(0, Ordering::SeqCst); @@ -202,7 +225,8 @@ pub fn clear_dock_badge(app: AppHandle) -> Result<(), String> { #[command] pub fn set_dock_badge_label(app: AppHandle, label: Option) -> Result<(), String> { BADGE_COUNT.store(0, Ordering::SeqCst); - apply_badge_label(&app, label.as_deref().filter(|s| !s.is_empty())) + let label = normalize_badge_label(label.as_deref())?; + apply_badge_label(&app, label.as_deref()) } #[command] diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index bd5bf88a71..f3bb7cf90c 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -1042,15 +1042,29 @@ document.addEventListener("DOMContentLoaded", () => { let pageManagedBadge = false; let autoBadgeActive = false; + const normalizeBadgeCount = (count) => { + if (typeof count !== "number" || !Number.isFinite(count)) { + throw new TypeError("Badge count must be a finite number."); + } + const normalized = Math.floor(count); + return normalized > 0 ? Math.min(normalized, 99999) : null; + }; const setBadge = (count) => { pageManagedBadge = true; + autoBadgeActive = false; return invoke("set_dock_badge", { count }).catch(() => {}); }; const clearBadge = () => invoke("clear_dock_badge").catch(() => {}); const setLabel = (label) => { pageManagedBadge = true; + autoBadgeActive = false; return invoke("set_dock_badge_label", { label }).catch(() => {}); }; + const incrementAutoBadge = () => { + if (pageManagedBadge) return Promise.resolve(); + autoBadgeActive = true; + return invoke("increment_dock_badge").catch(() => {}); + }; window.addEventListener("focus", () => { if (lastNotif?.onclick && Date.now() - lastNotifTime < 5000) { @@ -1084,11 +1098,11 @@ document.addEventListener("DOMContentLoaded", () => { lastNotifTime = Date.now(); lastNotif = notif; - autoBadgeActive = true; - - invoke("send_notification", { params: { title, body, icon } }).then(() => { - if (notif.onshow) notif.onshow(new Event("show")); - }); + invoke("send_notification", { params: { title, body, icon } }) + .then(() => incrementAutoBadge()) + .then(() => { + if (notif.onshow) notif.onshow(new Event("show")); + }); return notif; }; @@ -1115,8 +1129,18 @@ document.addEventListener("DOMContentLoaded", () => { // shows the count (0 clears). clearAppBadge() removes the badge entirely. const setAppBadge = (count) => { if (count === undefined) return setLabel("•"); - const n = typeof count === "number" && count > 0 ? Math.floor(count) : null; - return n === null ? clearBadge() : setBadge(n); + let normalized; + try { + normalized = normalizeBadgeCount(count); + } catch (error) { + return Promise.reject(error); + } + if (normalized === null) { + pageManagedBadge = false; + autoBadgeActive = false; + return clearBadge(); + } + return setBadge(normalized); }; const clearAppBadge = () => { pageManagedBadge = false; @@ -1135,33 +1159,6 @@ document.addEventListener("DOMContentLoaded", () => { value: clearAppBadge, }); } catch (_) {} - - // Service worker notifications: forward to the same Rust command so badge - // bookkeeping stays consistent. Fall through to the original implementation - // (if any) so push subscriptions still work. - if (typeof ServiceWorkerRegistration !== "undefined") { - try { - const orig = ServiceWorkerRegistration.prototype.showNotification; - ServiceWorkerRegistration.prototype.showNotification = function ( - title, - options, - ) { - const body = options?.body || ""; - let icon = options?.icon || ""; - if (icon.startsWith("/")) icon = window.location.origin + icon; - autoBadgeActive = true; - invoke("send_notification", { params: { title, body, icon } }).catch( - () => {}, - ); - if (orig) { - try { - return orig.call(this, title, options); - } catch (_) {} - } - return Promise.resolve(); - }; - } catch (_) {} - } })(); function setDefaultZoom() { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fec4c0ecd..68b5a5ecc0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,7 +14,8 @@ const WINDOW_SHOW_DELAY: u64 = 50; use app::{ invoke::{ clear_cache_and_restart, clear_dock_badge, download_file, download_file_by_binary, - send_notification, set_dock_badge, set_dock_badge_label, update_theme_mode, + increment_dock_badge, send_notification, set_dock_badge, set_dock_badge_label, + update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, @@ -81,6 +82,7 @@ pub fn run_app() { download_file, download_file_by_binary, send_notification, + increment_dock_badge, set_dock_badge, set_dock_badge_label, clear_dock_badge, diff --git a/tests/unit/event-link-guard.test.js b/tests/unit/event-link-guard.test.js index ff0fc31e27..2172907a87 100644 --- a/tests/unit/event-link-guard.test.js +++ b/tests/unit/event-link-guard.test.js @@ -3,12 +3,18 @@ import path from "path"; import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; -function loadEventHelpers() { +function loadEventHelpers({ withTauri = false } = {}) { const source = fs.readFileSync( path.join(process.cwd(), "src-tauri/src/inject/event.js"), "utf-8", ); + const invokeCalls = []; + const invoke = (command, payload) => { + invokeCalls.push([command, payload]); + return Promise.resolve(); + }; + const context = { console, URL, @@ -28,12 +34,14 @@ function loadEventHelpers() { }, location: { href: "https://example.com/app", + origin: "https://example.com", reload: () => {}, }, localStorage: { getItem: () => null, setItem: () => {}, }, + addEventListener: () => {}, dispatchEvent: () => {}, }, document: { @@ -46,9 +54,13 @@ function loadEventHelpers() { execCommand: () => {}, }, }; + context.window.navigator = context.navigator; + if (withTauri) { + context.window.__TAURI__ = { core: { invoke } }; + } runInNewContext(source, context); - return context; + return { ...context, invokeCalls }; } describe("event link guard", () => { @@ -71,4 +83,40 @@ describe("event link guard", () => { false, ); }); + + it("bridges Web Badging API calls to explicit badge commands", async () => { + const { navigator, invokeCalls } = loadEventHelpers({ withTauri: true }); + + await navigator.setAppBadge(3.8); + await navigator.setAppBadge(); + await navigator.setAppBadge(0); + + expect(invokeCalls).toEqual([ + ["set_dock_badge", { count: 3 }], + ["set_dock_badge_label", { label: "•" }], + ["clear_dock_badge", undefined], + ]); + }); + + it("keeps notification display separate from badge increment", async () => { + const { window, invokeCalls } = loadEventHelpers({ withTauri: true }); + + new window.Notification("Hello", { body: "World", icon: "/icon.png" }); + await Promise.resolve(); + await Promise.resolve(); + + expect(invokeCalls).toEqual([ + [ + "send_notification", + { + params: { + title: "Hello", + body: "World", + icon: "https://example.com/icon.png", + }, + }, + ], + ["increment_dock_badge", undefined], + ]); + }); }); From a2e22df10c7a3f9b6ae1110b49231b480eeaf201 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 9 May 2026 22:09:13 +0800 Subject: [PATCH 023/120] test: normalize Pake config path assertion --- tests/unit/base-builder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index ffb0a886ce..84db127e82 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -190,8 +190,9 @@ describe('BaseBuilder guards', () => { } as any); const command = (builder as any).getBuildCommand('pnpm'); + const normalizedCommand = command.replace(/\\/g, '/'); - expect(command).toContain('src-tauri/.pake/tauri.conf.json'); + expect(normalizedCommand).toContain('src-tauri/.pake/tauri.conf.json'); expect(command).toContain('--features cli-build'); }); From b5d74772d5ac26cd5f88d83b7c2cb6eec81ac962 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 9 May 2026 22:09:13 +0800 Subject: [PATCH 024/120] test: normalize Pake config path assertion --- tests/unit/base-builder.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index ffb0a886ce..84db127e82 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -190,8 +190,9 @@ describe('BaseBuilder guards', () => { } as any); const command = (builder as any).getBuildCommand('pnpm'); + const normalizedCommand = command.replace(/\\/g, '/'); - expect(command).toContain('src-tauri/.pake/tauri.conf.json'); + expect(normalizedCommand).toContain('src-tauri/.pake/tauri.conf.json'); expect(command).toContain('--features cli-build'); }); From a31ed8ff94a0b400818f8a58db78e60fcc006762 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 14:10:06 +0000 Subject: [PATCH 025/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index db789ec6fe..8d884ce62b 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -30,7 +30,7 @@ - + jeasonnow @@ -107,7 +107,7 @@ - + YangguangZhou @@ -118,7 +118,7 @@ - + AielloChan @@ -140,7 +140,7 @@ - + GXiang314 @@ -162,7 +162,7 @@ - + GoodbyeNJN @@ -173,7 +173,7 @@ - + eltociear @@ -184,7 +184,7 @@ - + kittizz @@ -217,7 +217,7 @@ - + QingZ11 @@ -360,7 +360,7 @@ - + fvn-elmy @@ -558,7 +558,7 @@ - + liudonghua123 @@ -591,7 +591,7 @@ - + enihsyou From 425a9940d629f8491815348c7c35d509efa38920 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 9 May 2026 14:16:34 +0000 Subject: [PATCH 026/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 8d884ce62b..db789ec6fe 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -30,7 +30,7 @@ - + jeasonnow @@ -107,7 +107,7 @@ - + YangguangZhou @@ -118,7 +118,7 @@ - + AielloChan @@ -140,7 +140,7 @@ - + GXiang314 @@ -162,7 +162,7 @@ - + GoodbyeNJN @@ -173,7 +173,7 @@ - + eltociear @@ -184,7 +184,7 @@ - + kittizz @@ -217,7 +217,7 @@ - + QingZ11 @@ -360,7 +360,7 @@ - + fvn-elmy @@ -558,7 +558,7 @@ - + liudonghua123 @@ -591,7 +591,7 @@ - + enihsyou From 4021f83dd49abf34e534eb050896084d8ea86eda Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 01:06:48 +0000 Subject: [PATCH 027/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 72 ++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index db789ec6fe..9f55fc9b16 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -30,7 +30,7 @@ - + jeasonnow @@ -107,7 +107,7 @@ - + YangguangZhou @@ -118,7 +118,7 @@ - + AielloChan @@ -156,39 +156,50 @@ + + + + + + + + AllDaGearNoIdea + + + - + GoodbyeNJN - + - + eltociear - + - + kittizz - + @@ -199,18 +210,18 @@ mattbajorek - + - + vaddisrinivas - + @@ -221,7 +232,7 @@ QingZ11 - + @@ -232,7 +243,7 @@ Tianj0o - + @@ -243,7 +254,7 @@ xinyii - + @@ -254,7 +265,7 @@ g1eny0ung - + @@ -265,7 +276,7 @@ lkieryan - + @@ -276,7 +287,7 @@ exposir - + @@ -287,7 +298,7 @@ 2nthony - + @@ -298,17 +309,6 @@ ACGNnsj - - - - - - - - - AllDaGearNoIdea - - @@ -338,7 +338,7 @@ - + kuishou68 @@ -360,7 +360,7 @@ - + fvn-elmy @@ -437,7 +437,7 @@ - + pgoslatara @@ -558,7 +558,7 @@ - + liudonghua123 @@ -591,7 +591,7 @@ - + enihsyou From 0936cd87ef997a9ae45c31312aa3fb0bf166f986 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 15 May 2026 07:57:59 +0800 Subject: [PATCH 028/120] feat: add optional find support --- bin/defaults.ts | 1 + bin/helpers/cli-program.ts | 8 + bin/helpers/merge.ts | 1 + bin/types.ts | 4 + dist/cli.js | 7 +- docs/cli-usage.md | 8 + docs/cli-usage_CN.md | 8 + package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/pake.json | 1 + src-tauri/src/app/config.rs | 2 + src-tauri/src/app/menu.rs | 49 +- src-tauri/src/app/window.rs | 1 + src-tauri/src/inject/find.js | 708 ++++++++++++++++++ src-tauri/src/lib.rs | 3 +- src-tauri/tauri.conf.json | 2 +- .../merge-window-options.test.ts.snap | 3 + tests/unit/cli-options.test.ts | 10 + tests/unit/find-shortcuts.test.js | 288 +++++++ tests/unit/merge-window-options.test.ts | 2 + 21 files changed, 1103 insertions(+), 9 deletions(-) create mode 100644 src-tauri/src/inject/find.js create mode 100644 tests/unit/find-shortcuts.test.js diff --git a/bin/defaults.ts b/bin/defaults.ts index 6fb41c02d9..c927754deb 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -44,6 +44,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + enableFind: false, iterativeBuild: false, zoom: 100, minWidth: 0, diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index e5a748fdbb..4780e0f69d 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -195,6 +195,14 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .default(DEFAULT.internalUrlRegex) .hideHelp(), ) + .addOption( + new Option( + '--enable-find', + 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts', + ) + .default(DEFAULT.enableFind) + .hideHelp(), + ) .addOption( new Option('--installer-language ', 'Installer language') .default(DEFAULT.installerLanguage) diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index 0425e9d8d5..1a79f7b040 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -49,6 +49,7 @@ export function buildWindowConfigOverrides( start_to_tray: options.startToTray && options.showSystemTray, force_internal_navigation: options.forceInternalNavigation, internal_url_regex: options.internalUrlRegex, + enable_find: options.enableFind, zoom: options.zoom, min_width: options.minWidth, min_height: options.minHeight, diff --git a/bin/types.ts b/bin/types.ts index 9c947774ff..c83dd022e9 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -108,6 +108,9 @@ export interface PakeCliOptions { // Regex pattern to match URLs that should be considered internal internalUrlRegex: string; + // Enable in-page Find UI and Cmd/Ctrl+F/G shortcuts, default false + enableFind: boolean; + // Initial page zoom level (50-200), default 100 zoom: number; @@ -167,6 +170,7 @@ export interface WindowConfig { start_to_tray: boolean; force_internal_navigation: boolean; internal_url_regex: string; + enable_find: boolean; zoom: number; min_width: number; min_height: number; diff --git a/dist/cli.js b/dist/cli.js index 85e4ab97eb..60c17c0e69 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.5"; +var version = "3.11.6"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" @@ -464,6 +464,7 @@ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(proc start_to_tray: options.startToTray && options.showSystemTray, force_internal_navigation: options.forceInternalNavigation, internal_url_regex: options.internalUrlRegex, + enable_find: options.enableFind, zoom: options.zoom, min_width: options.minWidth, min_height: options.minHeight, @@ -2402,6 +2403,7 @@ const DEFAULT_PAKE_OPTIONS = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + enableFind: false, iterativeBuild: false, zoom: 100, minWidth: 0, @@ -2541,6 +2543,9 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal') .default(DEFAULT_PAKE_OPTIONS.internalUrlRegex) .hideHelp()) + .addOption(new Option('--enable-find', 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts') + .default(DEFAULT_PAKE_OPTIONS.enableFind) + .hideHelp()) .addOption(new Option('--installer-language ', 'Installer language') .default(DEFAULT_PAKE_OPTIONS.installerLanguage) .hideHelp()) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 56ca718def..ac44befd3b 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -230,6 +230,14 @@ Sets whether to disable web shortcuts in the original Pake container, defaults t --disabled-web-shortcuts ``` +#### [enable-find] + +Enable Pake's in-page Find UI. Default is `false`. When enabled, users can press `Cmd/Ctrl+F` to open Find, `Cmd/Ctrl+G` to jump to the next match, and `Cmd/Ctrl+Shift+G` to jump to the previous match. + +```shell +--enable-find +``` + #### [force-internal-navigation] Keeps every clicked link (even pointing to other domains) inside the Pake window instead of letting the OS open an external browser or helper. Default is `false`. diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 4e2fe61e47..5da2e765ed 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -228,6 +228,14 @@ pake https://github.com --name GitHub --disabled-web-shortcuts ``` +#### [enable-find] + +启用 Pake 内置的页面查找浮层,默认 `false`。开启后用户可以使用 `Cmd/Ctrl+F` 打开查找,`Cmd/Ctrl+G` 跳到下一个匹配项,`Cmd/Ctrl+Shift+G` 跳到上一个匹配项。 + +```shell +--enable-find +``` + #### [force-internal-navigation] 启用后所有点击的链接(即使是跨域)都会在 Pake 窗口内打开,不会再调用外部浏览器或辅助程序。默认 `false`。 diff --git a/package.json b/package.json index 4fbdb8c169..26d37e7e86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.5", + "version": "3.11.6", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c95637062d..a571370123 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.5" +version = "3.11.6" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a09125c896..8b4c00db3b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.5" +version = "3.11.6" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" diff --git a/src-tauri/pake.json b/src-tauri/pake.json index 202cb6e84d..c797c64430 100644 --- a/src-tauri/pake.json +++ b/src-tauri/pake.json @@ -20,6 +20,7 @@ "start_to_tray": false, "force_internal_navigation": false, "internal_url_regex": "", + "enable_find": false, "new_window": false } ], diff --git a/src-tauri/src/app/config.rs b/src-tauri/src/app/config.rs index a40550f3e7..67f63b084a 100644 --- a/src-tauri/src/app/config.rs +++ b/src-tauri/src/app/config.rs @@ -26,6 +26,8 @@ pub struct WindowConfig { pub force_internal_navigation: bool, #[serde(default)] pub internal_url_regex: String, + #[serde(default)] + pub enable_find: bool, #[serde(default = "default_zoom")] pub zoom: u32, #[serde(default)] diff --git a/src-tauri/src/app/menu.rs b/src-tauri/src/app/menu.rs index 704d35a122..e9537bbc04 100644 --- a/src-tauri/src/app/menu.rs +++ b/src-tauri/src/app/menu.rs @@ -6,7 +6,11 @@ use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, Wry}; use tauri_plugin_opener::OpenerExt; -pub fn set_app_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result<()> { +pub fn set_app_menu( + app: &AppHandle, + allow_multi_window: bool, + enable_find: bool, +) -> tauri::Result<()> { let pake_version = env!("CARGO_PKG_VERSION"); let pake_menu_item_title = format!("Built with Pake V{}", pake_version); @@ -17,7 +21,7 @@ pub fn set_app_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Re &[ &app_menu(app)?, &file_menu(app, allow_multi_window)?, - &edit_menu(app)?, + &edit_menu(app, enable_find)?, &view_menu(app)?, &navigation_menu(app)?, &window_submenu, @@ -77,7 +81,7 @@ fn file_menu(app: &AppHandle, allow_multi_window: bool) -> tauri::Result) -> tauri::Result> { +fn edit_menu(app: &AppHandle, enable_find: bool) -> tauri::Result> { let edit_menu = Submenu::new(app, "Edit", true)?; edit_menu.append(&PredefinedMenuItem::undo(app, None)?)?; edit_menu.append(&PredefinedMenuItem::redo(app, None)?)?; @@ -94,6 +98,30 @@ fn edit_menu(app: &AppHandle) -> tauri::Result> { )?)?; edit_menu.append(&PredefinedMenuItem::select_all(app, None)?)?; edit_menu.append(&PredefinedMenuItem::separator(app)?)?; + if enable_find { + edit_menu.append(&MenuItem::with_id( + app, + "find", + "Find", + true, + Some("CmdOrCtrl+F"), + )?)?; + edit_menu.append(&MenuItem::with_id( + app, + "find_next", + "Find Next", + true, + Some("CmdOrCtrl+G"), + )?)?; + edit_menu.append(&MenuItem::with_id( + app, + "find_previous", + "Find Previous", + true, + Some("CmdOrCtrl+Shift+G"), + )?)?; + edit_menu.append(&PredefinedMenuItem::separator(app)?)?; + } edit_menu.append(&MenuItem::with_id( app, "copy_url", @@ -263,6 +291,21 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) { let _ = window.eval("triggerPasteAsPlainText()"); } } + "find" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.open()"); + } + } + "find_next" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.next()"); + } + } + "find_previous" => { + if let Some(window) = app_handle.get_webview_window("pake") { + let _ = window.eval("window.pakeFind?.previous()"); + } + } "clear_cache_restart" => { if let Some(window) = app_handle.get_webview_window("pake") { if let Ok(_) = window.clear_all_browsing_data() { diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 2535d981ca..6448d6d7f6 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -287,6 +287,7 @@ fn build_window( // calls show_toast(). window_builder = window_builder .initialization_script(&config_script) + .initialization_script(include_str!("../inject/find.js")) .initialization_script(include_str!("../inject/toast.js")) .initialization_script(include_str!("../inject/fullscreen.js")) .initialization_script(include_str!("../inject/event.js")) diff --git a/src-tauri/src/inject/find.js b/src-tauri/src/inject/find.js new file mode 100644 index 0000000000..a749d9b74a --- /dev/null +++ b/src-tauri/src/inject/find.js @@ -0,0 +1,708 @@ +(function () { + if (window.__PAKE_FIND_SCRIPT__) { + return; + } + window.__PAKE_FIND_SCRIPT__ = true; + + const PANEL_ID = "pake-find-panel"; + const STYLE_ID = "pake-find-style"; + const MARK_ATTR = "data-pake-find"; + const ACTIVE_ATTR = "data-pake-find-active"; + const MATCH_HIGHLIGHT = "pake-find-match"; + const ACTIVE_HIGHLIGHT = "pake-find-active"; + const MAX_MATCHES = 1000; + const SEARCH_DEBOUNCE_MS = 120; + const SKIPPED_TAGS = new Set([ + "script", + "style", + "noscript", + "input", + "textarea", + "select", + "option", + ]); + + const state = { + enabled: window.pakeConfig?.enable_find === true, + panel: null, + input: null, + counter: null, + status: null, + matches: [], + activeIndex: -1, + query: "", + truncated: false, + domMarks: [], + observer: null, + searchTimer: null, + isOpen: false, + }; + + function getState() { + return { + enabled: state.enabled, + isOpen: state.isOpen, + query: state.query, + matchCount: state.matches.length, + activeIndex: state.activeIndex, + truncated: state.truncated, + }; + } + + function noop() { + return getState(); + } + + if (!state.enabled) { + window.pakeFind = { + open: noop, + close: noop, + next: noop, + previous: noop, + search: noop, + getState, + getFindShortcutAction: () => "", + }; + return; + } + + function getNodeFilter() { + return ( + window.NodeFilter || + globalThis.NodeFilter || { + SHOW_TEXT: 4, + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + } + ); + } + + function supportsCustomHighlight() { + return ( + typeof CSS !== "undefined" && + CSS.highlights && + typeof Highlight === "function" + ); + } + + function isFindPanelNode(node) { + const element = + node?.nodeType === 1 ? node : node?.parentElement || node?.parentNode; + if (!element) { + return false; + } + if (element.id === PANEL_ID) { + return true; + } + return element.closest?.(`#${PANEL_ID}`) != null; + } + + function shouldSkipElement(element) { + for (let current = element; current; current = current.parentElement) { + if (current.id === PANEL_ID) { + return true; + } + + const tagName = current.tagName?.toLowerCase(); + if (tagName && SKIPPED_TAGS.has(tagName)) { + return true; + } + + if ( + current.isContentEditable || + current.getAttribute?.("contenteditable") === "true" + ) { + return true; + } + + if (current.hidden || current.getAttribute?.("aria-hidden") === "true") { + return true; + } + } + + return false; + } + + function getSearchableTextNodes(root = document.body) { + if (!root || !document.createTreeWalker) { + return []; + } + + const nodeFilter = getNodeFilter(); + const walker = document.createTreeWalker(root, nodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue || node.nodeValue.length === 0) { + return nodeFilter.FILTER_REJECT; + } + if (shouldSkipElement(node.parentElement)) { + return nodeFilter.FILTER_REJECT; + } + return nodeFilter.FILTER_ACCEPT; + }, + }); + + const nodes = []; + let current = walker.nextNode(); + while (current) { + nodes.push(current); + current = walker.nextNode(); + } + return nodes; + } + + function createRange(node, start, end) { + const range = document.createRange(); + range.setStart(node, start); + range.setEnd(node, end); + return range; + } + + function collectMatches(query) { + const matches = []; + const normalizedQuery = query.toLocaleLowerCase(); + if (!normalizedQuery) { + return { matches, truncated: false }; + } + + for (const node of getSearchableTextNodes()) { + const text = node.nodeValue || ""; + const normalizedText = text.toLocaleLowerCase(); + let searchFrom = 0; + + while (searchFrom <= normalizedText.length) { + const index = normalizedText.indexOf(normalizedQuery, searchFrom); + if (index === -1) { + break; + } + + matches.push({ + node, + start: index, + end: index + query.length, + range: createRange(node, index, index + query.length), + mark: null, + }); + + if (matches.length >= MAX_MATCHES) { + return { matches, truncated: true }; + } + + searchFrom = index + Math.max(query.length, 1); + } + } + + return { matches, truncated: false }; + } + + function ensureStyle() { + if (document.getElementById(STYLE_ID)) { + return; + } + + const style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = ` + #${PANEL_ID} { + position: fixed; + top: 14px; + right: 14px; + z-index: 2147483647; + display: none; + align-items: center; + gap: 6px; + box-sizing: border-box; + min-width: 278px; + max-width: min(420px, calc(100vw - 28px)); + padding: 8px; + border: 1px solid rgba(0, 0, 0, 0.14); + border-radius: 8px; + background: rgba(255, 255, 255, 0.96); + color: #1f2328; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18); + font: 13px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + backdrop-filter: blur(16px); + } + #${PANEL_ID}[data-visible="true"] { + display: flex; + } + #${PANEL_ID} input { + min-width: 0; + flex: 1 1 auto; + height: 28px; + box-sizing: border-box; + border: 1px solid rgba(0, 0, 0, 0.16); + border-radius: 6px; + padding: 0 8px; + background: #fff; + color: #1f2328; + font: inherit; + outline: none; + } + #${PANEL_ID} input:focus { + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.16); + } + #${PANEL_ID} [data-pake-find-counter] { + flex: 0 0 auto; + min-width: 42px; + color: #5f6b7a; + text-align: center; + font-size: 12px; + white-space: nowrap; + } + #${PANEL_ID} button { + flex: 0 0 auto; + width: 28px; + height: 28px; + border: 0; + border-radius: 6px; + background: transparent; + color: #30363d; + font: 15px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + cursor: pointer; + } + #${PANEL_ID} button:hover { + background: rgba(0, 0, 0, 0.08); + } + #${PANEL_ID} [data-pake-find-status] { + position: absolute; + left: 10px; + top: calc(100% + 4px); + color: #d1242f; + font-size: 12px; + white-space: nowrap; + } + @media (prefers-color-scheme: dark) { + #${PANEL_ID} { + border-color: rgba(255, 255, 255, 0.16); + background: rgba(31, 35, 40, 0.94); + color: #f0f3f6; + box-shadow: 0 10px 26px rgba(0, 0, 0, 0.36); + } + #${PANEL_ID} input { + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); + color: #f0f3f6; + } + #${PANEL_ID} [data-pake-find-counter] { + color: #b7c0cc; + } + #${PANEL_ID} button { + color: #f0f3f6; + } + #${PANEL_ID} button:hover { + background: rgba(255, 255, 255, 0.12); + } + } + ::highlight(${MATCH_HIGHLIGHT}) { + background: rgba(255, 214, 10, 0.58); + color: inherit; + } + ::highlight(${ACTIVE_HIGHLIGHT}) { + background: rgba(255, 149, 0, 0.9); + color: inherit; + } + mark[${MARK_ATTR}] { + background: rgba(255, 214, 10, 0.58); + color: inherit; + padding: 0; + } + mark[${MARK_ATTR}][${ACTIVE_ATTR}] { + background: rgba(255, 149, 0, 0.9); + } + `; + + (document.head || document.body || document.documentElement)?.appendChild( + style, + ); + } + + function createButton(label, title, onClick) { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = title; + button.setAttribute("aria-label", title); + button.addEventListener("click", onClick); + return button; + } + + function ensurePanel() { + if (state.panel) { + return state.panel; + } + + ensureStyle(); + + const panel = document.createElement("div"); + panel.id = PANEL_ID; + panel.setAttribute("role", "search"); + panel.setAttribute("aria-label", "Find in page"); + + const input = document.createElement("input"); + input.type = "search"; + input.autocomplete = "off"; + input.spellcheck = false; + input.placeholder = "Find"; + input.setAttribute("aria-label", "Find in page"); + + const counter = document.createElement("span"); + counter.setAttribute("data-pake-find-counter", ""); + counter.textContent = "0/0"; + + const previousButton = createButton("<", "Find Previous", () => previous()); + const nextButton = createButton(">", "Find Next", () => next()); + const closeButton = createButton("x", "Close Find", () => close()); + + const status = document.createElement("span"); + status.setAttribute("data-pake-find-status", ""); + + input.addEventListener("input", () => { + debounceSearch(input.value); + }); + input.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + previous(); + } else { + next(); + } + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + close(); + } + }); + + panel.append( + input, + counter, + previousButton, + nextButton, + closeButton, + status, + ); + (document.body || document.documentElement).appendChild(panel); + + state.panel = panel; + state.input = input; + state.counter = counter; + state.status = status; + + return panel; + } + + function clearCustomHighlights() { + if (!supportsCustomHighlight()) { + return; + } + + CSS.highlights.delete(MATCH_HIGHLIGHT); + CSS.highlights.delete(ACTIVE_HIGHLIGHT); + } + + function clearDomMarks() { + const marks = Array.from( + document.querySelectorAll?.(`mark[${MARK_ATTR}]`) || state.domMarks, + ); + + for (const mark of marks) { + const parent = mark.parentNode; + const text = document.createTextNode(mark.textContent || ""); + mark.replaceWith?.(text); + parent?.normalize?.(); + } + + state.domMarks = []; + } + + function clearHighlights() { + clearCustomHighlights(); + clearDomMarks(); + } + + function applyCustomHighlights() { + if (!supportsCustomHighlight()) { + return false; + } + + const ranges = state.matches.map((match) => match.range); + CSS.highlights.set(MATCH_HIGHLIGHT, new Highlight(...ranges)); + updateActiveHighlight(); + return true; + } + + function applyDomHighlights() { + const grouped = new Map(); + for (const match of state.matches) { + const nodeMatches = grouped.get(match.node) || []; + nodeMatches.push(match); + grouped.set(match.node, nodeMatches); + } + + for (const nodeMatches of grouped.values()) { + nodeMatches.sort((a, b) => b.start - a.start); + for (const match of nodeMatches) { + try { + const mark = document.createElement("mark"); + mark.setAttribute(MARK_ATTR, ""); + match.range.surroundContents(mark); + match.mark = mark; + state.domMarks.push(mark); + } catch (error) { + // Some browser-generated text ranges cannot be wrapped safely. + } + } + } + + updateDomActiveMark(); + } + + function updateDomActiveMark() { + state.matches.forEach((match, index) => { + const mark = match.mark; + if (!mark) { + return; + } + + if (mark.toggleAttribute) { + mark.toggleAttribute(ACTIVE_ATTR, index === state.activeIndex); + } else if (index === state.activeIndex) { + mark.setAttribute(ACTIVE_ATTR, ""); + } else { + mark.removeAttribute?.(ACTIVE_ATTR); + } + }); + } + + function updateActiveHighlight() { + if (!supportsCustomHighlight()) { + updateDomActiveMark(); + return; + } + + CSS.highlights.delete(ACTIVE_HIGHLIGHT); + if (state.activeIndex >= 0 && state.matches[state.activeIndex]) { + CSS.highlights.set( + ACTIVE_HIGHLIGHT, + new Highlight(state.matches[state.activeIndex].range), + ); + } + } + + function scrollActiveIntoView() { + const active = state.matches[state.activeIndex]; + if (!active) { + return; + } + + const target = active.mark || active.range.startContainer?.parentElement; + if (target?.scrollIntoView) { + target.scrollIntoView({ block: "center", inline: "nearest" }); + } + } + + function updateCounter() { + if (!state.counter) { + return; + } + + const total = state.matches.length; + const active = state.activeIndex >= 0 ? state.activeIndex + 1 : 0; + state.counter.textContent = `${active}/${total}${state.truncated ? "+" : ""}`; + + if (state.status) { + state.status.textContent = state.query && total === 0 ? "No results" : ""; + } + } + + function runSearch(query = state.query) { + state.query = query; + clearHighlights(); + + if (!query) { + state.matches = []; + state.activeIndex = -1; + state.truncated = false; + updateCounter(); + return getState(); + } + + const result = collectMatches(query); + state.matches = result.matches; + state.truncated = result.truncated; + state.activeIndex = state.matches.length > 0 ? 0 : -1; + + if (!applyCustomHighlights()) { + applyDomHighlights(); + } + + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function debounceSearch(query) { + clearTimeout(state.searchTimer); + state.searchTimer = setTimeout(() => runSearch(query), SEARCH_DEBOUNCE_MS); + } + + function next() { + if (!state.query && state.input?.value) { + runSearch(state.input.value); + } + + if (state.matches.length === 0) { + return getState(); + } + + state.activeIndex = (state.activeIndex + 1) % state.matches.length; + updateActiveHighlight(); + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function previous() { + if (!state.query && state.input?.value) { + runSearch(state.input.value); + } + + if (state.matches.length === 0) { + return getState(); + } + + state.activeIndex = + (state.activeIndex - 1 + state.matches.length) % state.matches.length; + updateActiveHighlight(); + updateCounter(); + scrollActiveIntoView(); + return getState(); + } + + function observeDocumentChanges() { + if ( + state.observer || + !document.body || + typeof MutationObserver !== "function" + ) { + return; + } + + state.observer = new MutationObserver((mutations) => { + if (!state.isOpen || !state.query) { + return; + } + if (mutations.every((mutation) => isFindPanelNode(mutation.target))) { + return; + } + debounceSearch(state.query); + }); + + state.observer.observe(document.body, { + childList: true, + characterData: true, + subtree: true, + }); + } + + function stopObservingDocumentChanges() { + state.observer?.disconnect(); + state.observer = null; + } + + function open() { + if (!state.enabled) { + return getState(); + } + + const panel = ensurePanel(); + panel.setAttribute("data-visible", "true"); + state.isOpen = true; + observeDocumentChanges(); + + requestAnimationFrame(() => { + state.input?.focus(); + state.input?.select(); + }); + + if (state.input?.value) { + runSearch(state.input.value); + } else { + updateCounter(); + } + + return getState(); + } + + function close() { + clearTimeout(state.searchTimer); + state.isOpen = false; + state.panel?.removeAttribute("data-visible"); + clearHighlights(); + stopObservingDocumentChanges(); + state.matches = []; + state.activeIndex = -1; + state.truncated = false; + updateCounter(); + return getState(); + } + + function search(query) { + if (state.input) { + state.input.value = query; + } + return runSearch(query); + } + + function getFindShortcutAction(event) { + const userAgent = navigator.userAgent || ""; + const isMac = /macintosh|mac os x/i.test(userAgent); + const hasModifier = isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + + if (!hasModifier || event.altKey) { + return ""; + } + + const key = event.key?.toLowerCase(); + if (key === "f" && !event.shiftKey) { + return "open"; + } + if (key === "g") { + return event.shiftKey ? "previous" : "next"; + } + return ""; + } + + function handleFindShortcut(event) { + const action = getFindShortcutAction(event); + if (!action) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + window.pakeFind[action](); + } + + window.pakeFind = { + open, + close, + next, + previous, + search, + getState, + getFindShortcutAction, + }; + + if (state.enabled) { + document.addEventListener("keydown", handleFindShortcut, true); + } +})(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28c0ae3fea..01c1434ebf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,6 +43,7 @@ pub fn run_app() { let start_to_tray = pake_config.windows[0].start_to_tray && show_system_tray; // Only valid when tray is enabled let multi_instance = pake_config.multi_instance; let multi_window = pake_config.multi_window; + let enable_find = pake_config.windows[0].enable_find; let window_state_plugin = WindowStatePlugin::default() .with_state_flags(if init_fullscreen { @@ -98,7 +99,7 @@ pub fn run_app() { // --- Menu Construction Start --- #[cfg(target_os = "macos")] { - app::menu::set_app_menu(app.app_handle(), multi_window)?; + app::menu::set_app_menu(app.app_handle(), multi_window, enable_find)?; // Event Handling for Custom Menu Item app.on_menu_event(move |app_handle, event| { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8d399afec9..368ebf1148 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.5", + "version": "3.11.6", "app": { "withGlobalTauri": true, "trayIcon": { diff --git a/tests/unit/__snapshots__/merge-window-options.test.ts.snap b/tests/unit/__snapshots__/merge-window-options.test.ts.snap index ca9f8e9da2..42b1144098 100644 --- a/tests/unit/__snapshots__/merge-window-options.test.ts.snap +++ b/tests/unit/__snapshots__/merge-window-options.test.ts.snap @@ -7,6 +7,7 @@ exports[`buildWindowConfigOverrides > matches the default snapshot on Linux 1`] "dark_mode": false, "disabled_web_shortcuts": false, "enable_drag_drop": false, + "enable_find": false, "enable_wasm": false, "force_internal_navigation": false, "fullscreen": false, @@ -35,6 +36,7 @@ exports[`buildWindowConfigOverrides > matches the default snapshot on Windows 1` "dark_mode": false, "disabled_web_shortcuts": false, "enable_drag_drop": false, + "enable_find": false, "enable_wasm": false, "force_internal_navigation": false, "fullscreen": false, @@ -63,6 +65,7 @@ exports[`buildWindowConfigOverrides > matches the default snapshot on macOS 1`] "dark_mode": false, "disabled_web_shortcuts": false, "enable_drag_drop": false, + "enable_find": false, "enable_wasm": false, "force_internal_navigation": false, "fullscreen": false, diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 9b8c800502..3262fd4eaa 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -36,4 +36,14 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); expect(option?.hidden).toBe(true); }); + + it('registers hidden --enable-find option', () => { + const option = program.options.find( + (item) => item.long === '--enable-find', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBe(true); + }); }); diff --git a/tests/unit/find-shortcuts.test.js b/tests/unit/find-shortcuts.test.js new file mode 100644 index 0000000000..278c935314 --- /dev/null +++ b/tests/unit/find-shortcuts.test.js @@ -0,0 +1,288 @@ +import fs from "fs"; +import path from "path"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +function createElement(tagName) { + const element = { + tagName: tagName.toUpperCase(), + id: "", + type: "", + textContent: "", + style: {}, + children: [], + attributes: new Map(), + parentElement: null, + parentNode: null, + hidden: false, + isContentEditable: false, + appendChild(child) { + child.parentElement = element; + child.parentNode = element; + element.children.push(child); + return child; + }, + append(...children) { + children.forEach((child) => element.appendChild(child)); + }, + addEventListener(type, handler) { + element.listeners = element.listeners || {}; + element.listeners[type] = element.listeners[type] || []; + element.listeners[type].push(handler); + }, + setAttribute(name, value) { + element.attributes.set(name, String(value)); + if (name === "id") element.id = String(value); + }, + getAttribute(name) { + return element.attributes.get(name) ?? null; + }, + removeAttribute(name) { + element.attributes.delete(name); + }, + toggleAttribute(name, force) { + if (force) { + element.setAttribute(name, ""); + } else { + element.removeAttribute(name); + } + }, + closest(selector) { + if (selector.startsWith("#")) { + const id = selector.slice(1); + for (let current = element; current; current = current.parentElement) { + if (current.id === id) return current; + } + } + return null; + }, + replaceWith() {}, + scrollIntoView() {}, + normalize() {}, + focus() {}, + select() {}, + }; + return element; +} + +function createTextNode(value, parent) { + return { + nodeType: 3, + nodeValue: value, + textContent: value, + parentElement: parent, + parentNode: parent, + }; +} + +function createDocument(textNodes) { + const listeners = {}; + const body = createElement("body"); + const head = createElement("head"); + + const document = { + body, + head, + documentElement: createElement("html"), + listeners, + addEventListener(type, handler, options) { + listeners[type] = listeners[type] || []; + listeners[type].push({ handler, options }); + }, + createElement, + createTextNode(value) { + return createTextNode(value, null); + }, + createRange() { + return { + setStart(node, start) { + this.startContainer = node; + this.start = start; + }, + setEnd(node, end) { + this.endContainer = node; + this.end = end; + }, + surroundContents(mark) { + mark.textContent = this.startContainer.nodeValue.slice( + this.start, + this.end, + ); + }, + }; + }, + createTreeWalker(root, _whatToShow, filter) { + const accepted = textNodes.filter( + (node) => filter.acceptNode(node) === 1, + ); + let index = -1; + return { + nextNode() { + index += 1; + return accepted[index] || null; + }, + }; + }, + getElementById(id) { + if (head.children.some((child) => child.id === id)) { + return head.children.find((child) => child.id === id); + } + if (body.children.some((child) => child.id === id)) { + return body.children.find((child) => child.id === id); + } + return null; + }, + querySelectorAll() { + return []; + }, + }; + + return document; +} + +function createKeyboardEvent(key, overrides = {}) { + const event = { + key, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...overrides, + }; + return event; +} + +function loadFindScript({ + enabled = true, + userAgent = "Mozilla/5.0", + nodes = [], +} = {}) { + const source = fs.readFileSync( + path.join(process.cwd(), "src-tauri/src/inject/find.js"), + "utf-8", + ); + const context = { + console, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => callback(), + navigator: { userAgent }, + NodeFilter: { + SHOW_TEXT: 4, + FILTER_ACCEPT: 1, + FILTER_REJECT: 2, + }, + window: { + pakeConfig: { enable_find: enabled }, + }, + document: createDocument(nodes), + }; + context.window.NodeFilter = context.NodeFilter; + context.window.navigator = context.navigator; + + runInNewContext(source, context); + return context; +} + +describe("Find injection", () => { + it("does not register shortcuts when enable_find is false", () => { + const paragraph = createElement("p"); + const context = loadFindScript({ + enabled: false, + nodes: [createTextNode("Alpha alpha", paragraph)], + }); + + expect(context.document.listeners.keydown).toBeUndefined(); + expect(context.window.pakeFind.getState().enabled).toBe(false); + expect( + context.window.pakeFind.getFindShortcutAction( + createKeyboardEvent("f", { ctrlKey: true }), + ), + ).toBe(""); + expect(context.window.pakeFind.open().isOpen).toBe(false); + expect(context.window.pakeFind.search("alpha").matchCount).toBe(0); + expect(context.window.pakeFind.next().activeIndex).toBe(-1); + expect(context.window.pakeFind.previous().activeIndex).toBe(-1); + expect(context.window.pakeFind.close().matchCount).toBe(0); + expect(context.document.head.children).toHaveLength(0); + expect(context.document.body.children).toHaveLength(0); + }); + + it("handles Cmd/Ctrl+F and Cmd/Ctrl+G shortcuts when enabled", () => { + const context = loadFindScript({ enabled: true }); + const calls = []; + context.window.pakeFind.open = () => calls.push("open"); + context.window.pakeFind.next = () => calls.push("next"); + context.window.pakeFind.previous = () => calls.push("previous"); + + const [listener] = context.document.listeners.keydown; + + const findEvent = createKeyboardEvent("f", { ctrlKey: true }); + listener.handler(findEvent); + const nextEvent = createKeyboardEvent("g", { ctrlKey: true }); + listener.handler(nextEvent); + const previousEvent = createKeyboardEvent("g", { + ctrlKey: true, + shiftKey: true, + }); + listener.handler(previousEvent); + + expect(calls).toEqual(["open", "next", "previous"]); + expect(findEvent.defaultPrevented).toBe(true); + expect(previousEvent.propagationStopped).toBe(true); + }); + + it("uses the macOS modifier for Find shortcuts", () => { + const context = loadFindScript({ + enabled: true, + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + }); + const calls = []; + context.window.pakeFind.open = () => calls.push("open"); + + const [listener] = context.document.listeners.keydown; + listener.handler(createKeyboardEvent("f", { ctrlKey: true })); + listener.handler(createKeyboardEvent("f", { metaKey: true })); + + expect(calls).toEqual(["open"]); + }); + + it("counts text matches and skips input and script content", () => { + const paragraph = createElement("p"); + const script = createElement("script"); + const input = createElement("input"); + const nodes = [ + createTextNode("Alpha beta alpha", paragraph), + createTextNode("alpha", script), + createTextNode("alpha", input), + ]; + const context = loadFindScript({ enabled: true, nodes }); + + const result = context.window.pakeFind.search("alpha"); + + expect(result.matchCount).toBe(2); + expect(result.activeIndex).toBe(0); + }); + + it("clears matches on Escape", () => { + const paragraph = createElement("p"); + const context = loadFindScript({ + enabled: true, + nodes: [createTextNode("Alpha alpha", paragraph)], + }); + + context.window.pakeFind.search("alpha"); + expect(context.window.pakeFind.getState().matchCount).toBe(2); + + context.window.pakeFind.close(); + expect(context.window.pakeFind.getState().matchCount).toBe(0); + }); +}); diff --git a/tests/unit/merge-window-options.test.ts b/tests/unit/merge-window-options.test.ts index 2e2f98cdba..387d887ed7 100644 --- a/tests/unit/merge-window-options.test.ts +++ b/tests/unit/merge-window-options.test.ts @@ -85,6 +85,7 @@ describe('buildWindowConfigOverrides', () => { enableDragDrop: true, ignoreCertificateErrors: true, newWindow: true, + enableFind: true, forceInternalNavigation: true, internalUrlRegex: '^https://example\\.com', }), @@ -100,6 +101,7 @@ describe('buildWindowConfigOverrides', () => { enable_drag_drop: true, ignore_certificate_errors: true, new_window: true, + enable_find: true, force_internal_navigation: true, internal_url_regex: '^https://example\\.com', }); From 123f3d8e73f5c1df390a108ec6c92374b456f663 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 23:58:17 +0000 Subject: [PATCH 029/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 52 ++++++++++++++++++++++++------------------------ 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 9f55fc9b16..49e177739b 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -8,7 +8,7 @@ - + tw93 @@ -19,7 +19,7 @@ - + Tlntin @@ -30,7 +30,7 @@ - + jeasonnow @@ -85,7 +85,7 @@ - + liby @@ -96,7 +96,7 @@ - + essesoul @@ -107,7 +107,7 @@ - + YangguangZhou @@ -118,7 +118,7 @@ - + AielloChan @@ -129,7 +129,7 @@ - + m1911star @@ -173,7 +173,7 @@ - + GoodbyeNJN @@ -195,7 +195,7 @@ - + kittizz @@ -228,7 +228,7 @@ - + QingZ11 @@ -250,7 +250,7 @@ - + xinyii @@ -272,7 +272,7 @@ - + lkieryan @@ -283,7 +283,7 @@ - + exposir @@ -316,7 +316,7 @@ - + kidylee @@ -327,7 +327,7 @@ - + nekomeowww @@ -338,7 +338,7 @@ - + kuishou68 @@ -371,7 +371,7 @@ - + Fechin @@ -404,7 +404,7 @@ - + JohannLai @@ -437,7 +437,7 @@ - + pgoslatara @@ -459,7 +459,7 @@ - + RoyRao2333 @@ -492,7 +492,7 @@ - + fulldecent @@ -536,7 +536,7 @@ - + geekvest @@ -558,7 +558,7 @@ - + liudonghua123 @@ -569,7 +569,7 @@ - + liusishan @@ -591,7 +591,7 @@ - + enihsyou From b97e80241bcf6a036c2cf03e38501c47d75d740c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 15 May 2026 08:17:23 +0800 Subject: [PATCH 030/120] ci: add trusted npm publishing --- .agents/skills/release/SKILL.md | 17 ++++-- .github/workflows/npm-publish.yml | 71 ++++++++++++++++++++++++ dist/cli.js | 3 +- package.json | 3 +- scripts/check-release-version.mjs | 92 +++++++++++++++++++++++++++++++ 5 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/npm-publish.yml create mode 100644 scripts/check-release-version.mjs diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index b184b3ad33..19833a63d6 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -16,10 +16,11 @@ Use this skill when preparing or executing a Pake release. ## Version Files -Three files must be updated in sync — never update one without the others: +Four files must be updated in sync — never update one without the others: - `package.json` → `"version"` - `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/Cargo.lock` → `version` for package `pake` - `src-tauri/tauri.conf.json` → `"version"` ## Release Checklist @@ -27,12 +28,13 @@ Three files must be updated in sync — never update one without the others: ### Pre-Release 1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) -2. [ ] Update all three version files above +2. [ ] Update all four version files above 3. [ ] Run `pnpm run format` — must pass cleanly 4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. 5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). -6. [ ] No uncommitted changes: `git status` -7. [ ] Commit version bump with message: `chore: bump version to VX.X.X` +6. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +7. [ ] No uncommitted changes: `git status` +8. [ ] Commit version bump with message: `chore: bump version to VX.X.X` ### Tagging (triggers CI) @@ -48,7 +50,10 @@ Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. 1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` 2. [ ] Watch CI status: `gh run watch` 3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` -4. [ ] Publish to npm (manual): `npm publish` +4. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +5. [ ] Verify npm published the package: `npm view pake-cli version` + +npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. ## Build Commands (local only) @@ -66,4 +71,4 @@ Cross-platform builds (Windows/Linux) are handled by CI, not locally. 1. **NEVER** auto-commit or auto-push without explicit user request 2. **NEVER** tag before all checks pass -3. **ALWAYS** verify the three version files are in sync before tagging +3. **ALWAYS** verify the four version files are in sync before tagging diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml new file mode 100644 index 0000000000..65c4360abb --- /dev/null +++ b/.github/workflows/npm-publish.yml @@ -0,0 +1,71 @@ +name: Publish npm Package + +on: + push: + tags: + - "V*" + +permissions: + contents: read + id-token: write + +concurrency: + group: npm-publish-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + name: Publish pake-cli + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: "10.26.2" + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + package-manager-cache: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check release version + run: node scripts/check-release-version.mjs "$GITHUB_REF_NAME" + + - name: Check formatting + run: pnpm run format:check + + - name: Run unit tests + run: npx vitest run + + - name: Build CLI + run: pnpm run cli:build + + - name: Check package contents + run: npm pack --dry-run --ignore-scripts + + - name: Publish to npm + run: npm publish + + - name: Verify published version + run: | + VERSION="$(node -p "require('./package.json').version")" + for attempt in {1..12}; do + PUBLISHED="$(npm view "pake-cli@${VERSION}" version --registry=https://registry.npmjs.org 2>/dev/null || true)" + if [ "$PUBLISHED" = "$VERSION" ]; then + echo "Published pake-cli@${VERSION}" + exit 0 + fi + echo "Waiting for npm registry to expose pake-cli@${VERSION} (attempt ${attempt}/12)" + sleep 10 + done + echo "pake-cli@${VERSION} was not visible in npm registry after publish" + exit 1 diff --git a/dist/cli.js b/dist/cli.js index 60c17c0e69..2ba465060f 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -31,7 +31,7 @@ var bin = { }; var repository = { type: "git", - url: "git+https://github.com/tw93/pake.git" + url: "git+https://github.com/tw93/Pake.git" }; var author = { name: "Tw93", @@ -62,6 +62,7 @@ var scripts = { test: "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", "format:check": "prettier --check . --ignore-unknown", + "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts", update: "pnpm update --verbose && cd src-tauri && cargo update", prepublishOnly: "pnpm run cli:build" }; diff --git a/package.json b/package.json index 26d37e7e86..19ca8434b5 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "repository": { "type": "git", - "url": "git+https://github.com/tw93/pake.git" + "url": "git+https://github.com/tw93/Pake.git" }, "author": { "name": "Tw93", @@ -42,6 +42,7 @@ "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", "format:check": "prettier --check . --ignore-unknown", + "release:check": "node scripts/check-release-version.mjs && pnpm run format:check && npx vitest run && pnpm run cli:build && npm pack --dry-run --ignore-scripts", "update": "pnpm update --verbose && cd src-tauri && cargo update", "prepublishOnly": "pnpm run cli:build" }, diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000000..4679af1275 --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const tag = process.argv[2] || process.env.GITHUB_REF_NAME; +const errors = []; + +function readText(filePath) { + return fs.readFileSync(path.join(root, filePath), "utf8"); +} + +function readJson(filePath) { + return JSON.parse(readText(filePath)); +} + +function expectEqual(label, actual, expected) { + if (actual !== expected) { + errors.push(`${label}: expected ${expected}, got ${actual || ""}`); + } +} + +function extractCargoVersion() { + const cargoToml = readText("src-tauri/Cargo.toml"); + const match = cargoToml.match(/^version\s*=\s*"([^"]+)"/m); + return match?.[1]; +} + +function extractCargoLockVersion() { + const cargoLock = readText("src-tauri/Cargo.lock"); + const match = cargoLock.match( + /\[\[package\]\]\s+name = "pake"\s+version = "([^"]+)"/, + ); + return match?.[1]; +} + +function extractDistVersion() { + const distCli = readText("dist/cli.js"); + const match = distCli.match( + /\b(?:var|let|const)\s+version\s*=\s*["']([^"']+)["'];/, + ); + return match?.[1]; +} + +const packageJson = readJson("package.json"); +const packageVersion = packageJson.version; +const expectedTag = tag || `V${packageVersion}`; + +if (!/^V\d+\.\d+\.\d+$/.test(expectedTag)) { + errors.push( + `release tag must match Vx.y.z, got ${expectedTag || ""}`, + ); +} else { + expectEqual("tag version", expectedTag.slice(1), packageVersion); +} + +expectEqual( + "src-tauri/Cargo.toml version", + extractCargoVersion(), + packageVersion, +); +expectEqual( + "src-tauri/Cargo.lock version", + extractCargoLockVersion(), + packageVersion, +); +expectEqual( + "src-tauri/tauri.conf.json version", + readJson("src-tauri/tauri.conf.json").version, + packageVersion, +); +expectEqual( + "dist/cli.js bundled version", + extractDistVersion(), + packageVersion, +); +expectEqual( + "package.json repository.url", + packageJson.repository?.url, + "git+https://github.com/tw93/Pake.git", +); + +if (errors.length > 0) { + console.error("Release version check failed:"); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`Release version check passed for V${packageVersion}`); From e1ffd0bad788f298499531ff7eb2231297d02d91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 00:18:10 +0000 Subject: [PATCH 031/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 49e177739b..e334ffa8b4 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -19,7 +19,7 @@ - + Tlntin @@ -30,7 +30,7 @@ - + jeasonnow @@ -140,7 +140,7 @@ - + GXiang314 @@ -206,7 +206,7 @@ - + mattbajorek @@ -261,7 +261,7 @@ - + g1eny0ung @@ -360,7 +360,7 @@ - + fvn-elmy @@ -393,7 +393,7 @@ - + droid-Q @@ -536,7 +536,7 @@ - + geekvest From f44468e75f812c115012b0a6249516bfc0ce5f25 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 15 May 2026 08:29:33 +0800 Subject: [PATCH 032/120] docs: harden release agent guidance --- .agents/skills/code-review/SKILL.md | 6 ++++-- .agents/skills/github-ops/SKILL.md | 9 ++++++++- .agents/skills/release/SKILL.md | 14 +++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index c2f9491b35..e352a7f148 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -1,7 +1,7 @@ --- name: code-review description: Pake project adapter for Waza check/code-review. Use for TypeScript CLI, Rust/Tauri, release artifact, and CI review. -version: 1.1.0 +version: 1.2.0 allowed-tools: - Bash - Read @@ -17,7 +17,9 @@ Use Waza `/check` for the generic review method. This adapter adds Pake-specific ## Pake-Specific Hard Stops - [ ] Changes under `bin/` rebuild and commit `dist/cli.js` with `pnpm run cli:build`. -- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` in sync. +- [ ] Changes to package metadata embedded by Rollup (`package.json` name/version/repository/bin/scripts/exports) rebuild and commit `dist/cli.js`. +- [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, and `src-tauri/tauri.conf.json` in sync. +- [ ] npm release workflow changes preserve Trusted Publishing: `.github/workflows/npm-publish.yml`, `id-token: write`, canonical `git+https://github.com/tw93/Pake.git`, and `scripts/check-release-version.mjs`. - [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. - [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. - [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. diff --git a/.agents/skills/github-ops/SKILL.md b/.agents/skills/github-ops/SKILL.md index 0f61037158..168cb655d7 100644 --- a/.agents/skills/github-ops/SKILL.md +++ b/.agents/skills/github-ops/SKILL.md @@ -1,7 +1,7 @@ --- name: github-ops description: GitHub issue, PR, and release operations via gh CLI. Not for code review or release builds. -version: 1.0.0 +version: 1.1.0 allowed-tools: - Bash - Read @@ -70,12 +70,17 @@ gh release view V3.10.0 # Check CI runs for a tag gh run list --workflow=release.yml +gh run list --workflow=npm-publish.yml # Watch a running CI job gh run watch # View CI run logs gh run view --log + +# Verify npm registry state after publish +npm view pake-cli version +npm view pake-cli@ dist.tarball ``` ## CI / Workflow Operations @@ -98,3 +103,5 @@ gh run rerun --failed-only 2. **NEVER** merge, close, or modify without explicit user request. 3. **ALWAYS** query current state before taking action — never assume. 4. Before replying to an issue or PR, read the body to confirm the author's language; match their language in the reply. This applies to the author, not to arbitrary thread commenters. +5. Before replying that a fix is released, verify the public artifact first: `npm view pake-cli version` for CLI releases or `gh release view ` for app releases. +6. Before closing an issue after release, confirm the target with `gh issue view --json number,title,state,author,url` and include the concrete version or upgrade command in the comment. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 19833a63d6..33d5e5180f 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -1,7 +1,7 @@ --- name: release description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. -version: 1.0.0 +version: 1.1.0 allowed-tools: - Bash - Read @@ -50,11 +50,19 @@ Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. 1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` 2. [ ] Watch CI status: `gh run watch` 3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` -4. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` -5. [ ] Verify npm published the package: `npm view pake-cli version` +4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` +5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +6. [ ] Verify npm published the package: `npm view pake-cli version` and `npm view pake-cli@X.Y.Z dist.tarball` npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. +## Trusted Publishing Notes + +- The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. +- npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. +- If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. + ## Build Commands (local only) ```bash From 55b7a38d3242cb39a6da6e4c07cb7d12fd9762df Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 00:30:08 +0000 Subject: [PATCH 033/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index e334ffa8b4..9a180095f2 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -19,7 +19,7 @@ - + Tlntin @@ -30,7 +30,7 @@ - + jeasonnow @@ -184,7 +184,7 @@ - + eltociear @@ -206,7 +206,7 @@ - + mattbajorek @@ -327,7 +327,7 @@ - + nekomeowww @@ -404,7 +404,7 @@ - + JohannLai @@ -492,7 +492,7 @@ - + fulldecent @@ -569,7 +569,7 @@ - + liusishan From 269cf202a1e6200ed91be8a6a52c5a54bad383fb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 01:08:32 +0000 Subject: [PATCH 034/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 9a180095f2..c6010fa35b 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -184,7 +184,7 @@ - + eltociear @@ -492,7 +492,7 @@ - + fulldecent @@ -503,7 +503,7 @@ - + beautifulrem From e9d94fc31147b98fc4f18586fcab5325f657f722 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 01:11:07 +0000 Subject: [PATCH 035/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index c6010fa35b..46d70b29d8 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -206,7 +206,7 @@ - + mattbajorek @@ -327,7 +327,7 @@ - + nekomeowww @@ -536,7 +536,7 @@ - + geekvest From 9783d7ef3927494c79f8eb8ad9320645f182e39f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 25 May 2026 09:04:10 +0800 Subject: [PATCH 036/120] fix: fall back from incompatible pnpm --- bin/builders/env.ts | 52 ++++++++++++++++++--- dist/cli.js | 39 +++++++++++++--- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- tests/unit/base-builder.test.ts | 80 +++++++++++++++++++++++++++++++++ 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/bin/builders/env.ts b/bin/builders/env.ts index bb4000078d..dfb1eecced 100644 --- a/bin/builders/env.ts +++ b/bin/builders/env.ts @@ -5,6 +5,7 @@ import { CN_MIRROR_ENV } from '@/utils/mirror'; import { IS_MAC } from '@/utils/platform'; import { npmDirectory } from '@/utils/dir'; import logger from '@/options/logger'; +import packageJson from '../../package.json'; /** * Returns build environment variables overrides for macOS, where Rust crates @@ -43,6 +44,28 @@ export function getBuildTimeout(): number { let packageManagerCache: 'pnpm' | 'npm' | null = null; +function parseMajorVersion(version: string): number | null { + const match = version.match(/^(\d+)/); + return match ? Number(match[1]) : null; +} + +function getPinnedPnpmMajorVersion(): number | null { + const packageManager = packageJson.packageManager; + const match = packageManager?.match(/^pnpm@(\d+)/); + return match ? Number(match[1]) : null; +} + +async function detectNpm( + execa: typeof import('execa').execa, +): Promise { + try { + await execa('npm', ['--version'], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + /** Resets the cached package manager. Exported for tests. */ export function _resetPackageManagerCache(): void { packageManagerCache = null; @@ -59,21 +82,36 @@ export async function detectPackageManager(): Promise<'pnpm' | 'npm'> { const { execa } = await import('execa'); try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); + const { stdout } = await execa('pnpm', ['--version']); + const pnpmMajor = parseMajorVersion(stdout.trim()); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + + if ( + pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor && + (await detectNpm(execa)) + ) { + logger.warn( + `✼ Detected pnpm v${stdout.trim()}, but Pake is pinned to ${packageJson.packageManager}; using npm for package installation instead.`, + ); + packageManagerCache = 'npm'; + return 'npm'; + } + logger.info('✺ Using pnpm for package management.'); packageManagerCache = 'pnpm'; return 'pnpm'; } catch { - try { - await execa('npm', ['--version'], { stdio: 'ignore' }); + if (await detectNpm(execa)) { logger.info('✺ pnpm not available, using npm for package management.'); packageManagerCache = 'npm'; return 'npm'; - } catch { - throw new Error( - 'Neither pnpm nor npm is available. Please install a package manager.', - ); } + + throw new Error( + 'Neither pnpm nor npm is available. Please install a package manager.', + ); } } diff --git a/dist/cli.js b/dist/cli.js index 2ba465060f..f646601d88 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.6"; +var version = "3.11.7"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" @@ -807,6 +807,24 @@ function getBuildTimeout() { return 900000; } let packageManagerCache = null; +function parseMajorVersion(version) { + const match = version.match(/^(\d+)/); + return match ? Number(match[1]) : null; +} +function getPinnedPnpmMajorVersion() { + const packageManager = packageJson.packageManager; + const match = packageManager?.match(/^pnpm@(\d+)/); + return match ? Number(match[1]) : null; +} +async function detectNpm(execa) { + try { + await execa('npm', ['--version'], { stdio: 'ignore' }); + return true; + } + catch { + return false; + } +} /** * Returns 'pnpm' when available, otherwise 'npm'. Throws if neither is found. * Cached after the first successful detection so tests can call repeatedly. @@ -817,21 +835,28 @@ async function detectPackageManager() { } const { execa } = await import('execa'); try { - await execa('pnpm', ['--version'], { stdio: 'ignore' }); + const { stdout } = await execa('pnpm', ['--version']); + const pnpmMajor = parseMajorVersion(stdout.trim()); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + if (pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor && + (await detectNpm(execa))) { + logger.warn(`✼ Detected pnpm v${stdout.trim()}, but Pake is pinned to ${packageJson.packageManager}; using npm for package installation instead.`); + packageManagerCache = 'npm'; + return 'npm'; + } logger.info('✺ Using pnpm for package management.'); packageManagerCache = 'pnpm'; return 'pnpm'; } catch { - try { - await execa('npm', ['--version'], { stdio: 'ignore' }); + if (await detectNpm(execa)) { logger.info('✺ pnpm not available, using npm for package management.'); packageManagerCache = 'npm'; return 'npm'; } - catch { - throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); - } + throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); } } function getInstallCommand(packageManager, useCnMirror) { diff --git a/package.json b/package.json index 19ca8434b5..1b5d080660 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.6", + "version": "3.11.7", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a571370123..108dcb1032 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.6" +version = "3.11.7" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8b4c00db3b..0206abb2cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.6" +version = "3.11.7" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 368ebf1148..02b21dd804 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.6", + "version": "3.11.7", "app": { "withGlobalTauri": true, "trayIcon": { diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 84db127e82..7a47415138 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -3,6 +3,12 @@ import path from 'path'; import fsExtra from 'fs-extra'; import { afterEach, describe, expect, it, vi } from 'vitest'; +const execaMock = vi.hoisted(() => vi.fn()); + +vi.mock('execa', () => ({ + execa: execaMock, +})); + vi.mock('@/utils/dir', () => ({ npmDirectory: process.cwd(), tauriConfigDirectory: path.join(process.cwd(), 'src-tauri', '.pake'), @@ -10,7 +16,9 @@ vi.mock('@/utils/dir', () => ({ import BaseBuilder from '@/builders/BaseBuilder'; import { + _resetPackageManagerCache, configureCargoRegistry, + detectPackageManager, getBuildEnvironment, getInstallCommand, } from '@/builders/env'; @@ -56,9 +64,30 @@ async function createCargoFixture(projectConfig?: string) { return { tauriSrcPath, projectConf, projectCnConf }; } +function mockPackageManagers(options: { + pnpm?: string | Error; + npm?: string | Error; +}) { + execaMock.mockImplementation(async (command: string) => { + const value = options[command as 'pnpm' | 'npm']; + + if (value instanceof Error) { + throw value; + } + + if (typeof value === 'string') { + return { stdout: value }; + } + + throw new Error(`${command} not found`); + }); +} + describe('BaseBuilder guards', () => { afterEach(async () => { vi.restoreAllMocks(); + execaMock.mockReset(); + _resetPackageManagerCache(); if (originalCnMirrorEnv === undefined) { delete process.env[CN_MIRROR_ENV]; @@ -138,6 +167,57 @@ describe('BaseBuilder guards', () => { ); }); + it('uses pnpm when the installed major matches the pinned package manager', async () => { + mockPackageManagers({ pnpm: '10.26.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('pnpm'); + expect(execaMock).toHaveBeenCalledTimes(1); + expect(execaMock).toHaveBeenCalledWith('pnpm', ['--version']); + }); + + it('falls back to npm when the installed pnpm major does not match the pinned major', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + mockPackageManagers({ pnpm: '11.2.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + expect(execaMock).toHaveBeenCalledWith('pnpm', ['--version']); + expect(execaMock).toHaveBeenCalledWith('npm', ['--version'], { + stdio: 'ignore', + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('using npm for package installation instead'), + ); + }); + + it('falls back to npm when pnpm is unavailable', async () => { + mockPackageManagers({ pnpm: new Error('missing pnpm'), npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + }); + + it('throws when neither pnpm nor npm is available', async () => { + mockPackageManagers({ + pnpm: new Error('missing pnpm'), + npm: new Error('missing npm'), + }); + + await expect(detectPackageManager()).rejects.toThrow( + 'Neither pnpm nor npm is available', + ); + }); + + it('caches the detected package manager until reset', async () => { + mockPackageManagers({ pnpm: '10.26.2' }); + + await expect(detectPackageManager()).resolves.toBe('pnpm'); + mockPackageManagers({ pnpm: new Error('missing pnpm'), npm: '11.12.1' }); + await expect(detectPackageManager()).resolves.toBe('pnpm'); + expect(execaMock).toHaveBeenCalledTimes(1); + + _resetPackageManagerCache(); + await expect(detectPackageManager()).resolves.toBe('npm'); + }); + it('copies Cargo mirror config only when CN mirror is enabled', async () => { const { tauriSrcPath, projectConf, projectCnConf } = await createCargoFixture(); From e8a6804f2539ebe737d197d3a4fc1747380b1d4f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:45:35 +0000 Subject: [PATCH 037/120] fix: save webview downloads natively + silence unused var warning Combine and refine two community fixes: - Save blob:/data:/Content-Disposition downloads via a native WebViewWindowBuilder::on_download handler instead of routing bytes through the Tauri IPC. The IPC origin (http://ipc.localhost) is blocked by strict-CSP sites (e.g. Gemini) and is unreachable from sandboxed iframes, so downloads silently failed while the page still reported success. The native path is independent of the page CSP and the IPC channel. (closes #1157) - Drop the document.createElement("a") interceptor and let blob:/data: anchor clicks fall through to the native download path. - Prefix the macOS-only `enable_find` binding with `_` to suppress the unused-variable warning on Windows/Linux builds. Refinements on top of the original PRs: - Toast on failed downloads too (not only success), so a failure is never silent again. - Use `next_back()` instead of `last()` on the path-segment iterator. Co-authored-by: Yushi47 <43625745+Yushi47@users.noreply.github.com> Co-authored-by: reblox01 <74146687+reblox01@users.noreply.github.com> https://claude.ai/code/session_018R2ceRrmzCUc13pSv9cXf5 --- src-tauri/src/app/window.rs | 61 +++++++++++++++++++++++++++++++++-- src-tauri/src/inject/event.js | 47 ++++++--------------------- src-tauri/src/lib.rs | 4 +-- 3 files changed, 71 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 6448d6d7f6..11f94af375 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -1,12 +1,14 @@ use crate::app::config::PakeConfig; -use crate::util::get_data_dir; +use crate::util::{ + check_file_or_append, get_data_dir, get_download_message_with_lang, show_toast, MessageType, +}; use std::{ path::PathBuf, str::FromStr, sync::atomic::{AtomicU32, Ordering}, }; use tauri::{ - webview::{NewWindowFeatures, NewWindowResponse}, + webview::{DownloadEvent, NewWindowFeatures, NewWindowResponse}, AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; @@ -426,6 +428,61 @@ fn build_window( } } + // Capture webview-initiated downloads (blob:, data:, Content-Disposition, + // etc.) and write them to the OS Downloads folder. This is essential for + // sites with a strict Content-Security-Policy (e.g. Gemini): their + // `connect-src` blocks Tauri's IPC origin, so downloads cannot be routed + // through the JS bridge, and downloads triggered from a sandboxed iframe + // can't reach the IPC either. Letting the browser download natively and + // catching it here is independent of the page CSP and the IPC channel. + { + let download_handle = app.clone(); + window_builder = window_builder.on_download(move |_webview, event| match event { + DownloadEvent::Requested { url, destination } => { + match download_handle.path().download_dir() { + Ok(download_dir) => { + let filename = destination + .file_name() + .map(|name| name.to_string_lossy().to_string()) + .filter(|name| !name.is_empty()) + .or_else(|| { + url.path_segments() + .and_then(|mut segments| segments.next_back()) + .map(|segment| segment.to_string()) + .filter(|segment| !segment.is_empty()) + }) + .unwrap_or_else(|| "download".to_string()); + + let target = download_dir.join(filename); + if let Some(path_str) = target.to_str() { + *destination = PathBuf::from(check_file_or_append(path_str)); + } + } + Err(error) => { + eprintln!("[Pake] Failed to resolve download dir: {error}"); + } + } + true + } + DownloadEvent::Finished { + url: _, + path: _, + success, + } => { + if let Some(window) = download_handle.get_webview_window("pake") { + let message_type = if success { + MessageType::Success + } else { + MessageType::Failure + }; + show_toast(&window, &get_download_message_with_lang(message_type, None)); + } + true + } + _ => true, + }); + } + window_builder = window_builder.on_navigation(|_| true); window_builder.build() diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index f3bb7cf90c..d5eb88ce89 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -423,37 +423,6 @@ document.addEventListener("DOMContentLoaded", () => { }); } - // 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)); @@ -587,11 +556,16 @@ document.addEventListener("DOMContentLoaded", () => { return; } - // Process download links for Rust to handle. - if ( - isDownloadRequired(absoluteUrl, anchorElement, e) && - !isSpecialDownload(absoluteUrl) - ) { + // Process download links. + if (isDownloadRequired(absoluteUrl, anchorElement, e)) { + // Let the browser download blob:/data: URLs natively; the Rust + // on_download handler saves them to the Downloads folder. Routing them + // through the IPC fails on strict-CSP sites (e.g. Gemini), whose + // connect-src blocks the IPC origin, and on downloads triggered from a + // sandboxed iframe where the IPC can't be reached. + if (isSpecialDownload(absoluteUrl)) { + return; + } e.preventDefault(); e.stopImmediatePropagation(); const userLanguage = getUserLanguage(); @@ -626,7 +600,6 @@ document.addEventListener("DOMContentLoaded", () => { document.addEventListener("click", detectAnchorElementClick, true); collectUrlToBlobs(); - detectDownloadByCreateAnchor(); // Rewrite the window.open function. const originalWindowOpen = window.open; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01c1434ebf..c4c90ad936 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -43,7 +43,7 @@ pub fn run_app() { let start_to_tray = pake_config.windows[0].start_to_tray && show_system_tray; // Only valid when tray is enabled let multi_instance = pake_config.multi_instance; let multi_window = pake_config.multi_window; - let enable_find = pake_config.windows[0].enable_find; + let _enable_find = pake_config.windows[0].enable_find; let window_state_plugin = WindowStatePlugin::default() .with_state_flags(if init_fullscreen { @@ -99,7 +99,7 @@ pub fn run_app() { // --- Menu Construction Start --- #[cfg(target_os = "macos")] { - app::menu::set_app_menu(app.app_handle(), multi_window, enable_find)?; + app::menu::set_app_menu(app.app_handle(), multi_window, _enable_find)?; // Event Handling for Custom Menu Item app.on_menu_event(move |app_handle, event| { From dcb30b49c3698cce4d6b211922ff333802625064 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 10:47:13 +0000 Subject: [PATCH 038/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 46d70b29d8..aca5850332 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -135,13 +135,13 @@ - + - - - GXiang314 + + + gxiang314 @@ -404,7 +404,7 @@ - + JohannLai @@ -569,7 +569,7 @@ - + liusishan From 66ae24b9d125790a38c9ea0dbc2b522f23c4125c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 10:53:02 +0000 Subject: [PATCH 039/120] fix: save right-click image/video downloads natively under strict CSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom context-menu "save image/video" still routed blob:/data: media through the Tauri IPC (download_file_by_binary), which strict-CSP sites such as Gemini block at connect-src — the same root cause as #1157. Trigger a native browser download via a transient anchor click instead, so the Rust on_download handler saves it independently of the page CSP and the IPC. This makes the native on_download path the single capture point for all blob:/data: downloads, so the now-orphaned IPC machinery is removed: - event.js: add triggerNativeDownload; use it for blob:/data: in downloadImage; drop the dead helpers (downloadFromDataUri, downloadFromBlobUrl, convertBlobUrlToBinary) and the collectUrlToBlobs/blobToUrlCaches createObjectURL override. - invoke.rs / lib.rs: remove the unused download_file_by_binary command and its BinaryDownloadParams; drop the now-unused fs self-import. https://claude.ai/code/session_018R2ceRrmzCUc13pSv9cXf5 --- src-tauri/src/app/invoke.rs | 50 +--------------- src-tauri/src/inject/event.js | 106 ++++++---------------------------- src-tauri/src/lib.rs | 6 +- 3 files changed, 20 insertions(+), 142 deletions(-) diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index 82022f4246..4a6d5c319e 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -1,5 +1,5 @@ use crate::util::{check_file_or_append, get_download_message_with_lang, show_toast, MessageType}; -use std::fs::{self, File}; +use std::fs::File; use std::io::Write; use std::str::FromStr; use std::sync::atomic::{AtomicI64, Ordering}; @@ -73,13 +73,6 @@ pub struct DownloadFileParams { language: Option, } -#[derive(serde::Deserialize)] -pub struct BinaryDownloadParams { - filename: String, - binary: Vec, - language: Option, -} - #[derive(serde::Deserialize)] pub struct NotificationParams { title: String, @@ -147,47 +140,6 @@ pub async fn download_file(app: AppHandle, params: DownloadFileParams) -> Result } } -#[command] -pub async fn download_file_by_binary( - app: AppHandle, - params: BinaryDownloadParams, -) -> Result<(), String> { - let window: WebviewWindow = app.get_webview_window("pake").ok_or("Window not found")?; - - show_toast( - &window, - &get_download_message_with_lang(MessageType::Start, params.language.clone()), - ); - - let download_dir = app - .path() - .download_dir() - .map_err(|e| format!("Failed to get download dir: {}", e))?; - - let output_path = download_dir.join(¶ms.filename); - - let path_str = output_path.to_str().ok_or("Invalid output path")?; - - let file_path = check_file_or_append(path_str); - - match fs::write(file_path, ¶ms.binary) { - Ok(_) => { - show_toast( - &window, - &get_download_message_with_lang(MessageType::Success, params.language.clone()), - ); - Ok(()) - } - Err(e) => { - show_toast( - &window, - &get_download_message_with_lang(MessageType::Failure, params.language), - ); - Err(e.to_string()) - } - } -} - #[command] pub fn send_notification(app: AppHandle, params: NotificationParams) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index d5eb88ce89..7e327e70ad 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -340,87 +340,19 @@ document.addEventListener("DOMContentLoaded", () => { true, ); - // Collect blob urls to blob by overriding window.URL.createObjectURL - function collectUrlToBlobs() { - const backupCreateObjectURL = window.URL.createObjectURL; - window.blobToUrlCaches = new Map(); - window.URL.createObjectURL = (blob) => { - const url = backupCreateObjectURL.call(window.URL, blob); - window.blobToUrlCaches.set(url, blob); - return url; - }; - } - - function convertBlobUrlToBinary(blobUrl) { - return new Promise((resolve, reject) => { - const blob = window.blobToUrlCaches.get(blobUrl); - if (!blob) { - fetch(blobUrl) - .then((res) => res.arrayBuffer()) - .then((buffer) => resolve(Array.from(new Uint8Array(buffer)))) - .catch(reject); - return; - } - const reader = new FileReader(); - reader.readAsArrayBuffer(blob); - reader.onload = () => { - resolve(Array.from(new Uint8Array(reader.result))); - }; - reader.onerror = () => reject(reader.error); - }); - } - - function downloadFromDataUri(dataURI, filename) { - try { - const byteString = atob(dataURI.split(",")[1]); - // write the bytes of the string to an ArrayBuffer - const bufferArray = new ArrayBuffer(byteString.length); - - // create a view into the buffer - const binary = new Uint8Array(bufferArray); - - // set the bytes of the buffer to the correct values - for (let i = 0; i < byteString.length; i++) { - binary[i] = byteString.charCodeAt(i); - } - - // write the ArrayBuffer to a binary, and you're done - const userLanguage = getUserLanguage(); - invoke("download_file_by_binary", { - params: { - filename, - binary: Array.from(binary), - language: userLanguage, - }, - }).catch((error) => { - console.error("Failed to download data URI file:", filename, error); - showDownloadError(filename); - }); - } catch (error) { - console.error("Failed to process data URI:", dataURI, error); - showDownloadError(filename || "file"); - } - } - - function downloadFromBlobUrl(blobUrl, filename) { - convertBlobUrlToBinary(blobUrl) - .then((binary) => { - const userLanguage = getUserLanguage(); - invoke("download_file_by_binary", { - params: { - filename, - binary, - language: userLanguage, - }, - }).catch((error) => { - console.error("Failed to download blob file:", filename, error); - showDownloadError(filename); - }); - }) - .catch((error) => { - console.error("Failed to convert blob to binary:", blobUrl, error); - showDownloadError(filename); - }); + // Trigger a native browser download via a transient anchor click. The Rust + // on_download handler then writes the file to the Downloads folder. This is + // used for blob:/data: URLs because routing their bytes through the Tauri + // IPC fails on strict-CSP sites (e.g. Gemini), whose connect-src blocks the + // IPC origin. The native download path is independent of the page CSP. + function triggerNativeDownload(url, filename) { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename || ""; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); } // process special download protocol['data:','blob:'] @@ -599,8 +531,6 @@ document.addEventListener("DOMContentLoaded", () => { // Prevent some special websites from executing in advance, before the click event is triggered. document.addEventListener("click", detectAnchorElementClick, true); - collectUrlToBlobs(); - // Rewrite the window.open function. const originalWindowOpen = window.open; window.open = function (url, name, specs) { @@ -836,12 +766,10 @@ document.addEventListener("DOMContentLoaded", () => { const filename = getFilenameFromUrl(imageUrl) || "image"; // Handle different URL types - if (imageUrl.startsWith("data:")) { - downloadFromDataUri(imageUrl, filename); - } else if (imageUrl.startsWith("blob:")) { - if (window.blobToUrlCaches && window.blobToUrlCaches.has(imageUrl)) { - downloadFromBlobUrl(imageUrl, filename); - } + if (isSpecialDownload(imageUrl)) { + // Download blob:/data: natively so it works under strict CSP; the Rust + // on_download handler saves it to the Downloads folder. + triggerNativeDownload(imageUrl, filename); } else { // Regular HTTP(S) image const userLanguage = getUserLanguage(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4c90ad936..93c6c0f9e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,9 +13,8 @@ const WINDOW_SHOW_DELAY: u64 = 50; use app::{ invoke::{ - clear_cache_and_restart, clear_dock_badge, download_file, download_file_by_binary, - increment_dock_badge, send_notification, set_dock_badge, set_dock_badge_label, - update_theme_mode, + clear_cache_and_restart, clear_dock_badge, download_file, increment_dock_badge, + send_notification, set_dock_badge, set_dock_badge_label, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, @@ -81,7 +80,6 @@ pub fn run_app() { app_builder .invoke_handler(tauri::generate_handler![ download_file, - download_file_by_binary, send_notification, increment_dock_badge, set_dock_badge, From f3df88e6cedf70fd9514c5c548b44f0a8920c8bd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 11:01:57 +0000 Subject: [PATCH 040/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index aca5850332..7a2c604001 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -360,7 +360,7 @@ - + fvn-elmy From e43c082bd6299b41cc5e933a4501c694de1277aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 13:54:02 +0000 Subject: [PATCH 041/120] chore: add use-pake skill documentation Document the Pake packaging workflow and CLI options as an agent skill, so AI agents / contributors can drive `node dist/cli.js` correctly. Refinements over the original PR #1203: - Drop the author-specific `~/contrib/Pake` hardcoded path; reference the repository root instead. - Note the `pnpm run cli:build` prerequisite (dist/cli.js is a build artifact). - Align frontmatter with the existing skills (add version + allowed-tools). Co-authored-by: only1zf <213174+only1zf@users.noreply.github.com> https://claude.ai/code/session_018R2ceRrmzCUc13pSv9cXf5 --- .agents/skills/use-pake/SKILL.md | 170 +++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 .agents/skills/use-pake/SKILL.md diff --git a/.agents/skills/use-pake/SKILL.md b/.agents/skills/use-pake/SKILL.md new file mode 100644 index 0000000000..ad7097b1ce --- /dev/null +++ b/.agents/skills/use-pake/SKILL.md @@ -0,0 +1,170 @@ +--- +name: use-pake +description: "Package any website into a lightweight desktop app using Pake (Tauri/Rust). Use when the user wants to: wrap a URL as a native app, build a desktop app from a website, use Pake CLI to package a page, set up proxy for a packaged app, customize app icons or bundle IDs, or mentions 'pake', 'tauri package', 'website to app', 'wrap site'. Also trigger when the user asks about Pake CLI options, proxy configuration for packaged apps, or icon handling." +version: 1.0.0 +allowed-tools: + - Bash + - Read +--- + +# Pake - Website to Desktop App + +Pake wraps any webpage into a native desktop app via Tauri (Rust + system WebView). Output is ~5MB (vs Electron's ~150MB). Run the commands below from the root of this Pake repository. + +## Quick Start + +```bash +# Build the CLI once (produces dist/cli.js); skip if dist/ already exists +pnpm run cli:build + +node dist/cli.js "" --name [options] +``` + +## Workflow + +### 1. Gather Requirements + +Before running the build, confirm these with the user: + +| Parameter | Why it matters | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| URL | The target website | +| Name | Becomes the `.app` / `.exe` name and productName | +| Bundle ID (`--identifier`) | Defaults to `com.pake.a{md5hash}` — set explicitly for clean installs (e.g. `com.pake.youtube`) | +| Proxy (`--proxy-url`) | Baked into the binary at build time; not runtime-configurable. Format: `http://host:port` or `socks5://host:port` | +| Icon | Auto-fetched from website if omitted; can be a local path or URL | + +### 2. Handle Icons + +Pake auto-fetches icons from multiple services (logo.dev, brandfetch, clearbit, Google favicons, direct favicon.ico). However: + +**The icon download does NOT use `--proxy-url`.** That flag only configures the packaged app's WebView proxy. If the icon URL requires a proxy to reach (e.g. Google/GitHub assets from China), download it manually first: + +```bash +curl -x http://: -o /tmp/icon.png "" --connect-timeout 15 -s +``` + +Then pass the local file: + +```bash +node dist/cli.js "" --icon /tmp/icon.png ... +``` + +**Icon tips:** + +- Prefer 256x256 or larger PNGs; SVG also works +- Pake auto-converts to platform format: `.icns` (macOS), `.ico` (Windows), `.png` (Linux) +- macOS icons get a squircle mask automatically +- If an old icon exists in `src-tauri/icons/.icns`, Pake reuses it — rename/remove it to force re-fetch + +### 3. Build + +```bash +node dist/cli.js "" \ + --name \ + --identifier \ + --proxy-url "http://host:port" \ + --icon /path/to/icon.png +``` + +Build takes ~40s with cache, ~2min cold. Output: `.dmg` in the project root. + +### 4. Verify + +After build, confirm: + +- DMG path printed in output +- Icon correctness (user should open and check) +- Bundle ID via: `mdls -name kMDItemCFBundleIdentifier .app` + +## CLI Options Reference + +### Common Options + +| Option | Default | Description | +| ------------------------ | ------------------ | --------------------------------- | +| `--name ` | — | App name | +| `--icon ` | auto-fetch | Icon path (local file or URL) | +| `--identifier ` | `com.pake.a{hash}` | Bundle ID / app identifier | +| `--proxy-url ` | — | WebView proxy (http/https/socks5) | +| `--width ` | 1200 | Window width | +| `--height ` | 780 | Window height | +| `--app-version ` | 1.0.0 | App version | + +### Window Behavior + +| Option | Default | Description | +| ------------------ | ------- | -------------------- | +| `--fullscreen` | false | Start fullscreen | +| `--maximize` | false | Start maximized | +| `--always-on-top` | false | Pin window on top | +| `--hide-title-bar` | false | Hide macOS title bar | + +### Advanced + +| Option | Default | Description | +| ----------------------------- | ------- | -------------------------------------------- | +| `--inject ` | — | Inject CSS/JS files (comma-separated paths) | +| `--user-agent ` | — | Custom user agent | +| `--debug` | false | Enable devtools and verbose logging | +| `--multi-arch` | false | Build for both Intel and Apple Silicon | +| `--multi-instance` | false | Allow multiple app instances | +| `--multi-window` | false | Multiple windows in one instance | +| `--new-window` | false | Allow popup windows (needed for OAuth flows) | +| `--incognito` | false | Private browsing mode | +| `--dark-mode` | false | Force macOS dark mode | +| `--zoom ` | 100 | Initial zoom level (50-200) | +| `--wasm` | false | Enable WebAssembly | +| `--enable-drag-drop` | false | Drag & drop support | +| `--camera` | false | Camera permission (macOS) | +| `--microphone` | false | Microphone permission (macOS) | +| `--ignore-certificate-errors` | false | Ignore TLS errors | +| `--targets ` | auto | Build target format | +| `--use-local-file` | false | Package local HTML file | + +## Platform Notes + +### Proxy Support + +| Platform | Status | Notes | +| -------- | --------- | -------------------------------------------------------------------- | +| Windows | Full | Via `--proxy-server` browser arg | +| Linux | Full | Via `--proxy-server` browser arg | +| macOS | macOS 14+ | Uses Tauri native `macos-proxy` feature; auto-detected at build time | + +### Chrome Extensions + +Not supported. Pake uses system WebView (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux), not a full Chrome browser. Use `--inject` to add custom JS/CSS as an alternative. + +## Common Patterns + +### Website behind proxy (icon also needs proxy) + +```bash +# Step 1: Download icon via proxy +curl -x http://127.0.0.1:7890 -o /tmp/icon.png "" -s + +# Step 2: Build with local icon +node dist/cli.js "https://example.com" \ + --name MyApp \ + --identifier com.pake.myapp \ + --proxy-url "http://127.0.0.1:7890" \ + --icon /tmp/icon.png +``` + +### Force icon re-fetch + +```bash +# Remove cached icon, then build without --icon +mv src-tauri/icons/.icns src-tauri/icons/.icns.bak +node dist/cli.js "https://example.com" --name MyApp +``` + +### OAuth-dependent site + +```bash +node dist/cli.js "https://accounts.google.com" \ + --name GoogleApp \ + --new-window \ + --ignore-certificate-errors +``` From 34e944ab92fbe5889ea279973231f0f9543dcc48 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 30 May 2026 13:54:53 +0000 Subject: [PATCH 042/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 7a2c604001..aca5850332 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -360,7 +360,7 @@ - + fvn-elmy From ed7c03c947093e09bad2cce528aee058d33d0822 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 01:16:05 +0000 Subject: [PATCH 043/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index aca5850332..7a2c604001 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -360,7 +360,7 @@ - + fvn-elmy From 170fb92180fee6a1ca0ac862f2c077ddb61c46b4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 1 Jun 2026 14:25:38 +0800 Subject: [PATCH 044/120] fix: clarify pnpm fallback handling --- bin/builders/env.ts | 52 ++++++++++++++++++++------------- dist/cli.js | 35 +++++++++++++--------- tests/unit/base-builder.test.ts | 26 ++++++++++++++++- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/bin/builders/env.ts b/bin/builders/env.ts index dfb1eecced..aaf320bf00 100644 --- a/bin/builders/env.ts +++ b/bin/builders/env.ts @@ -45,7 +45,7 @@ export function getBuildTimeout(): number { let packageManagerCache: 'pnpm' | 'npm' | null = null; function parseMajorVersion(version: string): number | null { - const match = version.match(/^(\d+)/); + const match = version.match(/^v?(\d+)/); return match ? Number(match[1]) : null; } @@ -81,27 +81,11 @@ export async function detectPackageManager(): Promise<'pnpm' | 'npm'> { } const { execa } = await import('execa'); + let pnpmVersion: string; + try { const { stdout } = await execa('pnpm', ['--version']); - const pnpmMajor = parseMajorVersion(stdout.trim()); - const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); - - if ( - pnpmMajor !== null && - pinnedPnpmMajor !== null && - pnpmMajor !== pinnedPnpmMajor && - (await detectNpm(execa)) - ) { - logger.warn( - `✼ Detected pnpm v${stdout.trim()}, but Pake is pinned to ${packageJson.packageManager}; using npm for package installation instead.`, - ); - packageManagerCache = 'npm'; - return 'npm'; - } - - logger.info('✺ Using pnpm for package management.'); - packageManagerCache = 'pnpm'; - return 'pnpm'; + pnpmVersion = stdout.trim(); } catch { if (await detectNpm(execa)) { logger.info('✺ pnpm not available, using npm for package management.'); @@ -113,6 +97,34 @@ export async function detectPackageManager(): Promise<'pnpm' | 'npm'> { 'Neither pnpm nor npm is available. Please install a package manager.', ); } + + const normalizedPnpmVersion = pnpmVersion.startsWith('v') + ? pnpmVersion + : `v${pnpmVersion}`; + const pnpmMajor = parseMajorVersion(pnpmVersion); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + + if ( + pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor + ) { + if (!(await detectNpm(execa))) { + throw new Error( + `Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}. Install npm so Pake can fall back, or use pnpm ${pinnedPnpmMajor}.x to match the project pin.`, + ); + } + + logger.warn( + `✼ Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}; using npm for package management instead.`, + ); + packageManagerCache = 'npm'; + return 'npm'; + } + + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; } export function getInstallCommand( diff --git a/dist/cli.js b/dist/cli.js index f646601d88..3b51b1a5f5 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -808,7 +808,7 @@ function getBuildTimeout() { } let packageManagerCache = null; function parseMajorVersion(version) { - const match = version.match(/^(\d+)/); + const match = version.match(/^v?(\d+)/); return match ? Number(match[1]) : null; } function getPinnedPnpmMajorVersion() { @@ -834,21 +834,10 @@ async function detectPackageManager() { return packageManagerCache; } const { execa } = await import('execa'); + let pnpmVersion; try { const { stdout } = await execa('pnpm', ['--version']); - const pnpmMajor = parseMajorVersion(stdout.trim()); - const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); - if (pnpmMajor !== null && - pinnedPnpmMajor !== null && - pnpmMajor !== pinnedPnpmMajor && - (await detectNpm(execa))) { - logger.warn(`✼ Detected pnpm v${stdout.trim()}, but Pake is pinned to ${packageJson.packageManager}; using npm for package installation instead.`); - packageManagerCache = 'npm'; - return 'npm'; - } - logger.info('✺ Using pnpm for package management.'); - packageManagerCache = 'pnpm'; - return 'pnpm'; + pnpmVersion = stdout.trim(); } catch { if (await detectNpm(execa)) { @@ -858,6 +847,24 @@ async function detectPackageManager() { } throw new Error('Neither pnpm nor npm is available. Please install a package manager.'); } + const normalizedPnpmVersion = pnpmVersion.startsWith('v') + ? pnpmVersion + : `v${pnpmVersion}`; + const pnpmMajor = parseMajorVersion(pnpmVersion); + const pinnedPnpmMajor = getPinnedPnpmMajorVersion(); + if (pnpmMajor !== null && + pinnedPnpmMajor !== null && + pnpmMajor !== pinnedPnpmMajor) { + if (!(await detectNpm(execa))) { + throw new Error(`Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}. Install npm so Pake can fall back, or use pnpm ${pinnedPnpmMajor}.x to match the project pin.`); + } + logger.warn(`✼ Detected pnpm ${normalizedPnpmVersion}, but Pake is pinned to ${packageJson.packageManager}; using npm for package management instead.`); + packageManagerCache = 'npm'; + return 'npm'; + } + logger.info('✺ Using pnpm for package management.'); + packageManagerCache = 'pnpm'; + return 'pnpm'; } function getInstallCommand(packageManager, useCnMirror) { const registryOption = useCnMirror diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 7a47415138..6e97fe0abd 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -185,10 +185,34 @@ describe('BaseBuilder guards', () => { stdio: 'ignore', }); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('using npm for package installation instead'), + expect.stringContaining('using npm for package management instead'), ); }); + it('parses v-prefixed pnpm versions before comparing majors', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + mockPackageManagers({ pnpm: 'v11.2.2', npm: '11.12.1' }); + + await expect(detectPackageManager()).resolves.toBe('npm'); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Detected pnpm v11.2.2'), + ); + }); + + it('throws a clear error when pnpm is incompatible and npm is unavailable', async () => { + mockPackageManagers({ + pnpm: '11.2.2', + npm: new Error('missing npm'), + }); + + await expect(detectPackageManager()).rejects.toThrow( + 'Detected pnpm v11.2.2, but Pake is pinned to pnpm@10.26.2', + ); + expect(execaMock).toHaveBeenCalledWith('npm', ['--version'], { + stdio: 'ignore', + }); + }); + it('falls back to npm when pnpm is unavailable', async () => { mockPackageManagers({ pnpm: new Error('missing pnpm'), npm: '11.12.1' }); From 35fc57d069e42a2772b76eca200ed20e239469a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 06:28:22 +0000 Subject: [PATCH 045/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 7a2c604001..aca5850332 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -360,7 +360,7 @@ - + fvn-elmy From 64a867aaadca37a4e71a274a20a5c53aae968374 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 6 Jun 2026 07:35:51 +0800 Subject: [PATCH 046/120] fix: distinguish gtk/gdk-pixbuf AppImage failures from strip errors linuxdeploy gtk-plugin failures (missing gdk-pixbuf loaders) were misreported as a strip incompatibility, suggesting NO_STRIP=1 even when already set. Give them dedicated guidance and skip the useless retry. --- bin/builders/env.ts | 8 ++++ bin/utils/shell.ts | 92 +++++++++++++++++++++++++++++++-------------- dist/cli.js | 87 ++++++++++++++++++++++++++++++------------ 3 files changed, 133 insertions(+), 54 deletions(-) diff --git a/bin/builders/env.ts b/bin/builders/env.ts index aaf320bf00..033a2b46ce 100644 --- a/bin/builders/env.ts +++ b/bin/builders/env.ts @@ -214,6 +214,14 @@ export function isLinuxDeployStripError(error: unknown): boolean { return false; } const message = error.message.toLowerCase(); + // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy but is not a + // strip issue, so a NO_STRIP retry won't help. Don't treat it as one. + if ( + message.includes('gdk-pixbuf') || + message.includes('failed to run plugin: gtk') + ) { + return false; + } return ( message.includes('linuxdeploy') || message.includes('failed to run linuxdeploy') || diff --git a/bin/utils/shell.ts b/bin/utils/shell.ts index e857d8f74c..64c1e1e7e2 100644 --- a/bin/utils/shell.ts +++ b/bin/utils/shell.ts @@ -29,40 +29,74 @@ export async function shellExec( let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; - // Provide helpful guidance for common Linux AppImage build failures - // caused by strip tool incompatibility with modern glibc (2.38+) + // Provide targeted guidance for common Linux AppImage build failures. + // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy/appimage but + // is unrelated to strip, so it must be matched first and given its own hint. const lowerError = errorMessage.toLowerCase(); + const divider = + '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - if ( - process.platform === 'linux' && - (lowerError.includes('linuxdeploy') || + if (process.platform === 'linux') { + const isGtkPixbufFailure = + lowerError.includes('gdk-pixbuf') || + lowerError.includes('failed to run plugin: gtk'); + const isAppImageStripFailure = + 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' + - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + lowerError.includes('strip'); + const noStripActive = !!(env?.NO_STRIP || process.env.NO_STRIP); - if ( - lowerError.includes('fuse') || - lowerError.includes('operation not permitted') || - lowerError.includes('/dev/fuse') - ) { + if (isGtkPixbufFailure) { 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.'; + `\n\n${divider}\n` + + 'Linux AppImage Build Failed\n' + + `${divider}\n\n` + + "Cause: linuxdeploy's gtk plugin could not find the gdk-pixbuf loaders\n" + + ' (e.g. "cannot stat \'/usr/lib/gdk-pixbuf-2.0/...\'").\n\n' + + 'Quick fix:\n' + + ' • Install the gdk-pixbuf loaders for your distro:\n' + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' • Refresh the loader cache, then rebuild:\n' + + ' gdk-pixbuf-query-loaders --update-cache\n\n' + + 'Alternative:\n' + + ' • Use DEB format instead: pake --targets deb\n' + + ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + divider; + } else if (isAppImageStripFailure) { + errorMsg += + `\n\n${divider}\n` + + 'Linux AppImage Build Failed\n' + + `${divider}\n\n` + + 'Cause: Strip tool incompatibility with glibc 2.38+\n' + + ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n'; + + errorMsg += noStripActive + ? 'NO_STRIP=1 is already set but the build still failed, so this is\n' + + 'likely not a strip issue. Try the alternatives below or rerun with\n' + + '--debug and read the linuxdeploy output above for the real cause.\n\n' + : 'Quick fix:\n' + + ' NO_STRIP=1 pake --targets appimage --debug\n\n'; + + errorMsg += + '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' + + divider; + + 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.'; + } } } diff --git a/dist/cli.js b/dist/cli.js index 3b51b1a5f5..01dd1c61e2 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -249,34 +249,65 @@ async function shellExec(command, timeout = 300000, env) { 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+) + // Provide targeted guidance for common Linux AppImage build failures. + // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy/appimage but + // is unrelated to strip, so it must be matched first and given its own hint. const lowerError = errorMessage.toLowerCase(); - if (process.platform === 'linux' && - (lowerError.includes('linuxdeploy') || + const divider = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + if (process.platform === 'linux') { + const isGtkPixbufFailure = lowerError.includes('gdk-pixbuf') || + lowerError.includes('failed to run plugin: gtk'); + const isAppImageStripFailure = 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')) { + lowerError.includes('strip'); + const noStripActive = !!(env?.NO_STRIP || process.env.NO_STRIP); + if (isGtkPixbufFailure) { + errorMsg += + `\n\n${divider}\n` + + 'Linux AppImage Build Failed\n' + + `${divider}\n\n` + + "Cause: linuxdeploy's gtk plugin could not find the gdk-pixbuf loaders\n" + + ' (e.g. "cannot stat \'/usr/lib/gdk-pixbuf-2.0/...\'").\n\n' + + 'Quick fix:\n' + + ' • Install the gdk-pixbuf loaders for your distro:\n' + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' • Refresh the loader cache, then rebuild:\n' + + ' gdk-pixbuf-query-loaders --update-cache\n\n' + + 'Alternative:\n' + + ' • Use DEB format instead: pake --targets deb\n' + + ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + divider; + } + else if (isAppImageStripFailure) { + errorMsg += + `\n\n${divider}\n` + + 'Linux AppImage Build Failed\n' + + `${divider}\n\n` + + 'Cause: Strip tool incompatibility with glibc 2.38+\n' + + ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n'; + errorMsg += noStripActive + ? 'NO_STRIP=1 is already set but the build still failed, so this is\n' + + 'likely not a strip issue. Try the alternatives below or rerun with\n' + + '--debug and read the linuxdeploy output above for the real cause.\n\n' + : 'Quick fix:\n' + + ' NO_STRIP=1 pake --targets appimage --debug\n\n'; 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.'; + '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' + + divider; + 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); @@ -929,6 +960,12 @@ function isLinuxDeployStripError(error) { return false; } const message = error.message.toLowerCase(); + // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy but is not a + // strip issue, so a NO_STRIP retry won't help. Don't treat it as one. + if (message.includes('gdk-pixbuf') || + message.includes('failed to run plugin: gtk')) { + return false; + } return (message.includes('linuxdeploy') || message.includes('failed to run linuxdeploy') || message.includes('strip:') || From 72d3bf49b64a7db2e6f6e2cf743b7bee25c3aeed Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 6 Jun 2026 08:19:04 +0800 Subject: [PATCH 047/120] fix: surface real AppImage failure causes from build target, not error text shellExec runs with stdio:inherit, so linuxdeploy's diagnostics never reach error.message (only the command line does). The old code grepped that string: the strip box fired off the word "appimage" in the command, and the auto-retry matched the "strip tool" text the box injected itself. A gtk/gdk-pixbuf failure (#1211) was always mislabeled as strip. Drive guidance off the structured fact target==='appimage' instead. Auto-retry once with NO_STRIP=1, then append combined guidance (strip ruled out, gdk-pixbuf, fuse, deb fallback) that finally reaches the NO_STRIP-already-set case. Delete the command-string classification and its dead helper; add regression tests. --- bin/builders/BaseBuilder.ts | 56 +++++----- bin/builders/env.ts | 27 ----- bin/utils/linuxBuildError.ts | 35 ++++++ bin/utils/shell.ts | 80 ++------------ dist/cli.js | 156 ++++++++++----------------- docs/faq.md | 16 +++ docs/faq_CN.md | 16 +++ tests/unit/linux-build-error.test.ts | 26 +++++ 8 files changed, 181 insertions(+), 231 deletions(-) create mode 100644 bin/utils/linuxBuildError.ts create mode 100644 tests/unit/linux-build-error.test.ts diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index c8a6f27c6b..7abd3308a1 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -24,8 +24,8 @@ import { getBuildTimeout, getInstallCommand, getInstallTimeout, - isLinuxDeployStripError, } from './env'; +import { appendAppImageGuidance } from '@/utils/linuxBuildError'; export default abstract class BaseBuilder { protected options: PakeAppOptions; @@ -156,18 +156,15 @@ export default abstract class BaseBuilder { const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined; - // Warn users about potential AppImage build failures on modern Linux systems. - // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't - // recognize the .relr.dyn section introduced in glibc 2.38+. - if (process.platform === 'linux' && target === 'appimage') { - if (!buildEnv.NO_STRIP) { - logger.warn( - '⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+', - ); - logger.warn( - '⚠ If build fails, retry with: NO_STRIP=1 pake --targets appimage', - ); - } + const isLinuxAppImage = + process.platform === 'linux' && target === 'appimage'; + + // AppImage builds can fail at the linuxdeploy strip step on glibc 2.38+. + // A real failure now prints full guidance, so only hint in debug mode. + if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) { + logger.warn( + '⚠ AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.', + ); } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; @@ -176,23 +173,26 @@ export default abstract class BaseBuilder { try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (error) { - const shouldRetryWithoutStrip = - process.platform === 'linux' && - target === 'appimage' && - !buildEnv.NO_STRIP && - isLinuxDeployStripError(error); + if (!isLinuxAppImage) { + throw error; + } - if (shouldRetryWithoutStrip) { - logger.warn( - '⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.', - ); - buildEnv = { - ...buildEnv, - NO_STRIP: '1', - }; + // linuxdeploy's diagnostics stream to the terminal (stdio: 'inherit') and + // never reach error.message, so we cannot classify the cause. strip is the + // most common AppImage failure, so retry once with NO_STRIP=1; if that + // (or an already-NO_STRIP run) still fails, surface all known causes. + if (buildEnv.NO_STRIP) { + throw appendAppImageGuidance(error); + } + + logger.warn( + '⚠ AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).', + ); + buildEnv = { ...buildEnv, NO_STRIP: '1' }; + try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); - } else { - throw error; + } catch (retryError) { + throw appendAppImageGuidance(retryError); } } diff --git a/bin/builders/env.ts b/bin/builders/env.ts index 033a2b46ce..8b5b578069 100644 --- a/bin/builders/env.ts +++ b/bin/builders/env.ts @@ -204,30 +204,3 @@ export async function configureCargoRegistry( ); } } - -/** - * Returns true when an error string looks like the well-known Tauri+linuxdeploy - * strip failure that we automatically retry with NO_STRIP=1. - */ -export function isLinuxDeployStripError(error: unknown): boolean { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy but is not a - // strip issue, so a NO_STRIP retry won't help. Don't treat it as one. - if ( - message.includes('gdk-pixbuf') || - message.includes('failed to run plugin: gtk') - ) { - return false; - } - 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') - ); -} diff --git a/bin/utils/linuxBuildError.ts b/bin/utils/linuxBuildError.ts new file mode 100644 index 0000000000..06dc10a2d6 --- /dev/null +++ b/bin/utils/linuxBuildError.ts @@ -0,0 +1,35 @@ +const DIVIDER = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; + +// Guidance printed after a Linux AppImage build fails for good. We cannot detect +// the exact cause: linuxdeploy streams its diagnostics to the terminal via +// `stdio: 'inherit'`, so they never reach `error.message` (which only holds the +// failed command line). We only reach here after NO_STRIP=1 has already been +// applied and still failed, so strip is presented as already ruled out. +const APPIMAGE_FAILURE_GUIDANCE = + `\n\n${DIVIDER}\n` + + 'Linux AppImage Build Failed\n' + + `${DIVIDER}\n\n` + + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + + ' the build still failed, so strip is likely not the cause.\n' + + ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + + " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' then: gdk-pixbuf-query-loaders --update-cache\n' + + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + + 'Still stuck? Build a DEB instead: pake --targets deb\n' + + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + DIVIDER; + +/** + * Returns the original error with AppImage guidance appended to its message, + * preserving the stack. Used when a Linux AppImage build fails for good. + */ +export function appendAppImageGuidance(error: unknown): Error { + const baseError = error instanceof Error ? error : new Error(String(error)); + baseError.message += APPIMAGE_FAILURE_GUIDANCE; + return baseError; +} diff --git a/bin/utils/shell.ts b/bin/utils/shell.ts index 64c1e1e7e2..1c87d83faa 100644 --- a/bin/utils/shell.ts +++ b/bin/utils/shell.ts @@ -27,79 +27,11 @@ export async function shellExec( ); } - let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; - - // Provide targeted guidance for common Linux AppImage build failures. - // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy/appimage but - // is unrelated to strip, so it must be matched first and given its own hint. - const lowerError = errorMessage.toLowerCase(); - const divider = - '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - - if (process.platform === 'linux') { - const isGtkPixbufFailure = - lowerError.includes('gdk-pixbuf') || - lowerError.includes('failed to run plugin: gtk'); - const isAppImageStripFailure = - lowerError.includes('linuxdeploy') || - lowerError.includes('appimage') || - lowerError.includes('strip'); - const noStripActive = !!(env?.NO_STRIP || process.env.NO_STRIP); - - if (isGtkPixbufFailure) { - errorMsg += - `\n\n${divider}\n` + - 'Linux AppImage Build Failed\n' + - `${divider}\n\n` + - "Cause: linuxdeploy's gtk plugin could not find the gdk-pixbuf loaders\n" + - ' (e.g. "cannot stat \'/usr/lib/gdk-pixbuf-2.0/...\'").\n\n' + - 'Quick fix:\n' + - ' • Install the gdk-pixbuf loaders for your distro:\n' + - ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + - ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + - ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + - ' • Refresh the loader cache, then rebuild:\n' + - ' gdk-pixbuf-query-loaders --update-cache\n\n' + - 'Alternative:\n' + - ' • Use DEB format instead: pake --targets deb\n' + - ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - divider; - } else if (isAppImageStripFailure) { - errorMsg += - `\n\n${divider}\n` + - 'Linux AppImage Build Failed\n' + - `${divider}\n\n` + - 'Cause: Strip tool incompatibility with glibc 2.38+\n' + - ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n'; - - errorMsg += noStripActive - ? 'NO_STRIP=1 is already set but the build still failed, so this is\n' + - 'likely not a strip issue. Try the alternatives below or rerun with\n' + - '--debug and read the linuxdeploy output above for the real cause.\n\n' - : 'Quick fix:\n' + - ' NO_STRIP=1 pake --targets appimage --debug\n\n'; - - errorMsg += - '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' + - divider; - - if ( - lowerError.includes('fuse') || - lowerError.includes('operation not permitted') || - lowerError.includes('/dev/fuse') - ) { - errorMsg += - '\n\nDocker / Container hint:\n' + - ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' + - ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' + - ' or run on the host directly.'; - } - } - } - - throw new Error(errorMsg); + // AppImage/linuxdeploy guidance is added by the caller (BaseBuilder), which + // knows the build target. We only have the command line here (the tool's + // diagnostics stream to the terminal via stdio:inherit, not into the error). + throw new Error( + `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`, + ); } } diff --git a/dist/cli.js b/dist/cli.js index 01dd1c61e2..ba865e30cd 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -248,69 +248,10 @@ async function shellExec(command, timeout = 300000, env) { if (error.timedOut) { throw new Error(`Command timed out after ${timeout}ms: "${command}". Try increasing timeout or check network connectivity.`); } - let errorMsg = `Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`; - // Provide targeted guidance for common Linux AppImage build failures. - // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy/appimage but - // is unrelated to strip, so it must be matched first and given its own hint. - const lowerError = errorMessage.toLowerCase(); - const divider = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - if (process.platform === 'linux') { - const isGtkPixbufFailure = lowerError.includes('gdk-pixbuf') || - lowerError.includes('failed to run plugin: gtk'); - const isAppImageStripFailure = lowerError.includes('linuxdeploy') || - lowerError.includes('appimage') || - lowerError.includes('strip'); - const noStripActive = !!(env?.NO_STRIP || process.env.NO_STRIP); - if (isGtkPixbufFailure) { - errorMsg += - `\n\n${divider}\n` + - 'Linux AppImage Build Failed\n' + - `${divider}\n\n` + - "Cause: linuxdeploy's gtk plugin could not find the gdk-pixbuf loaders\n" + - ' (e.g. "cannot stat \'/usr/lib/gdk-pixbuf-2.0/...\'").\n\n' + - 'Quick fix:\n' + - ' • Install the gdk-pixbuf loaders for your distro:\n' + - ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + - ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + - ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + - ' • Refresh the loader cache, then rebuild:\n' + - ' gdk-pixbuf-query-loaders --update-cache\n\n' + - 'Alternative:\n' + - ' • Use DEB format instead: pake --targets deb\n' + - ' • Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - divider; - } - else if (isAppImageStripFailure) { - errorMsg += - `\n\n${divider}\n` + - 'Linux AppImage Build Failed\n' + - `${divider}\n\n` + - 'Cause: Strip tool incompatibility with glibc 2.38+\n' + - ' (affects Debian Trixie, Arch Linux, and other modern distros)\n\n'; - errorMsg += noStripActive - ? 'NO_STRIP=1 is already set but the build still failed, so this is\n' + - 'likely not a strip issue. Try the alternatives below or rerun with\n' + - '--debug and read the linuxdeploy output above for the real cause.\n\n' - : 'Quick fix:\n' + - ' NO_STRIP=1 pake --targets appimage --debug\n\n'; - errorMsg += - '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' + - divider; - if (lowerError.includes('fuse') || - lowerError.includes('operation not permitted') || - lowerError.includes('/dev/fuse')) { - errorMsg += - '\n\nDocker / Container hint:\n' + - ' AppImage tooling needs access to /dev/fuse. When running inside Docker, add:\n' + - ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n' + - ' or run on the host directly.'; - } - } - } - throw new Error(errorMsg); + // AppImage/linuxdeploy guidance is added by the caller (BaseBuilder), which + // knows the build target. We only have the command line here (the tool's + // diagnostics stream to the terminal via stdio:inherit, not into the error). + throw new Error(`Error occurred while executing command "${command}". Exit code: ${exitCode}. Details: ${errorMessage}`); } } @@ -951,27 +892,38 @@ async function configureCargoRegistry(tauriSrcPath, useCnMirror) { logger.warn(`✼ ${projectConf} still references rsproxy.cn. Remove it or set ${CN_MIRROR_ENV}=1 if you want to use the CN mirror.`); } } + +const DIVIDER = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; +// Guidance printed after a Linux AppImage build fails for good. We cannot detect +// the exact cause: linuxdeploy streams its diagnostics to the terminal via +// `stdio: 'inherit'`, so they never reach `error.message` (which only holds the +// failed command line). We only reach here after NO_STRIP=1 has already been +// applied and still failed, so strip is presented as already ruled out. +const APPIMAGE_FAILURE_GUIDANCE = `\n\n${DIVIDER}\n` + + 'Linux AppImage Build Failed\n' + + `${DIVIDER}\n\n` + + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + + ' the build still failed, so strip is likely not the cause.\n' + + ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + + " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' then: gdk-pixbuf-query-loaders --update-cache\n' + + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + + 'Still stuck? Build a DEB instead: pake --targets deb\n' + + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + DIVIDER; /** - * Returns true when an error string looks like the well-known Tauri+linuxdeploy - * strip failure that we automatically retry with NO_STRIP=1. + * Returns the original error with AppImage guidance appended to its message, + * preserving the stack. Used when a Linux AppImage build fails for good. */ -function isLinuxDeployStripError(error) { - if (!(error instanceof Error) || !error.message) { - return false; - } - const message = error.message.toLowerCase(); - // A gtk-plugin / gdk-pixbuf failure also mentions linuxdeploy but is not a - // strip issue, so a NO_STRIP retry won't help. Don't treat it as one. - if (message.includes('gdk-pixbuf') || - message.includes('failed to run plugin: gtk')) { - return false; - } - 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')); +function appendAppImageGuidance(error) { + const baseError = error instanceof Error ? error : new Error(String(error)); + baseError.message += APPIMAGE_FAILURE_GUIDANCE; + return baseError; } class BaseBuilder { @@ -1065,14 +1017,11 @@ class BaseBuilder { ...(process.env.NO_STRIP ? { NO_STRIP: process.env.NO_STRIP } : {}), }; const resolveExecEnv = () => Object.keys(buildEnv).length > 0 ? buildEnv : undefined; - // Warn users about potential AppImage build failures on modern Linux systems. - // The linuxdeploy tool bundled in Tauri uses an older strip tool that doesn't - // recognize the .relr.dyn section introduced in glibc 2.38+. - if (process.platform === 'linux' && target === 'appimage') { - if (!buildEnv.NO_STRIP) { - logger.warn('⚠ Building AppImage on Linux may fail due to strip incompatibility with glibc 2.38+'); - logger.warn('⚠ If build fails, retry with: NO_STRIP=1 pake --targets appimage'); - } + const isLinuxAppImage = process.platform === 'linux' && target === 'appimage'; + // AppImage builds can fail at the linuxdeploy strip step on glibc 2.38+. + // A real failure now prints full guidance, so only hint in debug mode. + if (isLinuxAppImage && !buildEnv.NO_STRIP && this.options.debug) { + logger.warn('⚠ AppImage strip step can fail on glibc 2.38+; Pake will auto-retry with NO_STRIP=1.'); } const buildCommand = `cd "${npmDirectory}" && ${this.getBuildCommand(packageManager)}`; const buildTimeout = getBuildTimeout(); @@ -1080,20 +1029,23 @@ class BaseBuilder { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (error) { - const shouldRetryWithoutStrip = process.platform === 'linux' && - target === 'appimage' && - !buildEnv.NO_STRIP && - isLinuxDeployStripError(error); - if (shouldRetryWithoutStrip) { - logger.warn('⚠ AppImage build failed during linuxdeploy strip step, retrying with NO_STRIP=1 automatically.'); - buildEnv = { - ...buildEnv, - NO_STRIP: '1', - }; + if (!isLinuxAppImage) { + throw error; + } + // linuxdeploy's diagnostics stream to the terminal (stdio: 'inherit') and + // never reach error.message, so we cannot classify the cause. strip is the + // most common AppImage failure, so retry once with NO_STRIP=1; if that + // (or an already-NO_STRIP run) still fails, surface all known causes. + if (buildEnv.NO_STRIP) { + throw appendAppImageGuidance(error); + } + logger.warn('⚠ AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).'); + buildEnv = { ...buildEnv, NO_STRIP: '1' }; + try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } - else { - throw error; + catch (retryError) { + throw appendAppImageGuidance(retryError); } } // Copy app diff --git a/docs/faq.md b/docs/faq.md index 4c81dedf4f..c42214afea 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -108,6 +108,22 @@ When building AppImage on Linux (Debian, Ubuntu, Arch, etc.), you may encounter ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file +ERROR: Failed to run plugin: gtk +cp: cannot stat '/usr/lib/gdk-pixbuf-2.0/2.10.0': No such file or directory +``` + +**Identify which failure you have first.** Two distinct problems share the `failed to run linuxdeploy` message: + +- `strip: Unable to recognise the format of the input file`: a strip incompatibility. Use Solution 1. +- `Failed to run plugin: gtk` together with `cannot stat '/usr/lib/gdk-pixbuf-2.0/...'`: linuxdeploy's gtk plugin cannot find the gdk-pixbuf loaders. `NO_STRIP` will not help. Install the loaders, refresh the cache, then rebuild: + +```bash +# Arch +sudo pacman -S gdk-pixbuf2 librsvg +# Debian / Ubuntu +sudo apt install librsvg2-common gdk-pixbuf2.0-bin +# refresh the loader cache, then rebuild +gdk-pixbuf-query-loaders --update-cache ``` **Solution 1: Automatic NO_STRIP Retry (Recommended)** diff --git a/docs/faq_CN.md b/docs/faq_CN.md index d1c56c6d1d..401a3fbb24 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -108,6 +108,22 @@ sudo apt-get install -y libayatana-appindicator3-dev ```txt Error: failed to run linuxdeploy Error: strip: Unable to recognise the format of the input file +ERROR: Failed to run plugin: gtk +cp: cannot stat '/usr/lib/gdk-pixbuf-2.0/2.10.0': No such file or directory +``` + +**先判断你遇到的是哪一种失败。** 同样是 `failed to run linuxdeploy`,实际有两类不同原因: + +- `strip: Unable to recognise the format of the input file`:strip 不兼容,按解决方案 1 处理。 +- `Failed to run plugin: gtk` 且伴随 `cannot stat '/usr/lib/gdk-pixbuf-2.0/...'`:linuxdeploy 的 gtk 插件找不到 gdk-pixbuf loaders,`NO_STRIP` 无效。安装 loaders、刷新缓存后重新构建: + +```bash +# Arch +sudo pacman -S gdk-pixbuf2 librsvg +# Debian / Ubuntu +sudo apt install librsvg2-common gdk-pixbuf2.0-bin +# 刷新 loader 缓存后重新构建 +gdk-pixbuf-query-loaders --update-cache ``` **解决方案 1:自动 NO_STRIP 重试(推荐)** diff --git a/tests/unit/linux-build-error.test.ts b/tests/unit/linux-build-error.test.ts new file mode 100644 index 0000000000..d6bbad8b94 --- /dev/null +++ b/tests/unit/linux-build-error.test.ts @@ -0,0 +1,26 @@ +import { describe, it, expect } from 'vitest'; +import { appendAppImageGuidance } from '@/utils/linuxBuildError'; + +describe('appendAppImageGuidance', () => { + it('appends guidance listing every known cause and the deb fallback', () => { + const result = appendAppImageGuidance(new Error('boom')); + expect(result.message).toContain('boom'); // original message preserved + expect(result.message).toContain('Linux AppImage Build Failed'); + expect(result.message).toContain('NO_STRIP=1 was already applied'); + expect(result.message).toContain('gdk-pixbuf'); + expect(result.message).toContain('/dev/fuse'); + expect(result.message).toContain('--targets deb'); + }); + + it('returns the same Error instance so the stack is preserved', () => { + const original = new Error('Command failed with exit code 1'); + expect(appendAppImageGuidance(original)).toBe(original); + }); + + it('wraps non-Error throwables', () => { + const result = appendAppImageGuidance('raw string failure'); + expect(result).toBeInstanceOf(Error); + expect(result.message).toContain('raw string failure'); + expect(result.message).toContain('Linux AppImage Build Failed'); + }); +}); From d1c564d43d70978426877c094fc69f7c617515d6 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 6 Jun 2026 08:28:01 +0800 Subject: [PATCH 048/120] refactor: inline AppImage failure guidance, drop single-use module The guidance helper had one consumer (BaseBuilder) and its non-Error branch could never run (shellExec always throws Error). Inline the constant and append it at the two failure sites; remove the module and its constant-only test. --- bin/builders/BaseBuilder.ts | 30 +++++++++++++++++++++--- bin/utils/linuxBuildError.ts | 35 ---------------------------- dist/cli.js | 33 ++++++++++---------------- tests/unit/linux-build-error.test.ts | 26 --------------------- 4 files changed, 39 insertions(+), 85 deletions(-) delete mode 100644 bin/utils/linuxBuildError.ts delete mode 100644 tests/unit/linux-build-error.test.ts diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index 7abd3308a1..fc7ae65181 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -25,7 +25,29 @@ import { getInstallCommand, getInstallTimeout, } from './env'; -import { appendAppImageGuidance } from '@/utils/linuxBuildError'; +// Appended to the error when a Linux AppImage build fails for good. linuxdeploy's +// diagnostics stream to the terminal (stdio: 'inherit') and never reach +// error.message, so we cannot name the exact cause. We only reach here after +// NO_STRIP=1 has been applied and still failed, so strip is shown as ruled out. +const APPIMAGE_BAR = '━'.repeat(56); +const APPIMAGE_FAILURE_GUIDANCE = + `\n\n${APPIMAGE_BAR}\n` + + 'Linux AppImage Build Failed\n' + + `${APPIMAGE_BAR}\n\n` + + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + + ' the build still failed, so strip is likely not the cause.\n' + + ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + + " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + + ' then: gdk-pixbuf-query-loaders --update-cache\n' + + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + + 'Still stuck? Build a DEB instead: pake --targets deb\n' + + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + + APPIMAGE_BAR; export default abstract class BaseBuilder { protected options: PakeAppOptions; @@ -182,7 +204,8 @@ export default abstract class BaseBuilder { // most common AppImage failure, so retry once with NO_STRIP=1; if that // (or an already-NO_STRIP run) still fails, surface all known causes. if (buildEnv.NO_STRIP) { - throw appendAppImageGuidance(error); + (error as Error).message += APPIMAGE_FAILURE_GUIDANCE; + throw error; } logger.warn( @@ -192,7 +215,8 @@ export default abstract class BaseBuilder { try { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (retryError) { - throw appendAppImageGuidance(retryError); + (retryError as Error).message += APPIMAGE_FAILURE_GUIDANCE; + throw retryError; } } diff --git a/bin/utils/linuxBuildError.ts b/bin/utils/linuxBuildError.ts deleted file mode 100644 index 06dc10a2d6..0000000000 --- a/bin/utils/linuxBuildError.ts +++ /dev/null @@ -1,35 +0,0 @@ -const DIVIDER = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; - -// Guidance printed after a Linux AppImage build fails for good. We cannot detect -// the exact cause: linuxdeploy streams its diagnostics to the terminal via -// `stdio: 'inherit'`, so they never reach `error.message` (which only holds the -// failed command line). We only reach here after NO_STRIP=1 has already been -// applied and still failed, so strip is presented as already ruled out. -const APPIMAGE_FAILURE_GUIDANCE = - `\n\n${DIVIDER}\n` + - 'Linux AppImage Build Failed\n' + - `${DIVIDER}\n\n` + - 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + - ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + - ' the build still failed, so strip is likely not the cause.\n' + - ' • Missing gdk-pixbuf loaders (e.g. "cannot stat\n' + - " '/usr/lib/gdk-pixbuf-2.0/...'\"): install them, then rebuild:\n" + - ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + - ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + - ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + - ' then: gdk-pixbuf-query-loaders --update-cache\n' + - ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + - ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + - 'Still stuck? Build a DEB instead: pake --targets deb\n' + - 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - DIVIDER; - -/** - * Returns the original error with AppImage guidance appended to its message, - * preserving the stack. Used when a Linux AppImage build fails for good. - */ -export function appendAppImageGuidance(error: unknown): Error { - const baseError = error instanceof Error ? error : new Error(String(error)); - baseError.message += APPIMAGE_FAILURE_GUIDANCE; - return baseError; -} diff --git a/dist/cli.js b/dist/cli.js index ba865e30cd..306d7e05ce 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -893,15 +893,14 @@ async function configureCargoRegistry(tauriSrcPath, useCnMirror) { } } -const DIVIDER = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'; -// Guidance printed after a Linux AppImage build fails for good. We cannot detect -// the exact cause: linuxdeploy streams its diagnostics to the terminal via -// `stdio: 'inherit'`, so they never reach `error.message` (which only holds the -// failed command line). We only reach here after NO_STRIP=1 has already been -// applied and still failed, so strip is presented as already ruled out. -const APPIMAGE_FAILURE_GUIDANCE = `\n\n${DIVIDER}\n` + +// Appended to the error when a Linux AppImage build fails for good. linuxdeploy's +// diagnostics stream to the terminal (stdio: 'inherit') and never reach +// error.message, so we cannot name the exact cause. We only reach here after +// NO_STRIP=1 has been applied and still failed, so strip is shown as ruled out. +const APPIMAGE_BAR = '━'.repeat(56); +const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` + 'Linux AppImage Build Failed\n' + - `${DIVIDER}\n\n` + + `${APPIMAGE_BAR}\n\n` + 'The AppImage bundler (linuxdeploy) failed. Common causes and fixes:\n\n' + ' • Strip incompatibility (glibc 2.38+): NO_STRIP=1 was already applied and\n' + ' the build still failed, so strip is likely not the cause.\n' + @@ -915,17 +914,7 @@ const APPIMAGE_FAILURE_GUIDANCE = `\n\n${DIVIDER}\n` + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + 'Still stuck? Build a DEB instead: pake --targets deb\n' + 'Detailed guide: https://github.com/tw93/Pake/blob/main/docs/faq.md\n' + - DIVIDER; -/** - * Returns the original error with AppImage guidance appended to its message, - * preserving the stack. Used when a Linux AppImage build fails for good. - */ -function appendAppImageGuidance(error) { - const baseError = error instanceof Error ? error : new Error(String(error)); - baseError.message += APPIMAGE_FAILURE_GUIDANCE; - return baseError; -} - + APPIMAGE_BAR; class BaseBuilder { constructor(options) { this.options = options; @@ -1037,7 +1026,8 @@ class BaseBuilder { // most common AppImage failure, so retry once with NO_STRIP=1; if that // (or an already-NO_STRIP run) still fails, surface all known causes. if (buildEnv.NO_STRIP) { - throw appendAppImageGuidance(error); + error.message += APPIMAGE_FAILURE_GUIDANCE; + throw error; } logger.warn('⚠ AppImage build failed, retrying once with NO_STRIP=1 (common glibc 2.38+ strip issue).'); buildEnv = { ...buildEnv, NO_STRIP: '1' }; @@ -1045,7 +1035,8 @@ class BaseBuilder { await shellExec(buildCommand, buildTimeout, resolveExecEnv()); } catch (retryError) { - throw appendAppImageGuidance(retryError); + retryError.message += APPIMAGE_FAILURE_GUIDANCE; + throw retryError; } } // Copy app diff --git a/tests/unit/linux-build-error.test.ts b/tests/unit/linux-build-error.test.ts deleted file mode 100644 index d6bbad8b94..0000000000 --- a/tests/unit/linux-build-error.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { appendAppImageGuidance } from '@/utils/linuxBuildError'; - -describe('appendAppImageGuidance', () => { - it('appends guidance listing every known cause and the deb fallback', () => { - const result = appendAppImageGuidance(new Error('boom')); - expect(result.message).toContain('boom'); // original message preserved - expect(result.message).toContain('Linux AppImage Build Failed'); - expect(result.message).toContain('NO_STRIP=1 was already applied'); - expect(result.message).toContain('gdk-pixbuf'); - expect(result.message).toContain('/dev/fuse'); - expect(result.message).toContain('--targets deb'); - }); - - it('returns the same Error instance so the stack is preserved', () => { - const original = new Error('Command failed with exit code 1'); - expect(appendAppImageGuidance(original)).toBe(original); - }); - - it('wraps non-Error throwables', () => { - const result = appendAppImageGuidance('raw string failure'); - expect(result).toBeInstanceOf(Error); - expect(result.message).toContain('raw string failure'); - expect(result.message).toContain('Linux AppImage Build Failed'); - }); -}); From 48147dae7c759efb23090a3522c9580a3bf7bb4f Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 6 Jun 2026 08:50:49 +0800 Subject: [PATCH 049/120] docs: track agent instructions in-repo, CLAUDE.md symlinks AGENTS.md Make the project's agent guidance a public source of truth so Claude Code (CLAUDE.md) and Codex (AGENTS.md) read one file; CLAUDE.md is now a symlink to AGENTS.md, so only AGENTS.md is edited. Local-only overrides (CLAUDE.local.md, AGENTS.override.md, .claude/settings.local.json) stay ignored. --- .claude/rules/rust.md | 38 ++++++ .claude/skills/release/SKILL.md | 82 +++++++++++++ .gitignore | 6 +- AGENTS.md | 211 ++++++++++++++++++++++++++++++++ CLAUDE.md | 1 + 5 files changed, 334 insertions(+), 4 deletions(-) create mode 100644 .claude/rules/rust.md create mode 100644 .claude/skills/release/SKILL.md create mode 100644 AGENTS.md create mode 120000 CLAUDE.md diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md new file mode 100644 index 0000000000..f4756038de --- /dev/null +++ b/.claude/rules/rust.md @@ -0,0 +1,38 @@ +# Pake Rust + Tauri Rules + +> Shared Rust rules: see global `~/.claude/rules/rust.md` (includes Tauri section). + +## Pake-Specific + +### Error handling + +- No `panic!` / `.unwrap()` / `.expect()` on user-reachable paths: CLI options, config loading, event handlers, IPC commands. Use `?` and surface clear messages. +- Silent `catch {}` in TS or `let _ = ...` in Rust must surface the real error through `logger.warn` at minimum. +- `shellExec` runs subprocesses with `stdio: 'inherit'`, so their output (linuxdeploy, cargo, npm) never reaches `error.message`; only the failed command line does. Do NOT classify a build failure by grepping `error.message`; you would be matching the command, not the diagnostics. Drive failure guidance off a structured fact the caller holds (e.g. `target === 'appimage'`). Owners: `bin/utils/shell.ts` + `bin/builders/BaseBuilder.ts`. + +### IPC + +- `#[tauri::command]` handlers validate every input from the renderer. The webview is untrusted. +- Long work in handlers goes through `tauri::async_runtime::spawn`. Don't block the IPC thread. +- Don't broaden the allowlist (filesystem, shell, http) past the exact paths and commands needed. + +### Config types + +- No `tauriConf: any` or other untyped config bags. Use `PakeTauriConfig`. +- Window options live in `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`. Adding an option means touching all four plus `docs/cli-usage*.md`. Forgetting any is a regression. + +### Network mirrors + +- CN mirror switching is **explicit opt-in** via `PAKE_USE_CN_MIRROR=1`. Do not reintroduce automatic CN-domain detection. +- Behavior owners: `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts`. Keep docs and tests aligned. + +### dist/cli.js + +- `dist/cli.js` is a tracked build artifact (declared in `package.json` `files`). +- Any change under `bin/` must rebuild with `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change. + +### Platform sensitivity + +- WebKit compositing on Linux/Wayland is platform-sensitive. Don't change defaults without testing on the affected platform or documenting the risk. +- `--incognito` trades persistence for clean private sessions; be deliberate around login / cookies / local storage / embedded-WebView detection. +- Google OAuth and other embedded-WebView restrictions may still apply even with `--new-window` / `--multi-window`. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md new file mode 100644 index 0000000000..33d5e5180f --- /dev/null +++ b/.claude/skills/release/SKILL.md @@ -0,0 +1,82 @@ +--- +name: release +description: Prepare, validate, and publish a Pake release. Not for version bumps without release intent. +version: 1.1.0 +allowed-tools: + - Bash + - Read + - Grep + - Glob +disable-model-invocation: true +--- + +# Release Skill + +Use this skill when preparing or executing a Pake release. + +## Version Files + +Four files must be updated in sync — never update one without the others: + +- `package.json` → `"version"` +- `src-tauri/Cargo.toml` → `version` under `[package]` +- `src-tauri/Cargo.lock` → `version` for package `pake` +- `src-tauri/tauri.conf.json` → `"version"` + +## Release Checklist + +### Pre-Release + +1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) +2. [ ] Update all four version files above +3. [ ] Run `pnpm run format` — must pass cleanly +4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +6. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +7. [ ] No uncommitted changes: `git status` +8. [ ] Commit version bump with message: `chore: bump version to VX.X.X` + +### Tagging (triggers CI) + +```bash +git tag -a VX.X.X -m "Release VX.X.X" +git push origin VX.X.X +``` + +Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. + +### Post-Tag Verification + +1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` +2. [ ] Watch CI status: `gh run watch` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` +4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` +5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +6. [ ] Verify npm published the package: `npm view pake-cli version` and `npm view pake-cli@X.Y.Z dist.tarball` + +npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. + +## Trusted Publishing Notes + +- The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. +- npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. +- If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. + +## Build Commands (local only) + +```bash +# Current platform +pnpm build + +# macOS universal binary +pnpm build:mac +``` + +Cross-platform builds (Windows/Linux) are handled by CI, not locally. + +## Safety Rules + +1. **NEVER** auto-commit or auto-push without explicit user request +2. **NEVER** tag before all checks pass +3. **ALWAYS** verify the four version files are in sync before tagging diff --git a/.gitignore b/.gitignore index 28597bc3f7..518540e934 100644 --- a/.gitignore +++ b/.gitignore @@ -20,12 +20,10 @@ *.suo *.sw? *.tmp -# Local AI assistant docs -CLAUDE.md +# Local AI assistant overrides (shared docs AGENTS.md / CLAUDE.md / .claude are tracked) CLAUDE.local.md -AGENTS.md AGENTS.override.md -.claude/ +.claude/settings.local.json .agents/settings.local.json # Editor directories and files # Logs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000..97b5fa9036 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,211 @@ +# AGENTS.md - Pake Project Knowledge Base + +> 全局规则在 `~/.claude/CLAUDE.md`,Rust 通用规则在 `~/.claude/rules/rust.md`,项目级 Rust+Tauri 补充在 `.claude/rules/rust.md`。发版细节看 `.claude/skills/release`(`/release`)。 + +## Project Identity + +**Pake** - Turn any webpage into a lightweight desktop app with one command. + +- **Purpose**: Package any website into a ~5MB desktop app (20x smaller than Electron) +- **Stack**: Tauri v2 (Rust) + TypeScript CLI +- **Platforms**: macOS, Windows, Linux +- **Mechanism**: Uses system webview (WebKit on macOS/Linux, WebView2 on Windows) + +## Repository Structure + +``` +Pake/ +├── bin/ # CLI source code (TypeScript) +│ └── cli.ts # Main CLI entry (Commander.js) +├── src-tauri/ # Tauri Rust application +│ ├── src/ # Rust source code +│ ├── src/app/ # window creation, setup, menu, config, and invokes +│ ├── src/inject/ # injected JS/CSS behavior +│ ├── Cargo.toml # Rust dependencies and version +│ ├── tauri.conf.json # Tauri configuration and version +│ └── .cargo/ # Cargo configuration (gitignored) +├── dist/ # Compiled CLI output +├── docs/ # Documentation +│ ├── cli-usage.md # CLI parameters +│ ├── advanced-usage.md # Customization guide +│ └── faq.md # Troubleshooting +├── scripts/ # Utility scripts +├── tests/ # Unit, integration, and release-flow tests +├── .github/workflows/ # quality/test and release automation +├── default_app_list.json # Popular apps config for release builds +├── package.json # Node.js dependencies and version +└── rollup.config.js # CLI build configuration +``` + +## Development Commands + +| Command | Purpose | +| ------------------------------------ | --------------------------------------------------------------- | +| `pnpm install` | Install dependencies | +| `pnpm run dev` | Tauri development mode | +| `pnpm run cli:dev -- ` | CLI wrapper + Tauri (recommended) | +| `pnpm run cli:dev --iterative-build` | Faster dev (skip checks) | +| `pnpm run cli:build` | Rollup + TypeScript check (catches type errors Prettier misses) | +| `pnpm run build` | Build for current platform | +| `pnpm run build:mac` | macOS universal binary | +| `pnpm run format` | Format code (prettier + cargo fmt) | +| `npx vitest run` | Unit and integration tests only (sub-second) | +| `pnpm test -- --no-build` | Full suite minus the multi-arch real build | +| `pnpm test` | Full suite including release workflow | + +Keep shared project facts in this file so Codex, Claude Code, and other agents use the same source of truth. `CLAUDE.md` is a symlink to this file, so edit `AGENTS.md` only. Local-only overrides (`CLAUDE.local.md`, `AGENTS.override.md`, `.claude/settings.local.json`) stay ignored. + +## Code Conventions + +- No Chinese comments in any source (Rust / TypeScript / any file). Comments and identifiers in English; follow the existing language of surrounding prose. + +## Task Intake And Investigation + +Prefer requests with: + +- `Goal`: exact bug, feature, refactor, or review target +- `Scope`: files, directories, or subsystem boundaries to inspect first +- `Repro`: command, input, fixture, or failing test +- `Expected`: expected behavior +- `Actual`: current behavior, error text, or regression note +- `Constraints`: what must not change +- `Verify`: minimum command or test that proves the result + +When task scope is incomplete, inspect in this order: + +1. CLI entry and option parsing under `bin/cli.ts`, `bin/options/`, and `bin/helpers/` +2. Target TypeScript module under `bin/` +3. Tauri runtime or packaging files under `src-tauri/src/` and `src-tauri/tauri*.conf.json` +4. Narrow tests under `tests/unit/` or `tests/integration/` +5. Release workflow files under `.github/workflows/` only for CI or release issues +6. Docs only if behavior, ownership, or expected usage is still unclear + +Execution rules: + +- Start with the smallest plausible file set +- Prefer targeted search (`rg `) over repository-wide scans +- Ignore generated or output-heavy areas unless the task directly targets them, especially `dist/`, `node_modules/`, `src-tauri/target/`, `.app/`, `src-tauri/icons/`, and `src-tauri/png/`. Exception: `dist/cli.js` is the shipped CLI build artifact (see `package.json` `files`); when you change anything under `bin/`, rebuild it via `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change +- Keep changes local to one subsystem when possible +- Run the narrowest relevant verification first, expand only if needed +- If key context is missing, make one reasonable assumption and proceed + +## Current Risk Areas + +- CLI options are user-facing and must stay synchronized across `bin/helpers/cli-program.ts`, `bin/types.ts`, `bin/defaults.ts`, `bin/helpers/merge.ts`, generated `dist/cli.js`, and `docs/cli-usage*.md`. +- Recent window/runtime options include `--incognito`, `--new-window`, `--min-width`, `--min-height`, `--maximize`, multi-window behavior, notification click handling, and Linux/Wayland WebKit compositing defaults. +- `--incognito` intentionally trades persistence for clean private sessions; be careful around login, cookies, local storage, and WeChat-style WebView detection. +- `--new-window` and `--multi-window` do not bypass every provider policy. Google OAuth and similar embedded-WebView restrictions may still require a normal browser or native client. +- Notification flows cross injected JS, Tauri invokes, capabilities, and native notification plugins. Verify the Rust capability and JS caller together. +- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Do not change defaults without testing the affected platform path or documenting the risk. + +## Platform-Specific Development + +### macOS + +- Universal builds via `--multi-arch` (Intel + Apple Silicon). +- Icons: `.icns`. +- Title bar can be customized via Tauri window options. + +### Windows + +- Requires Visual Studio Build Tools to compile. +- Icons: `.ico`. +- MSI installer supported via Tauri bundler. + +### Linux + +- Multiple package formats: `.deb`, `.AppImage`, `.rpm`. +- Runtime depends on `libwebkit2gtk` and its companion libraries. +- Icons: `.png`. +- WebKit compositing is platform-sensitive on Wayland; see Current Risk Areas before changing defaults. + +## Branch Strategy + +- `main` - Only branch. All development and releases happen here directly. + +## Version Management + +Four files must be updated in sync for every release: + +| File | Field | +| --------------------------- | --------------------------- | +| `package.json` | `"version"` | +| `src-tauri/Cargo.toml` | `version` under `[package]` | +| `src-tauri/Cargo.lock` | `version` for package `pake` | +| `src-tauri/tauri.conf.json` | `"version"` | + +Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. + +## Release Workflow (CI) + +Pushing a `V*` tag triggers `.github/workflows/release.yml`: + +1. **release-apps** - reads `default_app_list.json` for app list +2. **create-release** - creates the GitHub Release placeholder +3. **build-cli** - builds and uploads the `dist/` CLI artifact +4. **build-popular-apps** - builds all apps in parallel across macOS/Windows/Linux +5. **publish-docker** - builds and pushes Docker image to GHCR + +The workflow can also be triggered manually via `workflow_dispatch` with options to build popular apps or publish Docker independently. + +Pushing the same `V*` tag also triggers `.github/workflows/npm-publish.yml`, which publishes `pake-cli` to npm through Trusted Publishing. Configure the npm package's Trusted Publisher as GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, with no environment. Local `npm publish` is only a fallback when CI or npm registry state blocks the trusted path. + +Before treating an npm release as shipped, verify both `gh workflow list --all | grep "Publish npm Package"` and `npm view pake-cli@X.Y.Z version`. Do not reply to or close GitHub issues as released until the public registry returns the expected version. + +`.github/workflows/quality-and-test.yml` runs auto-format on push, Rust quality checks, and CLI/build validation across Linux, Windows, and macOS. + +### Network Mirror Behavior + +Pake uses official npm and Rust sources by default. CN mirrors are explicit opt-in only: + +- Set `PAKE_USE_CN_MIRROR=1` only when the user or CI environment intentionally wants npmmirror/rsProxy. +- Do not reintroduce automatic China-domain mirror switching. +- If an install fails against a CN mirror, retry the same install command to separate network availability from a product regression. +- `bin/utils/mirror.ts` and `bin/builders/BaseBuilder.ts` own this behavior; keep docs and tests aligned when changing it. + +## CLI Usage Example + +```bash +# Install CLI +pnpm install -g pake-cli + +# Basic usage +pake https://github.com --name GitHub + +# Advanced usage +pake https://weekly.tw93.fun --name Weekly --width 1200 --height 800 +``` + +## Troubleshooting + +See `docs/faq.md` for common issues and solutions. + +### macOS SDK / Compile Errors + +If compilation errors occur (e.g. on macOS beta), create `src-tauri/.cargo/config.toml`: + +```toml +[env] +MACOSX_DEPLOYMENT_TARGET = "15.0" +SDKROOT = "/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk" +``` + +This file is already in `.gitignore`. + +### `dist/cli.js` out of sync with `bin/` + +Symptom: tests or release builds use stale CLI behavior after a `bin/` edit. Fix with `pnpm run cli:build` and commit the regenerated `dist/cli.js`. + +### First Tauri build is slow + +The first `cargo build` on a fresh clone takes 10+ minutes as Cargo compiles every Tauri dependency from source. Subsequent builds reuse the `src-tauri/target/` cache. This is expected, not a bug. + +## Documentation Guidelines + +- **Main README**: keep only common, frequently-used parameters to avoid clutter. +- **CLI Documentation** (`docs/cli-usage.md` and locale variants): include **all** CLI parameters with detailed usage examples. +- **Rare or advanced parameters**: should have full documentation in `docs/cli-usage*.md` but minimal or no mention in the main README. Examples: `--title`, `--incognito`, `--system-tray-icon`, `--multi-window`, `--min-width`, `--min-height`. +- **Key configuration files**: + - `pake.json` - default app configuration. + - `src-tauri/tauri.conf.json` - shared Tauri settings. + - `src-tauri/tauri.{macos,windows,linux}.conf.json` - per-platform overrides. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000000..47dc3e3d86 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From bec904117945fd8854551e5da48dab57eb8a680f Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 6 Jun 2026 00:51:21 +0000 Subject: [PATCH 050/120] Auto-fix formatting issues --- AGENTS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 97b5fa9036..d5563ee633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,12 +127,12 @@ Execution rules: Four files must be updated in sync for every release: -| File | Field | -| --------------------------- | --------------------------- | -| `package.json` | `"version"` | -| `src-tauri/Cargo.toml` | `version` under `[package]` | +| File | Field | +| --------------------------- | ---------------------------- | +| `package.json` | `"version"` | +| `src-tauri/Cargo.toml` | `version` under `[package]` | | `src-tauri/Cargo.lock` | `version` for package `pake` | -| `src-tauri/tauri.conf.json` | `"version"` | +| `src-tauri/tauri.conf.json` | `"version"` | Tag format: `V0.x.x` (uppercase V). Current version: check `package.json`. From 5d0373a2b3ba4af274f04fe368f2e4608f8779a6 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 6 Jun 2026 09:00:26 +0800 Subject: [PATCH 051/120] docs: make published agent docs self-contained Drop references to the maintainer's local ~/.claude global files from the now public AGENTS.md and .claude/rules/rust.md; keep only in-repo pointers so contributors are not sent to files they do not have. --- .claude/rules/rust.md | 2 +- AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md index f4756038de..afaab53db9 100644 --- a/.claude/rules/rust.md +++ b/.claude/rules/rust.md @@ -1,6 +1,6 @@ # Pake Rust + Tauri Rules -> Shared Rust rules: see global `~/.claude/rules/rust.md` (includes Tauri section). +> Pake-specific Rust + Tauri rules. Standard Rust hygiene is assumed: `?` over `unwrap()`, `cargo clippy` clean, `cargo fmt` before commit. ## Pake-Specific diff --git a/AGENTS.md b/AGENTS.md index d5563ee633..db780cb164 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md - Pake Project Knowledge Base -> 全局规则在 `~/.claude/CLAUDE.md`,Rust 通用规则在 `~/.claude/rules/rust.md`,项目级 Rust+Tauri 补充在 `.claude/rules/rust.md`。发版细节看 `.claude/skills/release`(`/release`)。 +> Project-specific Rust + Tauri rules: `.claude/rules/rust.md`. Release runbook: `.claude/skills/release/SKILL.md` (run `/release`). ## Project Identity From 4ecb17aa55a1f6ef719cfd1068f2a68d2d504329 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 01:17:05 +0000 Subject: [PATCH 052/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 346 +++++++++++++++++++++++------------------------ 1 file changed, 173 insertions(+), 173 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index aca5850332..995efd87e5 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -102,24 +102,24 @@ - + - - - YangguangZhou + + + AielloChan - + - - - AielloChan + + + YangguangZhou @@ -157,453 +157,453 @@ - + - - - AllDaGearNoIdea + + + exposir - + - - - GoodbyeNJN + + + lkieryan - + - - - eltociear + + + g1eny0ung - + - - - kittizz + + + xinyii - + - - - mattbajorek + + + Tianj0o - + - - - vaddisrinivas + + + QingZ11 - + - - - QingZ11 + + + vaddisrinivas - + - - - Tianj0o + + + mattbajorek - + - - - xinyii + + + kittizz - + - - - g1eny0ung + + + eltociear - + - - - lkieryan + + + GoodbyeNJN - + - - - exposir + + + AllDaGearNoIdea - + - - - 2nthony + + + princemaple - + - - - ACGNnsj + + + RoyRao2333 - + - - - kidylee + + + sebastianbreguel - + - - - nekomeowww + + + youxi798 - + - - - kuishou68 + + + fulldecent - + - - - turkyden + + + beautifulrem - + - - - fvn-elmy + + + bocanhcam - + - - - Fechin + + + dbraendle - + - - - ImgBotApp + + + geekvest - + - - - droid-Q + + + lakca - + - - - JohannLai + + + liudonghua123 - + - - - Jason6987 + + + liusishan - + - - - Milo123459 + + + piaoyidage - + - - - pgoslatara + + + enihsyou - + - - - princemaple + + + hetz - + - - - RoyRao2333 + + + pgoslatara - + - - - sebastianbreguel + + + Milo123459 - + - - - youxi798 + + + Jason6987 - + - - - fulldecent + + + JohannLai - + - - - beautifulrem + + + droid-Q - + - - - bocanhcam + + + ImgBotApp - + - - - dbraendle + + + Fechin - + - - - geekvest + + + fvn-elmy - + - - - lakca + + + turkyden - + - - - liudonghua123 + + + kuishou68 - + - - - liusishan + + + nekomeowww - + - - - piaoyidage + + + kidylee - + - - - enihsyou + + + ACGNnsj - + - - - hetz + + + 2nthony \ No newline at end of file From fa6bbe2369da31e822135e8360e3dd9f0d591708 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 21:27:50 -0700 Subject: [PATCH 053/120] feat: add Arch zst Linux target --- bin/builders/LinuxBuilder.ts | 93 ++++++++++++++++++++++++++++++++++- bin/defaults.ts | 12 ++++- bin/helpers/merge.ts | 4 +- bin/types.ts | 2 +- dist/cli.js | 95 ++++++++++++++++++++++++++++++++++-- docs/cli-usage.md | 5 +- docs/cli-usage_CN.md | 5 +- tests/unit/builders.test.ts | 38 +++++++++------ 8 files changed, 229 insertions(+), 25 deletions(-) diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 41d13f0451..b5055bd238 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -1,7 +1,10 @@ import path from 'path'; +import fsExtra from 'fs-extra'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; +import { shellExec } from '@/utils/shell'; +import { generateLinuxPackageName } from '@/utils/name'; export default class LinuxBuilder extends BaseBuilder { private buildFormat: string; @@ -51,11 +54,15 @@ export default class LinuxBuilder extends BaseBuilder { return `${name}-${version}-1.${arch}`; } + if (this.currentBuildType === 'zst') { + return `${name}-${version}-1-${arch}.pkg.tar`; + } + return `${name}_${version}_${arch}`; } async build(url: string) { - const targetTypes = ['deb', 'appimage', 'rpm']; + const targetTypes = ['deb', 'appimage', 'rpm', 'zst']; const requestedTargets = this.options.targets .split(',') .map((t: string) => t.trim()); @@ -63,11 +70,90 @@ export default class LinuxBuilder extends BaseBuilder { for (const target of targetTypes) { if (requestedTargets.includes(target)) { this.currentBuildType = target; - await this.buildAndCopy(url, target); + if (target === 'zst') { + await this.buildAndCopy(url, 'deb'); + await this.createArchPackageFromDeb(); + } else { + await this.buildAndCopy(url, target); + } } } } + private async createArchPackageFromDeb() { + const { name = 'pake-app' } = this.options; + const packageName = generateLinuxPackageName(name); + const version = tauriConfig.version; + const debArch = this.buildArch === 'arm64' ? 'arm64' : 'amd64'; + const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; + const debPath = path.resolve(`${name}_${version}_${debArch}.deb`); + const packagePath = path.resolve( + `${name}-${version}-1-${arch}.pkg.tar.zst`, + ); + const workDir = path.resolve('.pake-arch-package'); + const dataDir = path.join(workDir, 'data'); + const controlDir = path.join(workDir, 'control'); + + await fsExtra.remove(workDir); + await fsExtra.ensureDir(dataDir); + await fsExtra.ensureDir(controlDir); + + try { + await shellExec(`cd "${controlDir}" && ar x "${debPath}"`); + const dataArchive = (await fsExtra.readdir(controlDir)).find((file) => + file.startsWith('data.tar'), + ); + if (!dataArchive) { + throw new Error(`Could not find data.tar payload in ${debPath}`); + } + + await shellExec( + `tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`, + ); + + const installedSize = await this.getDirectorySize(dataDir); + const pkgInfo = `pkgname = ${packageName} +pkgbase = ${packageName} +pkgver = ${version}-1 +pkgdesc = ${name} +url = https://github.com/tw93/Pake +builddate = ${Math.floor(Date.now() / 1000)} +packager = Pake +size = ${installedSize} +arch = ${arch} +license = MIT +depend = cairo +depend = desktop-file-utils +depend = gdk-pixbuf2 +depend = glib2 +depend = gtk3 +depend = hicolor-icon-theme +depend = libsoup +depend = pango +depend = webkit2gtk-4.1 +`; + await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); + await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + } finally { + await fsExtra.remove(workDir); + } + } + + private async getDirectorySize(directory: string): Promise { + let size = 0; + for (const entry of await fsExtra.readdir(directory, { + withFileTypes: true, + })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + size += await this.getDirectorySize(entryPath); + } else if (entry.isFile()) { + size += (await fsExtra.stat(entryPath)).size; + } + } + return size; + } + // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. async buildAndCopy(url: string, target: string) { this.currentBuildType = target; @@ -122,6 +208,9 @@ export default class LinuxBuilder extends BaseBuilder { if (target === 'appimage') { return 'AppImage'; } + if (target === 'zst') { + return 'zst'; + } return super.getFileType(target); } diff --git a/bin/defaults.ts b/bin/defaults.ts index c927754deb..e43819f36e 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -1,5 +1,15 @@ +import fs from 'fs'; import { PakeCliOptions } from './types.js'; +function isArchLinuxBased(): boolean { + try { + const osRelease = fs.readFileSync('/etc/os-release', 'utf8').toLowerCase(); + return osRelease.includes('id=arch') || osRelease.includes('id_like=arch'); + } catch { + return false; + } +} + export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { icon: '', height: 780, @@ -19,7 +29,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return isArchLinuxBased() ? 'zst' : 'deb,appimage'; case 'darwin': return 'dmg'; case 'win32': diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index 1a79f7b040..e250a9c0b5 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -191,16 +191,18 @@ Terminal=false 'deb', 'appimage', 'rpm', + 'zst', 'deb-arm64', 'appimage-arm64', 'rpm-arm64', + 'zst-arm64', ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { - tauriConf.bundle.targets = [baseTarget]; + tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { logger.warn( `✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`, diff --git a/bin/types.ts b/bin/types.ts index c83dd022e9..cb53715fa6 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -63,7 +63,7 @@ export interface PakeCliOptions { multiArch: boolean; // Build target architecture/format: - // Linux: "deb", "appimage", "deb-arm64", "appimage-arm64"; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal" + // Linux: "deb", "appimage", "rpm", "zst" and "*-arm64" variants; Windows: "x64", "arm64"; macOS: "intel", "apple", "universal" targets: string; // Debug mode, outputs more logs diff --git a/dist/cli.js b/dist/cli.js index 306d7e05ce..87256adba7 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -537,15 +537,17 @@ Terminal=false 'deb', 'appimage', 'rpm', + 'zst', 'deb-arm64', 'appimage-arm64', 'rpm-arm64', + 'zst-arm64', ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { - tauriConf.bundle.targets = [baseTarget]; + tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { logger.warn(`✼ The target must be one of ${validTargets.join(', ')}, the default 'deb' will be used.`); @@ -1391,19 +1393,92 @@ class LinuxBuilder extends BaseBuilder { if (this.currentBuildType === 'rpm') { return `${name}-${version}-1.${arch}`; } + if (this.currentBuildType === 'zst') { + return `${name}-${version}-1-${arch}.pkg.tar`; + } return `${name}_${version}_${arch}`; } async build(url) { - const targetTypes = ['deb', 'appimage', 'rpm']; + const targetTypes = ['deb', 'appimage', 'rpm', 'zst']; const requestedTargets = this.options.targets .split(',') .map((t) => t.trim()); for (const target of targetTypes) { if (requestedTargets.includes(target)) { this.currentBuildType = target; - await this.buildAndCopy(url, target); + if (target === 'zst') { + await this.buildAndCopy(url, 'deb'); + await this.createArchPackageFromDeb(); + } + else { + await this.buildAndCopy(url, target); + } + } + } + } + async createArchPackageFromDeb() { + const { name = 'pake-app' } = this.options; + const packageName = generateLinuxPackageName(name); + const version = tauriConfig.version; + const debArch = this.buildArch === 'arm64' ? 'arm64' : 'amd64'; + const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; + const debPath = path.resolve(`${name}_${version}_${debArch}.deb`); + const packagePath = path.resolve(`${name}-${version}-1-${arch}.pkg.tar.zst`); + const workDir = path.resolve('.pake-arch-package'); + const dataDir = path.join(workDir, 'data'); + const controlDir = path.join(workDir, 'control'); + await fsExtra.remove(workDir); + await fsExtra.ensureDir(dataDir); + await fsExtra.ensureDir(controlDir); + try { + await shellExec(`cd "${controlDir}" && ar x "${debPath}"`); + const dataArchive = (await fsExtra.readdir(controlDir)).find((file) => file.startsWith('data.tar')); + if (!dataArchive) { + throw new Error(`Could not find data.tar payload in ${debPath}`); + } + await shellExec(`tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`); + const installedSize = await this.getDirectorySize(dataDir); + const pkgInfo = `pkgname = ${packageName} +pkgbase = ${packageName} +pkgver = ${version}-1 +pkgdesc = ${name} +url = https://github.com/tw93/Pake +builddate = ${Math.floor(Date.now() / 1000)} +packager = Pake +size = ${installedSize} +arch = ${arch} +license = MIT +depend = cairo +depend = desktop-file-utils +depend = gdk-pixbuf2 +depend = glib2 +depend = gtk3 +depend = hicolor-icon-theme +depend = libsoup +depend = pango +depend = webkit2gtk-4.1 +`; + await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); + await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + } + finally { + await fsExtra.remove(workDir); + } + } + async getDirectorySize(directory) { + let size = 0; + for (const entry of await fsExtra.readdir(directory, { + withFileTypes: true, + })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + size += await this.getDirectorySize(entryPath); + } + else if (entry.isFile()) { + size += (await fsExtra.stat(entryPath)).size; } } + return size; } // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. async buildAndCopy(url, target) { @@ -1442,6 +1517,9 @@ class LinuxBuilder extends BaseBuilder { if (target === 'appimage') { return 'AppImage'; } + if (target === 'zst') { + return 'zst'; + } return super.getFileType(target); } hasArchSpecificTarget() { @@ -2373,6 +2451,15 @@ async function handleOptions(options, url) { return appOptions; } +function isArchLinuxBased() { + try { + const osRelease = fs$1.readFileSync('/etc/os-release', 'utf8').toLowerCase(); + return osRelease.includes('id=arch') || osRelease.includes('id_like=arch'); + } + catch { + return false; + } +} const DEFAULT_PAKE_OPTIONS = { icon: '', height: 780, @@ -2391,7 +2478,7 @@ const DEFAULT_PAKE_OPTIONS = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return isArchLinuxBased() ? 'zst' : 'deb,appimage'; case 'darwin': return 'dmg'; case 'win32': diff --git a/docs/cli-usage.md b/docs/cli-usage.md index ac44befd3b..055dfd344d 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -289,7 +289,7 @@ Package the application to support both Intel and M1 chips, exclusively for macO Specify the build target architecture or format: -- **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64` (default: `deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: `zst` on Arch Linux based distributions, otherwise `deb`, `appimage`) - **Windows**: `x64`, `arm64` (auto-detects if not specified) - **macOS**: `intel`, `apple`, `universal` (auto-detects if not specified) @@ -305,9 +305,11 @@ Specify the build target architecture or format: --targets deb # Linux DEB package (x64) --targets rpm # Linux RPM package (x64) --targets appimage # Linux AppImage (x64) +--targets zst # Linux Arch package (x64 .pkg.tar.zst) --targets deb-arm64 # Linux DEB package (ARM64) --targets rpm-arm64 # Linux RPM package (ARM64) --targets appimage-arm64 # Linux AppImage (ARM64) +--targets zst-arm64 # Linux Arch package (ARM64 .pkg.tar.zst) ``` **Note for Linux ARM64**: @@ -315,6 +317,7 @@ Specify the build target architecture or format: - Cross-compilation requires additional setup. Install `gcc-aarch64-linux-gnu` and configure environment variables for cross-compilation. - ARM64 support enables Pake apps to run on ARM-based Linux devices, including Linux phones (postmarketOS, Ubuntu Touch), Raspberry Pi, and other ARM64 Linux systems. - Use `--target appimage-arm64` for portable ARM64 applications that work across different ARM64 Linux distributions. +- Use `--targets zst` on Arch Linux based distributions to produce a `.pkg.tar.zst` package directly. Pake follows Tauri's AUR packaging guidance by building the Linux package payload first, then emitting Arch package metadata and zstd-compressed output. #### [user-agent] diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 5da2e765ed..8ca85d677f 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -287,7 +287,7 @@ pake https://github.com --name GitHub 指定构建目标架构或格式: -- **Linux**: `deb`, `appimage`, `rpm`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`(默认:`deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(基于 Arch Linux 的发行版默认:`zst`,其它 Linux 默认:`deb`, `appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) - **macOS**: `intel`, `apple`, `universal`(未指定时自动检测) @@ -303,9 +303,11 @@ pake https://github.com --name GitHub --targets deb # Linux DEB 包(x64) --targets rpm # Linux RPM 包(x64) --targets appimage # Linux AppImage(x64) +--targets zst # Linux Arch 包(x64 .pkg.tar.zst) --targets deb-arm64 # Linux DEB 包(ARM64) --targets rpm-arm64 # Linux RPM 包(ARM64) --targets appimage-arm64 # Linux AppImage(ARM64) +--targets zst-arm64 # Linux Arch 包(ARM64 .pkg.tar.zst) ``` **Linux ARM64 注意事项**: @@ -313,6 +315,7 @@ pake https://github.com --name GitHub - 交叉编译需要额外设置。需要安装 `gcc-aarch64-linux-gnu` 并配置交叉编译环境变量。 - ARM64 支持让 Pake 应用可以在基于 ARM 的 Linux 设备上运行,包括 Linux 手机(postmarketOS、Ubuntu Touch)、树莓派和其他 ARM64 Linux 系统。 - 使用 `--target appimage-arm64` 可以创建便携式 ARM64 应用,在不同的 ARM64 Linux 发行版上运行。 +- 在基于 Arch Linux 的发行版上使用 `--targets zst` 可直接生成 `.pkg.tar.zst` 包。Pake 会按 Tauri 的 AUR 打包说明先生成 Linux 包内容,再写入 Arch 包元数据并输出 zstd 压缩包。 #### [user-agent] diff --git a/tests/unit/builders.test.ts b/tests/unit/builders.test.ts index dfedb9ca2c..c532e9ed38 100644 --- a/tests/unit/builders.test.ts +++ b/tests/unit/builders.test.ts @@ -9,7 +9,7 @@ describe('Multi-target build parsing', () => { * Simulates the logic from LinuxBuilder.build() */ function parseAndFilterTargets(targetsString: string): string[] { - const validTargets = ['deb', 'appimage', 'rpm']; + const validTargets = ['deb', 'appimage', 'rpm', 'zst']; const requestedTargets = targetsString .split(',') .map((t: string) => t.trim()); @@ -33,10 +33,10 @@ describe('Multi-target build parsing', () => { }); it('should handle targets with spaces', () => { - const result = parseAndFilterTargets('deb, appimage, rpm'); + const result = parseAndFilterTargets('deb, appimage, rpm, zst'); - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); + expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); + expect(result).toHaveLength(4); }); it('should filter out invalid targets', () => { @@ -48,10 +48,10 @@ describe('Multi-target build parsing', () => { }); it('should handle all valid targets', () => { - const result = parseAndFilterTargets('deb,appimage,rpm'); + const result = parseAndFilterTargets('deb,appimage,rpm,zst'); - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); + expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); + expect(result).toHaveLength(4); }); it('should return empty array for all invalid targets', () => { @@ -62,10 +62,12 @@ describe('Multi-target build parsing', () => { }); it('should handle excessive whitespace', () => { - const result = parseAndFilterTargets(' deb , appimage , rpm '); + const result = parseAndFilterTargets( + ' deb , appimage , rpm , zst ', + ); - expect(result).toEqual(['deb', 'appimage', 'rpm']); - expect(result).toHaveLength(3); + expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); + expect(result).toHaveLength(4); }); it('should be case-sensitive', () => { @@ -85,23 +87,24 @@ describe('Multi-target build parsing', () => { describe('Target validation', () => { it('should validate against Linux target types', () => { - const validTargets = ['deb', 'appimage', 'rpm']; + const validTargets = ['deb', 'appimage', 'rpm', 'zst']; expect(validTargets).toContain('deb'); expect(validTargets).toContain('appimage'); expect(validTargets).toContain('rpm'); + expect(validTargets).toContain('zst'); 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 validTargets = ['deb', 'appimage', 'rpm', 'zst']; + const testTargets = ['deb', 'invalid', 'appimage', 'zst', 'msi']; const valid = testTargets.filter((t) => validTargets.includes(t)); const invalid = testTargets.filter((t) => !validTargets.includes(t)); - expect(valid).toEqual(['deb', 'appimage']); + expect(valid).toEqual(['deb', 'appimage', 'zst']); expect(invalid).toEqual(['invalid', 'msi']); }); }); @@ -127,5 +130,12 @@ describe('Multi-target build parsing', () => { expect(format).toBe('appimage'); }); + + it('should handle zst-arm64', () => { + const target = 'zst-arm64'; + const format = target.replace('-arm64', ''); + + expect(format).toBe('zst'); + }); }); }); From a9d7593f9a6e9f8daa70e52d0ca3c07e61defb64 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 21:39:15 -0700 Subject: [PATCH 054/120] fix: convert zst from copied deb artifact --- bin/builders/LinuxBuilder.ts | 3 +-- dist/cli.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index b5055bd238..9dedb442ef 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -84,9 +84,8 @@ export default class LinuxBuilder extends BaseBuilder { const { name = 'pake-app' } = this.options; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; - const debArch = this.buildArch === 'arm64' ? 'arm64' : 'amd64'; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; - const debPath = path.resolve(`${name}_${version}_${debArch}.deb`); + const debPath = path.resolve(`${name}.deb`); const packagePath = path.resolve( `${name}-${version}-1-${arch}.pkg.tar.zst`, ); diff --git a/dist/cli.js b/dist/cli.js index 87256adba7..5c71c3d2cd 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1420,9 +1420,8 @@ class LinuxBuilder extends BaseBuilder { const { name = 'pake-app' } = this.options; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; - const debArch = this.buildArch === 'arm64' ? 'arm64' : 'amd64'; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; - const debPath = path.resolve(`${name}_${version}_${debArch}.deb`); + const debPath = path.resolve(`${name}.deb`); const packagePath = path.resolve(`${name}-${version}-1-${arch}.pkg.tar.zst`); const workDir = path.resolve('.pake-arch-package'); const dataDir = path.join(workDir, 'data'); From 1c05a1a5781c78274ea3f3807cc0a84dc033f634 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 21:42:35 -0700 Subject: [PATCH 055/120] fix: report final zst installer path --- bin/builders/BaseBuilder.ts | 8 +++++--- bin/builders/LinuxBuilder.ts | 9 ++++++--- dist/cli.js | 16 ++++++++++------ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index fc7ae65181..a4899b2781 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -155,7 +155,7 @@ export default abstract class BaseBuilder { await shellExec(command); } - async buildAndCopy(url: string, target: string) { + async buildAndCopy(url: string, target: string, logSuccess = true) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); @@ -233,8 +233,10 @@ export default abstract class BaseBuilder { } await fsExtra.remove(appPath); - logger.success('✔ Build success!'); - logger.success('✔ App installer located in', distPath); + if (logSuccess) { + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', distPath); + } // Log binary location if preserved if (this.options.keepBinary) { diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 9dedb442ef..000d9acee0 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -5,6 +5,7 @@ import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; import { shellExec } from '@/utils/shell'; import { generateLinuxPackageName } from '@/utils/name'; +import logger from '@/options/logger'; export default class LinuxBuilder extends BaseBuilder { private buildFormat: string; @@ -71,7 +72,7 @@ export default class LinuxBuilder extends BaseBuilder { if (requestedTargets.includes(target)) { this.currentBuildType = target; if (target === 'zst') { - await this.buildAndCopy(url, 'deb'); + await this.buildAndCopy(url, 'deb', false); await this.createArchPackageFromDeb(); } else { await this.buildAndCopy(url, target); @@ -133,6 +134,8 @@ depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', packagePath); } finally { await fsExtra.remove(workDir); } @@ -154,9 +157,9 @@ depend = webkit2gtk-4.1 } // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. - async buildAndCopy(url: string, target: string) { + async buildAndCopy(url: string, target: string, logSuccess = true) { this.currentBuildType = target; - await super.buildAndCopy(url, target); + await super.buildAndCopy(url, target, logSuccess); } protected getBuildCommand(packageManager: string = 'pnpm'): string { diff --git a/dist/cli.js b/dist/cli.js index 5c71c3d2cd..4cf675c620 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -991,7 +991,7 @@ class BaseBuilder { const command = `cd "${npmDirectory}" && ${packageManager} run tauri${argSeparator} dev --config "${configPath}" ${featureArgs}`; await shellExec(command); } - async buildAndCopy(url, target) { + async buildAndCopy(url, target, logSuccess = true) { const { name = 'pake-app' } = this.options; await mergeConfig(url, this.options, tauriConfig); const packageManager = await detectPackageManager(); @@ -1052,8 +1052,10 @@ class BaseBuilder { await this.copyRawBinary(npmDirectory, name); } await fsExtra.remove(appPath); - logger.success('✔ Build success!'); - logger.success('✔ App installer located in', distPath); + if (logSuccess) { + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', distPath); + } // Log binary location if preserved if (this.options.keepBinary) { const binaryPath = this.getRawBinaryPath(name); @@ -1407,7 +1409,7 @@ class LinuxBuilder extends BaseBuilder { if (requestedTargets.includes(target)) { this.currentBuildType = target; if (target === 'zst') { - await this.buildAndCopy(url, 'deb'); + await this.buildAndCopy(url, 'deb', false); await this.createArchPackageFromDeb(); } else { @@ -1459,6 +1461,8 @@ depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + logger.success('✔ Build success!'); + logger.success('✔ App installer located in', packagePath); } finally { await fsExtra.remove(workDir); @@ -1480,9 +1484,9 @@ depend = webkit2gtk-4.1 return size; } // Override buildAndCopy to ensure currentBuildType is synced if called directly, though the loop above handles it most of the time. - async buildAndCopy(url, target) { + async buildAndCopy(url, target, logSuccess = true) { this.currentBuildType = target; - await super.buildAndCopy(url, target); + await super.buildAndCopy(url, target, logSuccess); } getBuildCommand(packageManager = 'pnpm') { const configPath = path.join('src-tauri', '.pake', 'tauri.conf.json'); From 5750f704a89c082d89ba5f1e637d61fcbfb3d4dc Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 22:32:57 -0700 Subject: [PATCH 056/120] fix: create valid zst package metadata --- bin/builders/LinuxBuilder.ts | 5 ++++- dist/cli.js | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 000d9acee0..45ed97362c 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -133,7 +133,10 @@ depend = pango depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); - await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + await shellExec( + `bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO usr`, + ); + await fsExtra.remove(debPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', packagePath); } finally { diff --git a/dist/cli.js b/dist/cli.js index 4cf675c620..4bf0b1b022 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1460,7 +1460,8 @@ depend = pango depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); - await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .`); + await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO usr`); + await fsExtra.remove(debPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', packagePath); } From 22d6e490faae9e485e2a4a2e1c4ceccb879114e2 Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 22:40:57 -0700 Subject: [PATCH 057/120] fix: improve zst desktop metadata --- bin/builders/LinuxBuilder.ts | 31 +++++++++++++++++++++++++++++-- bin/helpers/merge.ts | 8 +++++--- bin/options/index.ts | 4 ++++ bin/types.ts | 1 + dist/cli.js | 32 +++++++++++++++++++++++++++----- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 45ed97362c..0c84208138 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -83,6 +83,7 @@ export default class LinuxBuilder extends BaseBuilder { private async createArchPackageFromDeb() { const { name = 'pake-app' } = this.options; + const displayName = this.options.displayName || name; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; @@ -110,12 +111,21 @@ export default class LinuxBuilder extends BaseBuilder { await shellExec( `tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`, ); + await fsExtra.remove( + path.join( + dataDir, + 'usr', + 'share', + 'applications', + `${packageName}.desktop`, + ), + ); const installedSize = await this.getDirectorySize(dataDir); const pkgInfo = `pkgname = ${packageName} pkgbase = ${packageName} pkgver = ${version}-1 -pkgdesc = ${name} +pkgdesc = ${displayName} Pake app url = https://github.com/tw93/Pake builddate = ${Math.floor(Date.now() / 1000)} packager = Pake @@ -133,8 +143,25 @@ depend = pango depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); + await fsExtra.writeFile( + path.join(dataDir, '.INSTALL'), + `post_install() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} + +post_upgrade() { + post_install +} + +post_remove() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} +`, + ); await shellExec( - `bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO usr`, + `bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`, ); await fsExtra.remove(debPath); logger.success('✔ Build success!'); diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index e250a9c0b5..f6a18b9b9b 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -151,21 +151,23 @@ async function mergeLinuxConfig( delete linuxBundle.deb.files; const linuxName = generateLinuxPackageName(name); + const displayName = options.displayName || name; const desktopFileName = `com.pake.${linuxName}.desktop`; - const iconName = `${linuxName}_512`; + const iconName = `pake-${linuxName}`; const { title } = options; const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application -Name=${name} +Name=${displayName} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} -Comment=${name} +Comment=${displayName} Pake app Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; +StartupWMClass=${linuxBinaryName} StartupNotify=true Terminal=false `; diff --git a/bin/options/index.ts b/bin/options/index.ts index 63945874d3..3b353a1f29 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -49,6 +49,7 @@ export default async function handleOptions( const { platform } = process; const isActions = process.env.GITHUB_ACTIONS; let name = options.name; + let displayName = options.name; const pathExists = await fsExtra.pathExists(url); if (!options.name) { @@ -58,9 +59,11 @@ export default async function handleOptions( const promptMessage = 'Enter your application name'; const namePrompt = await promptText(promptMessage, defaultName); name = namePrompt?.trim() || defaultName; + displayName = name; } if (name && platform === 'linux') { + displayName = displayName || name; name = generateLinuxPackageName(name); } @@ -83,6 +86,7 @@ export default async function handleOptions( const appOptions: PakeAppOptions = { ...options, name: resolvedName, + displayName: displayName || resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; diff --git a/bin/types.ts b/bin/types.ts index cb53715fa6..d626af10bc 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -141,6 +141,7 @@ export interface PakeCliOptions { export interface PakeAppOptions extends PakeCliOptions { identifier: string; + displayName?: string; } export interface PlatformSpecific { diff --git a/dist/cli.js b/dist/cli.js index 4bf0b1b022..eaa61d20d6 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -502,20 +502,22 @@ async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) { } delete linuxBundle.deb.files; const linuxName = generateLinuxPackageName(name); + const displayName = options.displayName || name; const desktopFileName = `com.pake.${linuxName}.desktop`; - const iconName = `${linuxName}_512`; + const iconName = `pake-${linuxName}`; const { title } = options; const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application -Name=${name} +Name=${displayName} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} -Comment=${name} +Comment=${displayName} Pake app Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; +StartupWMClass=${linuxBinaryName} StartupNotify=true Terminal=false `; @@ -1420,6 +1422,7 @@ class LinuxBuilder extends BaseBuilder { } async createArchPackageFromDeb() { const { name = 'pake-app' } = this.options; + const displayName = this.options.displayName || name; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; @@ -1438,11 +1441,12 @@ class LinuxBuilder extends BaseBuilder { throw new Error(`Could not find data.tar payload in ${debPath}`); } await shellExec(`tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`); + await fsExtra.remove(path.join(dataDir, 'usr', 'share', 'applications', `${packageName}.desktop`)); const installedSize = await this.getDirectorySize(dataDir); const pkgInfo = `pkgname = ${packageName} pkgbase = ${packageName} pkgver = ${version}-1 -pkgdesc = ${name} +pkgdesc = ${displayName} Pake app url = https://github.com/tw93/Pake builddate = ${Math.floor(Date.now() / 1000)} packager = Pake @@ -1460,7 +1464,21 @@ depend = pango depend = webkit2gtk-4.1 `; await fsExtra.writeFile(path.join(dataDir, '.PKGINFO'), pkgInfo); - await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO usr`); + await fsExtra.writeFile(path.join(dataDir, '.INSTALL'), `post_install() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} + +post_upgrade() { + post_install +} + +post_remove() { + gtk-update-icon-cache -q -t -f usr/share/icons/hicolor + update-desktop-database -q usr/share/applications +} +`); + await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`); await fsExtra.remove(debPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', packagePath); @@ -2419,6 +2437,7 @@ async function handleOptions(options, url) { const { platform } = process; const isActions = process.env.GITHUB_ACTIONS; let name = options.name; + let displayName = options.name; const pathExists = await fsExtra.pathExists(url); if (!options.name) { const defaultName = pathExists @@ -2427,8 +2446,10 @@ async function handleOptions(options, url) { const promptMessage = 'Enter your application name'; const namePrompt = await promptText(promptMessage, defaultName); name = namePrompt?.trim() || defaultName; + displayName = name; } if (name && platform === 'linux') { + displayName = displayName || name; name = generateLinuxPackageName(name); } if (name && !isValidName(name, platform)) { @@ -2448,6 +2469,7 @@ async function handleOptions(options, url) { const appOptions = { ...options, name: resolvedName, + displayName: displayName || resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; const iconPath = await handleIcon(appOptions, url); From f9c135caec215b74941ea0b4421d2cc9f0d8357a Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 22:48:06 -0700 Subject: [PATCH 058/120] fix: preserve display title on Linux --- bin/helpers/merge.ts | 3 +++ dist/cli.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index f6a18b9b9b..91520c88ed 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -446,6 +446,9 @@ export async function mergeConfig( const platform = asSupportedPlatform(process.platform); const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); + if (!tauriConfWindowOptions.title && options.displayName) { + tauriConfWindowOptions.title = options.displayName; + } Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; diff --git a/dist/cli.js b/dist/cli.js index eaa61d20d6..fefcc8cc63 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -707,6 +707,9 @@ async function mergeConfig(url, options, tauriConf) { const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); + if (!tauriConfWindowOptions.title && options.displayName) { + tauriConfWindowOptions.title = options.displayName; + } Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; From 41bb710e4539a00e56d2852dd5dbab0d7766627e Mon Sep 17 00:00:00 2001 From: artrixdotdev Date: Sat, 6 Jun 2026 22:55:30 -0700 Subject: [PATCH 059/120] fix: hide decorated titlebar on Linux --- src-tauri/src/app/window.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 11f94af375..72b21ce55e 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -366,7 +366,10 @@ fn build_window( // Windows and Linux: set data_directory before proxy_url #[cfg(not(target_os = "macos"))] { - window_builder = window_builder.data_directory(_data_dir).theme(None); + window_builder = window_builder + .data_directory(_data_dir) + .decorations(!window_config.hide_title_bar) + .theme(None); if !config.proxy_url.is_empty() { if let Ok(proxy_url) = Url::from_str(&config.proxy_url) { From 274d7cd22db63bc668f9ddf3e25b1a9ec10b40f9 Mon Sep 17 00:00:00 2001 From: Ghraven Date: Sun, 7 Jun 2026 22:44:14 +0800 Subject: [PATCH 060/120] docs: add missing cli dev script alias --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 1b5d080660..04e7427ea5 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "analyze": "cd src-tauri && cargo bloat --release --crates", "tauri": "tauri", "cli": "cross-env NODE_ENV=development rollup -c -w", + "cli:dev": "cross-env NODE_ENV=development rollup -c -w", "cli:build": "cross-env NODE_ENV=production rollup -c", "test": "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", "format": "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", From cb113484c89a23b726c82b04d3fcd4a0bffd4e26 Mon Sep 17 00:00:00 2001 From: Ghraven Date: Sun, 7 Jun 2026 22:45:22 +0800 Subject: [PATCH 061/120] fix: reject malformed zoom values --- bin/helpers/cli-program.ts | 4 ++-- tests/unit/cli-options.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index 4780e0f69d..5460a8baea 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -212,8 +212,8 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option('--zoom ', 'Initial page zoom level (50-200)') .default(DEFAULT.zoom) .argParser((value) => { - const zoom = parseInt(value); - if (isNaN(zoom) || zoom < 50 || zoom > 200) { + const zoom = Number(value); + if (!Number.isFinite(zoom) || zoom < 50 || zoom > 200) { throw new Error('--zoom must be a number between 50 and 200'); } return zoom; diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 3262fd4eaa..0f588dd846 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -46,4 +46,14 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); expect(option?.hidden).toBe(true); }); + + it('rejects malformed zoom values instead of truncating them', () => { + const option = program.options.find((item) => item.long === '--zoom'); + + expect(option).toBeDefined(); + expect(option?.parseArg?.('80', undefined)).toBe(80); + expect(() => option?.parseArg?.('80abc', undefined)).toThrow( + '--zoom must be a number between 50 and 200', + ); + }); }); From df62f187722844e27ef1c6804688a9522b1be7b0 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 7 Jun 2026 22:49:42 +0800 Subject: [PATCH 062/120] fix: allow dots in desktop app names --- bin/options/index.ts | 12 +++++----- dist/cli.js | 8 +++---- tests/unit/options-name.test.ts | 41 +++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 tests/unit/options-name.test.ts diff --git a/bin/options/index.ts b/bin/options/index.ts index 63945874d3..bcb6a0aa8d 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -18,7 +18,7 @@ function resolveAppName(name: string, platform: NodeJS.Platform): string { return platform !== 'linux' ? capitalizeFirstLetter(domain) : domain; } -function resolveLocalAppName( +export function resolveLocalAppName( filePath: string, platform: NodeJS.Platform, ): string { @@ -27,18 +27,18 @@ function resolveLocalAppName( return generateLinuxPackageName(baseName) || 'pake-app'; } const normalized = baseName - .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '') - .replace(/^[ -]+/, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff .-]/g, '') + .replace(/^[ .-]+/, '') .replace(/\s+/g, ' ') .trim(); return normalized || 'pake-app'; } -function isValidName(name: string, platform: NodeJS.Platform): boolean { +export function isValidName(name: string, platform: NodeJS.Platform): boolean { const reg = platform === 'linux' ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/ - : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/; + : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff .-]*$/; return !!name && reg.test(name); } @@ -66,7 +66,7 @@ export default async function handleOptions( if (name && !isValidName(name, platform)) { const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`; - const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`; + const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dots, dashes, and spaces (not leading dots, dashes, and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, Vectorizer.AI, 123.`; const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; if (isActions) { diff --git a/dist/cli.js b/dist/cli.js index 306d7e05ce..d0e9d81c8e 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2321,8 +2321,8 @@ function resolveLocalAppName(filePath, platform) { return generateLinuxPackageName(baseName) || 'pake-app'; } const normalized = baseName - .replace(/[^a-zA-Z0-9\u4e00-\u9fff -]/g, '') - .replace(/^[ -]+/, '') + .replace(/[^a-zA-Z0-9\u4e00-\u9fff .-]/g, '') + .replace(/^[ .-]+/, '') .replace(/\s+/g, ' ') .trim(); return normalized || 'pake-app'; @@ -2330,7 +2330,7 @@ function resolveLocalAppName(filePath, platform) { function isValidName(name, platform) { const reg = platform === 'linux' ? /^[a-z0-9\u4e00-\u9fff][a-z0-9\u4e00-\u9fff-]*$/ - : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff- ]*$/; + : /^[a-zA-Z0-9\u4e00-\u9fff][a-zA-Z0-9\u4e00-\u9fff .-]*$/; return !!name && reg.test(name); } async function handleOptions(options, url) { @@ -2351,7 +2351,7 @@ async function handleOptions(options, url) { } if (name && !isValidName(name, platform)) { const LINUX_NAME_ERROR = `✕ Name should only include lowercase letters, numbers, and dashes (not leading dashes). Examples: com-123-xxx, 123pan, pan123, weread, we-read, 123.`; - const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dashes, and spaces (not leading dashes and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, 123.`; + const DEFAULT_NAME_ERROR = `✕ Name should only include letters, numbers, dots, dashes, and spaces (not leading dots, dashes, and spaces). Examples: 123pan, 123Pan, Pan123, weread, WeRead, WERead, we-read, We Read, Vectorizer.AI, 123.`; const errorMsg = platform === 'linux' ? LINUX_NAME_ERROR : DEFAULT_NAME_ERROR; if (isActions) { logger.error(errorMsg); diff --git a/tests/unit/options-name.test.ts b/tests/unit/options-name.test.ts new file mode 100644 index 0000000000..e6792e711b --- /dev/null +++ b/tests/unit/options-name.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { isValidName, resolveLocalAppName } from '@/options/index'; + +describe('option name validation', () => { + it('allows dots inside macOS and Windows app names', () => { + expect(isValidName('Vectorizer.AI', 'darwin')).toBe(true); + expect(isValidName('Vectorizer.AI', 'win32')).toBe(true); + }); + + it('rejects leading dots, dashes, and spaces on macOS and Windows', () => { + expect(isValidName('.hidden', 'darwin')).toBe(false); + expect(isValidName('-hidden', 'win32')).toBe(false); + expect(isValidName(' Hidden', 'darwin')).toBe(false); + }); + + it('keeps Linux package names stricter than desktop app names', () => { + expect(isValidName('vectorizer.ai', 'linux')).toBe(false); + expect(isValidName('vectorizer-ai', 'linux')).toBe(true); + }); +}); + +describe('local app name resolution', () => { + it('preserves dots in local file names on macOS and Windows', () => { + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'darwin')).toBe( + 'Vectorizer.AI', + ); + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'win32')).toBe( + 'Vectorizer.AI', + ); + }); + + it('normalizes leading dots from local file names', () => { + expect(resolveLocalAppName('/tmp/.hidden.html', 'darwin')).toBe('hidden'); + }); + + it('normalizes dotted local names for Linux package names', () => { + expect(resolveLocalAppName('/tmp/Vectorizer.AI.html', 'linux')).toBe( + 'vectorizer-ai', + ); + }); +}); From 74acf91daeff226f5fb4661b1b2642203ca349c9 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 7 Jun 2026 22:57:26 +0800 Subject: [PATCH 063/120] chore: bump version to V3.11.8 --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index d0e9d81c8e..29aea20692 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.7"; +var version = "3.11.8"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index 1b5d080660..0442191a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.7", + "version": "3.11.8", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 108dcb1032..6e8628f319 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.7" +version = "3.11.8" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0206abb2cc..5d07e71891 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.7" +version = "3.11.8" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "MIT" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 02b21dd804..6f9c3cba13 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.7", + "version": "3.11.8", "app": { "withGlobalTauri": true, "trayIcon": { From 4966e12718d9d4b2e9c1ddd016f68a4b3a121988 Mon Sep 17 00:00:00 2001 From: Ghraven Date: Mon, 8 Jun 2026 05:13:15 +0800 Subject: [PATCH 064/120] fix: reject non-finite CLI numbers --- bin/utils/validate.ts | 2 +- dist/cli.js | 2 +- tests/unit/cli-options.test.ts | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/utils/validate.ts b/bin/utils/validate.ts index 2ef83f0c2d..a5d073fb89 100644 --- a/bin/utils/validate.ts +++ b/bin/utils/validate.ts @@ -4,7 +4,7 @@ import { normalizeUrl } from './url'; export function validateNumberInput(value: string) { const parsedValue = Number(value); - if (isNaN(parsedValue)) { + if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } return parsedValue; diff --git a/dist/cli.js b/dist/cli.js index 29aea20692..1a3d8c3338 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2430,7 +2430,7 @@ const DEFAULT_PAKE_OPTIONS = { function validateNumberInput(value) { const parsedValue = Number(value); - if (isNaN(parsedValue)) { + if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } return parsedValue; diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 3262fd4eaa..8f45ac9784 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { getCliProgram } from '../../bin/helpers/cli-program.js'; +import { validateNumberInput } from '../../bin/utils/validate.js'; describe('CLI options', () => { const program = getCliProgram(); @@ -46,4 +47,10 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); expect(option?.hidden).toBe(true); }); + + it('rejects non-finite numeric option values', () => { + expect(() => validateNumberInput('Infinity')).toThrow('Not a number.'); + expect(() => validateNumberInput('-Infinity')).toThrow('Not a number.'); + expect(validateNumberInput('1200')).toBe(1200); + }); }); From 2058e49b84aa41056d38e73006fc676796fec061 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 11 Jun 2026 09:55:45 +0800 Subject: [PATCH 065/120] chore: relicense from MIT to GPL-3.0 with a Pake Output Exception Pake's MIT license let closed, paid forks ship on top of its code. GPL-3.0 keeps Pake's own source open and blocks closed-source forks going forward, while the Pake Output Exception leaves every app built with pake-cli free to use and distribute under any license, including proprietary, so normal usage is unaffected. Earlier MIT releases remain under their original terms. Also adds TRADEMARK.md reserving the Pake name and logo. --- LICENSE | 728 +++++++++++++++++++++++++++++++++++++++++-- README.md | 4 + README_CN.md | 4 + TRADEMARK.md | 14 + package.json | 2 +- src-tauri/Cargo.toml | 2 +- 6 files changed, 731 insertions(+), 23 deletions(-) create mode 100644 TRADEMARK.md diff --git a/LICENSE b/LICENSE index b52b582ca3..77c0f78905 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,707 @@ -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. + Pake License + +Pake is free software: you can redistribute it and/or modify it under the +terms of the GNU General Public License version 3 (GPLv3) as published by +the Free Software Foundation, together with the additional permission (the +"Pake Output Exception") stated below. + +------------------------------------------------------------------------ +Pake Output Exception +------------------------------------------------------------------------ + +As an additional permission under section 7 of the GNU General Public +License version 3 ("GPLv3"): + +When you use Pake, or a build of Pake produced by the standard Pake build +and packaging process, to generate a target application ("Pake Output"), +the portions of Pake incorporated into that Pake Output do not by +themselves cause the Pake Output as a whole to be or become subject to the +GPLv3. You may distribute such Pake Output under license terms of your own +choosing, including proprietary terms. + +This permission does NOT apply to Pake itself or to any modified version of +Pake's own source code: anyone who distributes Pake, or a work derived from +Pake's source code beyond the configuration and packaging performed by the +standard build process, remains fully bound by the GPLv3. + +Copyright (c) 2024 Tw93 and the Pake contributors. + +======================================================================== +The full text of the GNU General Public License version 3 follows. +======================================================================== + + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 2e421e1201..40cf514266 100644 --- a/README.md +++ b/README.md @@ -207,3 +207,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. + +## License + +Pake is open source under GPL-3.0, see [LICENSE](./LICENSE); apps you build with Pake are entirely yours to use and distribute. If you fork Pake into your own product, to avoid confusion please give it a different name and credit Pake as the source. diff --git a/README_CN.md b/README_CN.md index 4f4ddfa3aa..7f74941c70 100644 --- a/README_CN.md +++ b/README_CN.md @@ -209,3 +209,7 @@ 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 的网页也欢迎告诉我。 + +## 开源协议 + +Pake 使用 GPL-3.0 协议开源,详见 [LICENSE](./LICENSE),用 Pake 打包生成的应用所有权完全归你,可以自由使用和分发;假如你想基于 fork 重新做一个 Pake 产品,为了避免误解,辛苦换一个名字,并注明来源。 diff --git a/TRADEMARK.md b/TRADEMARK.md new file mode 100644 index 0000000000..0a6cc231b3 --- /dev/null +++ b/TRADEMARK.md @@ -0,0 +1,14 @@ +# Trademark Policy + +"Pake" and the Pake logo are trademarks of the Pake project (Tw93). The code +license covers the code, not the brand. Open source licenses grant copyright, +not trademark. + +We want users to trust that something called "Pake" really is this project. So +if you publish a fork or a derived product, please: + +- Use your own name and icon, not "Pake" or the Pake logo. +- Don't imply your product is endorsed by or affiliated with Pake. +- Don't use the Pake name to market a paid or competing product. + +Permission requests: open an issue at . diff --git a/package.json b/package.json index 0442191a35..6b5ae46711 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "type": "module", "exports": "./dist/cli.js", - "license": "MIT", + "license": "SEE LICENSE IN LICENSE", "dependencies": { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5d07e71891..dbd677ef2d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ name = "pake" version = "3.11.8" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] -license = "MIT" +license = "GPL-3.0-or-later" repository = "https://github.com/tw93/Pake" edition = "2021" rust-version = "1.85.0" From 3075d30566cb579e5a4557174bcad985b61d2437 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 11 Jun 2026 09:58:29 +0800 Subject: [PATCH 066/120] chore: keep LICENSE as pure GPLv3, move output exception to LICENSE-EXCEPTION Embedding the exception inside LICENSE made GitHub report the license as Other. Keeping LICENSE as verbatim GPLv3 lets it detect GPL-3.0, with the Pake Output Exception in a separate LICENSE-EXCEPTION file. package.json now declares GPL-3.0-or-later. --- LICENSE | 33 --------------------------------- LICENSE-EXCEPTION | 20 ++++++++++++++++++++ package.json | 2 +- 3 files changed, 21 insertions(+), 34 deletions(-) create mode 100644 LICENSE-EXCEPTION diff --git a/LICENSE b/LICENSE index 77c0f78905..f288702d2f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,36 +1,3 @@ - Pake License - -Pake is free software: you can redistribute it and/or modify it under the -terms of the GNU General Public License version 3 (GPLv3) as published by -the Free Software Foundation, together with the additional permission (the -"Pake Output Exception") stated below. - ------------------------------------------------------------------------- -Pake Output Exception ------------------------------------------------------------------------- - -As an additional permission under section 7 of the GNU General Public -License version 3 ("GPLv3"): - -When you use Pake, or a build of Pake produced by the standard Pake build -and packaging process, to generate a target application ("Pake Output"), -the portions of Pake incorporated into that Pake Output do not by -themselves cause the Pake Output as a whole to be or become subject to the -GPLv3. You may distribute such Pake Output under license terms of your own -choosing, including proprietary terms. - -This permission does NOT apply to Pake itself or to any modified version of -Pake's own source code: anyone who distributes Pake, or a work derived from -Pake's source code beyond the configuration and packaging performed by the -standard build process, remains fully bound by the GPLv3. - -Copyright (c) 2024 Tw93 and the Pake contributors. - -======================================================================== -The full text of the GNU General Public License version 3 follows. -======================================================================== - - GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 diff --git a/LICENSE-EXCEPTION b/LICENSE-EXCEPTION new file mode 100644 index 0000000000..bc68a88c5a --- /dev/null +++ b/LICENSE-EXCEPTION @@ -0,0 +1,20 @@ +Pake Output Exception + +This is an exception to the GNU General Public License version 3 (GPLv3), +under which Pake is licensed (see the LICENSE file). + +As an additional permission under section 7 of the GPLv3: + +When you use Pake, or a build of Pake produced by the standard Pake build +and packaging process, to generate a target application ("Pake Output"), +the portions of Pake incorporated into that Pake Output do not by +themselves cause the Pake Output as a whole to be or become subject to the +GPLv3. You may distribute such Pake Output under license terms of your own +choosing, including proprietary terms. + +This permission does NOT apply to Pake itself or to any modified version of +Pake's own source code: anyone who distributes Pake, or a work derived from +Pake's source code beyond the configuration and packaging performed by the +standard build process, remains fully bound by the GPLv3. + +Copyright (c) 2024 Tw93 and the Pake contributors. diff --git a/package.json b/package.json index 6b5ae46711..92b4d7e3e1 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ }, "type": "module", "exports": "./dist/cli.js", - "license": "SEE LICENSE IN LICENSE", + "license": "GPL-3.0-or-later", "dependencies": { "@tauri-apps/api": "~2.10.1", "@tauri-apps/cli": "^2.10.0", From 59046f88c224462bc5e8ef3a70f97d36c73db3a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:44:55 +0000 Subject: [PATCH 067/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 995efd87e5..b43e0a0903 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -519,6 +519,17 @@ + + + + + + + + Ghraven + + + @@ -529,7 +540,7 @@ Fechin - + @@ -540,7 +551,7 @@ fvn-elmy - + @@ -551,7 +562,7 @@ turkyden - + @@ -562,7 +573,7 @@ kuishou68 - + @@ -573,7 +584,7 @@ nekomeowww - + @@ -584,7 +595,7 @@ kidylee - + @@ -595,7 +606,7 @@ ACGNnsj - + From 5e4e1e21dffc76358d28ca991c387af4e7d14066 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:05:08 +0800 Subject: [PATCH 068/120] refactor: scope zst support to packaging path and harden arch package build Keep the PR focused on the zst target: revert the unrelated window decorations, desktop-entry icon rename, StartupWMClass, and displayName threading so existing Linux targets keep their current behavior. Make zst explicit opt-in instead of switching the default on Arch hosts, pre-check ar/bsdtar with actionable install guidance since their diagnostics stream to the terminal, use libsoup3 to match the Tauri v2 webkit2gtk-4.1 stack, and stop claiming MIT in .PKGINFO after the GPL-3.0 relicense. --- bin/builders/LinuxBuilder.ts | 24 +++++++++++++++--- bin/defaults.ts | 12 +-------- bin/helpers/merge.ts | 11 +++------ bin/options/index.ts | 4 --- bin/types.ts | 1 - dist/cli.js | 48 +++++++++++++++++------------------- docs/cli-usage.md | 4 +-- docs/cli-usage_CN.md | 4 +-- src-tauri/src/app/window.rs | 5 +--- 9 files changed, 51 insertions(+), 62 deletions(-) diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 0c84208138..3d1e3fc8b4 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -81,9 +81,24 @@ export default class LinuxBuilder extends BaseBuilder { } } + private async ensureArchPackagingTools() { + const requiredTools = [ + { tool: 'ar', pacmanPackage: 'binutils' }, + { tool: 'bsdtar', pacmanPackage: 'libarchive' }, + ]; + for (const { tool, pacmanPackage } of requiredTools) { + try { + await shellExec(`command -v ${tool} >/dev/null 2>&1`); + } catch { + throw new Error( + `Building a zst package requires "${tool}". Install it first, e.g. "sudo pacman -S ${pacmanPackage}".`, + ); + } + } + } + private async createArchPackageFromDeb() { const { name = 'pake-app' } = this.options; - const displayName = this.options.displayName || name; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; @@ -95,6 +110,7 @@ export default class LinuxBuilder extends BaseBuilder { const dataDir = path.join(workDir, 'data'); const controlDir = path.join(workDir, 'control'); + await this.ensureArchPackagingTools(); await fsExtra.remove(workDir); await fsExtra.ensureDir(dataDir); await fsExtra.ensureDir(controlDir); @@ -125,20 +141,20 @@ export default class LinuxBuilder extends BaseBuilder { const pkgInfo = `pkgname = ${packageName} pkgbase = ${packageName} pkgver = ${version}-1 -pkgdesc = ${displayName} Pake app +pkgdesc = ${name} Pake app url = https://github.com/tw93/Pake builddate = ${Math.floor(Date.now() / 1000)} packager = Pake size = ${installedSize} arch = ${arch} -license = MIT +license = custom depend = cairo depend = desktop-file-utils depend = gdk-pixbuf2 depend = glib2 depend = gtk3 depend = hicolor-icon-theme -depend = libsoup +depend = libsoup3 depend = pango depend = webkit2gtk-4.1 `; diff --git a/bin/defaults.ts b/bin/defaults.ts index e43819f36e..c927754deb 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -1,15 +1,5 @@ -import fs from 'fs'; import { PakeCliOptions } from './types.js'; -function isArchLinuxBased(): boolean { - try { - const osRelease = fs.readFileSync('/etc/os-release', 'utf8').toLowerCase(); - return osRelease.includes('id=arch') || osRelease.includes('id_like=arch'); - } catch { - return false; - } -} - export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { icon: '', height: 780, @@ -29,7 +19,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { targets: (() => { switch (process.platform) { case 'linux': - return isArchLinuxBased() ? 'zst' : 'deb,appimage'; + return 'deb,appimage'; case 'darwin': return 'dmg'; case 'win32': diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index 91520c88ed..e250a9c0b5 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -151,23 +151,21 @@ async function mergeLinuxConfig( delete linuxBundle.deb.files; const linuxName = generateLinuxPackageName(name); - const displayName = options.displayName || name; const desktopFileName = `com.pake.${linuxName}.desktop`; - const iconName = `pake-${linuxName}`; + const iconName = `${linuxName}_512`; const { title } = options; const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application -Name=${displayName} +Name=${name} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} -Comment=${displayName} Pake app +Comment=${name} Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; -StartupWMClass=${linuxBinaryName} StartupNotify=true Terminal=false `; @@ -446,9 +444,6 @@ export async function mergeConfig( const platform = asSupportedPlatform(process.platform); const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); - if (!tauriConfWindowOptions.title && options.displayName) { - tauriConfWindowOptions.title = options.displayName; - } Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; diff --git a/bin/options/index.ts b/bin/options/index.ts index 95202be982..bcb6a0aa8d 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -49,7 +49,6 @@ export default async function handleOptions( const { platform } = process; const isActions = process.env.GITHUB_ACTIONS; let name = options.name; - let displayName = options.name; const pathExists = await fsExtra.pathExists(url); if (!options.name) { @@ -59,11 +58,9 @@ export default async function handleOptions( const promptMessage = 'Enter your application name'; const namePrompt = await promptText(promptMessage, defaultName); name = namePrompt?.trim() || defaultName; - displayName = name; } if (name && platform === 'linux') { - displayName = displayName || name; name = generateLinuxPackageName(name); } @@ -86,7 +83,6 @@ export default async function handleOptions( const appOptions: PakeAppOptions = { ...options, name: resolvedName, - displayName: displayName || resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; diff --git a/bin/types.ts b/bin/types.ts index d626af10bc..cb53715fa6 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -141,7 +141,6 @@ export interface PakeCliOptions { export interface PakeAppOptions extends PakeCliOptions { identifier: string; - displayName?: string; } export interface PlatformSpecific { diff --git a/dist/cli.js b/dist/cli.js index 4f6a0eab05..b7110f57a2 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -502,22 +502,20 @@ async function mergeLinuxConfig(options, name, tauriConf, linuxBinaryName) { } delete linuxBundle.deb.files; const linuxName = generateLinuxPackageName(name); - const displayName = options.displayName || name; const desktopFileName = `com.pake.${linuxName}.desktop`; - const iconName = `pake-${linuxName}`; + const iconName = `${linuxName}_512`; const { title } = options; const chineseName = title && /[\u4e00-\u9fa5]/.test(title) ? title : null; const desktopContent = `[Desktop Entry] Version=1.0 Type=Application -Name=${displayName} +Name=${name} ${chineseName ? `Name[zh_CN]=${chineseName}` : ''} -Comment=${displayName} Pake app +Comment=${name} Exec=${linuxBinaryName} Icon=${iconName} Categories=Network;WebBrowser;Utility; MimeType=text/html;text/xml;application/xhtml_xml; -StartupWMClass=${linuxBinaryName} StartupNotify=true Terminal=false `; @@ -707,9 +705,6 @@ async function mergeConfig(url, options, tauriConf) { const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); - if (!tauriConfWindowOptions.title && options.displayName) { - tauriConfWindowOptions.title = options.displayName; - } Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; tauriConf.identifier = identifier; @@ -1423,9 +1418,22 @@ class LinuxBuilder extends BaseBuilder { } } } + async ensureArchPackagingTools() { + const requiredTools = [ + { tool: 'ar', pacmanPackage: 'binutils' }, + { tool: 'bsdtar', pacmanPackage: 'libarchive' }, + ]; + for (const { tool, pacmanPackage } of requiredTools) { + try { + await shellExec(`command -v ${tool} >/dev/null 2>&1`); + } + catch { + throw new Error(`Building a zst package requires "${tool}". Install it first, e.g. "sudo pacman -S ${pacmanPackage}".`); + } + } + } async createArchPackageFromDeb() { const { name = 'pake-app' } = this.options; - const displayName = this.options.displayName || name; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; const arch = this.buildArch === 'arm64' ? 'aarch64' : 'x86_64'; @@ -1434,6 +1442,7 @@ class LinuxBuilder extends BaseBuilder { const workDir = path.resolve('.pake-arch-package'); const dataDir = path.join(workDir, 'data'); const controlDir = path.join(workDir, 'control'); + await this.ensureArchPackagingTools(); await fsExtra.remove(workDir); await fsExtra.ensureDir(dataDir); await fsExtra.ensureDir(controlDir); @@ -1449,20 +1458,20 @@ class LinuxBuilder extends BaseBuilder { const pkgInfo = `pkgname = ${packageName} pkgbase = ${packageName} pkgver = ${version}-1 -pkgdesc = ${displayName} Pake app +pkgdesc = ${name} Pake app url = https://github.com/tw93/Pake builddate = ${Math.floor(Date.now() / 1000)} packager = Pake size = ${installedSize} arch = ${arch} -license = MIT +license = custom depend = cairo depend = desktop-file-utils depend = gdk-pixbuf2 depend = glib2 depend = gtk3 depend = hicolor-icon-theme -depend = libsoup +depend = libsoup3 depend = pango depend = webkit2gtk-4.1 `; @@ -2440,7 +2449,6 @@ async function handleOptions(options, url) { const { platform } = process; const isActions = process.env.GITHUB_ACTIONS; let name = options.name; - let displayName = options.name; const pathExists = await fsExtra.pathExists(url); if (!options.name) { const defaultName = pathExists @@ -2449,10 +2457,8 @@ async function handleOptions(options, url) { const promptMessage = 'Enter your application name'; const namePrompt = await promptText(promptMessage, defaultName); name = namePrompt?.trim() || defaultName; - displayName = name; } if (name && platform === 'linux') { - displayName = displayName || name; name = generateLinuxPackageName(name); } if (name && !isValidName(name, platform)) { @@ -2472,7 +2478,6 @@ async function handleOptions(options, url) { const appOptions = { ...options, name: resolvedName, - displayName: displayName || resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; const iconPath = await handleIcon(appOptions, url); @@ -2480,15 +2485,6 @@ async function handleOptions(options, url) { return appOptions; } -function isArchLinuxBased() { - try { - const osRelease = fs$1.readFileSync('/etc/os-release', 'utf8').toLowerCase(); - return osRelease.includes('id=arch') || osRelease.includes('id_like=arch'); - } - catch { - return false; - } -} const DEFAULT_PAKE_OPTIONS = { icon: '', height: 780, @@ -2507,7 +2503,7 @@ const DEFAULT_PAKE_OPTIONS = { targets: (() => { switch (process.platform) { case 'linux': - return isArchLinuxBased() ? 'zst' : 'deb,appimage'; + return 'deb,appimage'; case 'darwin': return 'dmg'; case 'win32': diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 055dfd344d..b296141587 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -289,7 +289,7 @@ Package the application to support both Intel and M1 chips, exclusively for macO Specify the build target architecture or format: -- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: `zst` on Arch Linux based distributions, otherwise `deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: `deb`, `appimage`) - **Windows**: `x64`, `arm64` (auto-detects if not specified) - **macOS**: `intel`, `apple`, `universal` (auto-detects if not specified) @@ -317,7 +317,7 @@ Specify the build target architecture or format: - Cross-compilation requires additional setup. Install `gcc-aarch64-linux-gnu` and configure environment variables for cross-compilation. - ARM64 support enables Pake apps to run on ARM-based Linux devices, including Linux phones (postmarketOS, Ubuntu Touch), Raspberry Pi, and other ARM64 Linux systems. - Use `--target appimage-arm64` for portable ARM64 applications that work across different ARM64 Linux distributions. -- Use `--targets zst` on Arch Linux based distributions to produce a `.pkg.tar.zst` package directly. Pake follows Tauri's AUR packaging guidance by building the Linux package payload first, then emitting Arch package metadata and zstd-compressed output. +- Use `--targets zst` on Arch Linux based distributions to produce a `.pkg.tar.zst` package directly. Pake follows Tauri's AUR packaging guidance by building the Linux package payload first, then emitting Arch package metadata and zstd-compressed output. Requires `binutils` (for `ar`) and `libarchive` (for `bsdtar`). #### [user-agent] diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 8ca85d677f..2a8a49a2b6 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -287,7 +287,7 @@ pake https://github.com --name GitHub 指定构建目标架构或格式: -- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(基于 Arch Linux 的发行版默认:`zst`,其它 Linux 默认:`deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(默认:`deb`, `appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) - **macOS**: `intel`, `apple`, `universal`(未指定时自动检测) @@ -315,7 +315,7 @@ pake https://github.com --name GitHub - 交叉编译需要额外设置。需要安装 `gcc-aarch64-linux-gnu` 并配置交叉编译环境变量。 - ARM64 支持让 Pake 应用可以在基于 ARM 的 Linux 设备上运行,包括 Linux 手机(postmarketOS、Ubuntu Touch)、树莓派和其他 ARM64 Linux 系统。 - 使用 `--target appimage-arm64` 可以创建便携式 ARM64 应用,在不同的 ARM64 Linux 发行版上运行。 -- 在基于 Arch Linux 的发行版上使用 `--targets zst` 可直接生成 `.pkg.tar.zst` 包。Pake 会按 Tauri 的 AUR 打包说明先生成 Linux 包内容,再写入 Arch 包元数据并输出 zstd 压缩包。 +- 在基于 Arch Linux 的发行版上使用 `--targets zst` 可直接生成 `.pkg.tar.zst` 包。Pake 会按 Tauri 的 AUR 打包说明先生成 Linux 包内容,再写入 Arch 包元数据并输出 zstd 压缩包。需要预先安装 `binutils`(提供 `ar`)和 `libarchive`(提供 `bsdtar`)。 #### [user-agent] diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 72b21ce55e..11f94af375 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -366,10 +366,7 @@ fn build_window( // Windows and Linux: set data_directory before proxy_url #[cfg(not(target_os = "macos"))] { - window_builder = window_builder - .data_directory(_data_dir) - .decorations(!window_config.hide_title_bar) - .theme(None); + 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) { From fdbc54fd3bb0290e3e008db9b59f60ca9c203d5d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:20:20 +0800 Subject: [PATCH 069/120] refactor: dedupe linux target handling and tighten cli number validation The zst branches in getFileName/getFileType were unreachable (zst is remapped to deb before buildAndCopy) and encoded a package name format that contradicted the real one in createArchPackageFromDeb. Target filtering now lives in bin/utils/targets.ts so tests exercise the real implementation instead of a hand-synced copy, a typo in --targets fails loudly instead of exiting 0 with nothing built, and shared numeric CLI options reject negative values. --- bin/builders/LinuxBuilder.ts | 38 ++++---- bin/helpers/merge.ts | 12 +-- bin/utils/targets.ts | 8 ++ bin/utils/validate.ts | 3 + dist/cli.js | 57 +++++------ tests/unit/builders.test.ts | 173 +++++++++------------------------ tests/unit/cli-options.test.ts | 5 + 7 files changed, 112 insertions(+), 184 deletions(-) create mode 100644 bin/utils/targets.ts diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 3d1e3fc8b4..0953215000 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -5,6 +5,7 @@ import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; import { shellExec } from '@/utils/shell'; import { generateLinuxPackageName } from '@/utils/name'; +import { LINUX_TARGET_TYPES, filterLinuxTargets } from '@/utils/targets'; import logger from '@/options/logger'; export default class LinuxBuilder extends BaseBuilder { @@ -55,28 +56,24 @@ export default class LinuxBuilder extends BaseBuilder { return `${name}-${version}-1.${arch}`; } - if (this.currentBuildType === 'zst') { - return `${name}-${version}-1-${arch}.pkg.tar`; - } - return `${name}_${version}_${arch}`; } async build(url: string) { - const targetTypes = ['deb', 'appimage', 'rpm', 'zst']; - const requestedTargets = this.options.targets - .split(',') - .map((t: string) => t.trim()); - - for (const target of targetTypes) { - if (requestedTargets.includes(target)) { - this.currentBuildType = target; - if (target === 'zst') { - await this.buildAndCopy(url, 'deb', false); - await this.createArchPackageFromDeb(); - } else { - await this.buildAndCopy(url, target); - } + const targets = filterLinuxTargets(this.options.targets); + if (targets.length === 0) { + throw new Error( + `No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`, + ); + } + + for (const target of targets) { + this.currentBuildType = target; + if (target === 'zst') { + await this.buildAndCopy(url, 'deb', false); + await this.createArchPackageFromDeb(); + } else { + await this.buildAndCopy(url, target); } } } @@ -127,6 +124,8 @@ export default class LinuxBuilder extends BaseBuilder { await shellExec( `tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`, ); + // Drop the desktop entry auto-generated by the Tauri deb bundler; + // the payload already ships Pake's own com.pake..desktop. await fsExtra.remove( path.join( dataDir, @@ -256,9 +255,6 @@ post_remove() { if (target === 'appimage') { return 'AppImage'; } - if (target === 'zst') { - return 'zst'; - } return super.getFileType(target); } diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index e250a9c0b5..70c2c6b832 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -17,6 +17,7 @@ import { WindowConfig, } from '@/types'; import { tauriConfigDirectory, npmDirectory } from '@/utils/dir'; +import { LINUX_TARGET_TYPES } from '@/utils/targets'; /** * Pure transform from CLI options to the window-config slice that gets @@ -188,20 +189,15 @@ Terminal=false }; const validTargets = [ - 'deb', - 'appimage', - 'rpm', - 'zst', - 'deb-arm64', - 'appimage-arm64', - 'rpm-arm64', - 'zst-arm64', + ...LINUX_TARGET_TYPES, + ...LINUX_TARGET_TYPES.map((target) => `${target}-arm64`), ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { + // zst is repacked from the deb payload, so Tauri itself bundles a deb. tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { logger.warn( diff --git a/bin/utils/targets.ts b/bin/utils/targets.ts new file mode 100644 index 0000000000..b3a9b61aff --- /dev/null +++ b/bin/utils/targets.ts @@ -0,0 +1,8 @@ +export const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst']; + +// Returns the valid Linux build targets from a comma-separated targets +// string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped. +export function filterLinuxTargets(targets: string): string[] { + const requested = targets.split(',').map((target) => target.trim()); + return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); +} diff --git a/bin/utils/validate.ts b/bin/utils/validate.ts index a5d073fb89..b85153c133 100644 --- a/bin/utils/validate.ts +++ b/bin/utils/validate.ts @@ -7,6 +7,9 @@ export function validateNumberInput(value: string) { if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } + if (parsedValue < 0) { + throw new InvalidArgumentError('Must not be negative.'); + } return parsedValue; } diff --git a/dist/cli.js b/dist/cli.js index b7110f57a2..04b2a0aa19 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -58,6 +58,7 @@ var scripts = { analyze: "cd src-tauri && cargo bloat --release --crates", tauri: "tauri", cli: "cross-env NODE_ENV=development rollup -c -w", + "cli:dev": "cross-env NODE_ENV=development rollup -c -w", "cli:build": "cross-env NODE_ENV=production rollup -c", test: "pnpm run cli:build && cross-env PAKE_CREATE_APP=1 node tests/index.js", format: "prettier --write . --ignore-unknown && find tests -name '*.js' -exec sed -i '' 's/[[:space:]]*$//' {} \\; && cd src-tauri && cargo fmt --verbose", @@ -409,6 +410,14 @@ function generateIdentifierSafeName(name) { return cleaned; } +const LINUX_TARGET_TYPES = ['deb', 'appimage', 'rpm', 'zst']; +// Returns the valid Linux build targets from a comma-separated targets +// string, preserving LINUX_TARGET_TYPES order. Unknown entries are dropped. +function filterLinuxTargets(targets) { + const requested = targets.split(',').map((target) => target.trim()); + return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); +} + /** * Pure transform from CLI options to the window-config slice that gets * merged into pake.json. Exposed for snapshot testing so option drift @@ -534,19 +543,14 @@ Terminal=false [desktopInstallPath]: `assets/${desktopFileName}`, }; const validTargets = [ - 'deb', - 'appimage', - 'rpm', - 'zst', - 'deb-arm64', - 'appimage-arm64', - 'rpm-arm64', - 'zst-arm64', + ...LINUX_TARGET_TYPES, + ...LINUX_TARGET_TYPES.map((target) => `${target}-arm64`), ]; const baseTarget = options.targets.includes('-arm64') ? options.targets.replace('-arm64', '') : options.targets; if (validTargets.includes(options.targets)) { + // zst is repacked from the deb payload, so Tauri itself bundles a deb. tauriConf.bundle.targets = [baseTarget === 'zst' ? 'deb' : baseTarget]; } else { @@ -1395,26 +1399,21 @@ class LinuxBuilder extends BaseBuilder { if (this.currentBuildType === 'rpm') { return `${name}-${version}-1.${arch}`; } - if (this.currentBuildType === 'zst') { - return `${name}-${version}-1-${arch}.pkg.tar`; - } return `${name}_${version}_${arch}`; } async build(url) { - const targetTypes = ['deb', 'appimage', 'rpm', 'zst']; - const requestedTargets = this.options.targets - .split(',') - .map((t) => t.trim()); - for (const target of targetTypes) { - if (requestedTargets.includes(target)) { - this.currentBuildType = target; - if (target === 'zst') { - await this.buildAndCopy(url, 'deb', false); - await this.createArchPackageFromDeb(); - } - else { - await this.buildAndCopy(url, target); - } + const targets = filterLinuxTargets(this.options.targets); + if (targets.length === 0) { + throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`); + } + for (const target of targets) { + this.currentBuildType = target; + if (target === 'zst') { + await this.buildAndCopy(url, 'deb', false); + await this.createArchPackageFromDeb(); + } + else { + await this.buildAndCopy(url, target); } } } @@ -1453,6 +1452,8 @@ class LinuxBuilder extends BaseBuilder { throw new Error(`Could not find data.tar payload in ${debPath}`); } await shellExec(`tar -xf "${path.join(controlDir, dataArchive)}" -C "${dataDir}"`); + // Drop the desktop entry auto-generated by the Tauri deb bundler; + // the payload already ships Pake's own com.pake..desktop. await fsExtra.remove(path.join(dataDir, 'usr', 'share', 'applications', `${packageName}.desktop`)); const installedSize = await this.getDirectorySize(dataDir); const pkgInfo = `pkgname = ${packageName} @@ -1551,9 +1552,6 @@ post_remove() { if (target === 'appimage') { return 'AppImage'; } - if (target === 'zst') { - return 'zst'; - } return super.getFileType(target); } hasArchSpecificTarget() { @@ -2545,6 +2543,9 @@ function validateNumberInput(value) { if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); } + if (parsedValue < 0) { + throw new InvalidArgumentError('Must not be negative.'); + } return parsedValue; } function validateUrlInput(url) { diff --git a/tests/unit/builders.test.ts b/tests/unit/builders.test.ts index c532e9ed38..7747f8a626 100644 --- a/tests/unit/builders.test.ts +++ b/tests/unit/builders.test.ts @@ -1,141 +1,60 @@ 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', 'zst']; - 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, zst'); - - expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); - expect(result).toHaveLength(4); - }); - - 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,zst'); - - expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); - expect(result).toHaveLength(4); - }); - - 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 , zst ', - ); - - expect(result).toEqual(['deb', 'appimage', 'rpm', 'zst']); - expect(result).toHaveLength(4); - }); - - it('should be case-sensitive', () => { - const result = parseAndFilterTargets('DEB,APPIMAGE'); - - // Should not match uppercase - expect(result).toEqual([]); - }); - - it('should handle single target with comma', () => { - const result = parseAndFilterTargets('deb,'); - - expect(result).toEqual(['deb']); - expect(result).toHaveLength(1); - }); +import { + LINUX_TARGET_TYPES, + filterLinuxTargets, +} from '../../bin/utils/targets.js'; + +describe('Linux target filtering', () => { + it('parses a single target', () => { + expect(filterLinuxTargets('deb')).toEqual(['deb']); }); - describe('Target validation', () => { - it('should validate against Linux target types', () => { - const validTargets = ['deb', 'appimage', 'rpm', 'zst']; - - expect(validTargets).toContain('deb'); - expect(validTargets).toContain('appimage'); - expect(validTargets).toContain('rpm'); - expect(validTargets).toContain('zst'); - expect(validTargets).not.toContain('msi'); - expect(validTargets).not.toContain('dmg'); - }); - - it('should check if target is valid', () => { - const validTargets = ['deb', 'appimage', 'rpm', 'zst']; - const testTargets = ['deb', 'invalid', 'appimage', 'zst', 'msi']; - - const valid = testTargets.filter((t) => validTargets.includes(t)); - const invalid = testTargets.filter((t) => !validTargets.includes(t)); - - expect(valid).toEqual(['deb', 'appimage', 'zst']); - expect(invalid).toEqual(['invalid', 'msi']); - }); + it('parses comma-separated targets', () => { + expect(filterLinuxTargets('deb,appimage')).toEqual(['deb', 'appimage']); }); - describe('Architecture suffix handling', () => { - it('should extract format from arm64 target', () => { - const target = 'deb-arm64'; - const format = target.replace('-arm64', ''); + it('handles targets with spaces', () => { + expect(filterLinuxTargets('deb, appimage, rpm, zst')).toEqual([ + 'deb', + 'appimage', + 'rpm', + 'zst', + ]); + }); - expect(format).toBe('deb'); - }); + it('filters out invalid targets', () => { + expect(filterLinuxTargets('deb,invalid,appimage')).toEqual([ + 'deb', + 'appimage', + ]); + }); - it('should keep format without suffix', () => { - const target = 'deb'; - const format = target.replace('-arm64', ''); + it('returns empty array when no target is valid', () => { + expect(filterLinuxTargets('invalid1,invalid2')).toEqual([]); + }); - expect(format).toBe('deb'); - }); + it('handles excessive whitespace', () => { + expect(filterLinuxTargets(' deb , appimage , rpm , zst ')).toEqual([ + 'deb', + 'appimage', + 'rpm', + 'zst', + ]); + }); - it('should handle appimage-arm64', () => { - const target = 'appimage-arm64'; - const format = target.replace('-arm64', ''); + it('is case-sensitive', () => { + expect(filterLinuxTargets('DEB,APPIMAGE')).toEqual([]); + }); - expect(format).toBe('appimage'); - }); + it('ignores trailing commas', () => { + expect(filterLinuxTargets('deb,')).toEqual(['deb']); + }); - it('should handle zst-arm64', () => { - const target = 'zst-arm64'; - const format = target.replace('-arm64', ''); + it('preserves canonical order regardless of input order', () => { + expect(filterLinuxTargets('zst,deb')).toEqual(['deb', 'zst']); + }); - expect(format).toBe('zst'); - }); + it('covers exactly the supported Linux formats', () => { + expect(LINUX_TARGET_TYPES).toEqual(['deb', 'appimage', 'rpm', 'zst']); }); }); diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index b5fab70274..dc79b4b37b 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -63,4 +63,9 @@ describe('CLI options', () => { expect(() => validateNumberInput('-Infinity')).toThrow('Not a number.'); expect(validateNumberInput('1200')).toBe(1200); }); + + it('rejects negative numeric option values', () => { + expect(() => validateNumberInput('-100')).toThrow('Must not be negative.'); + expect(validateNumberInput('0')).toBe(0); + }); }); From 52d6cafa95505404bc4ad33f1040ceb53766b396 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:24:04 +0800 Subject: [PATCH 070/120] fix: appimage guidance needs sudo for gdk-pixbuf cache refresh User evidence in #1220 shows gdk-pixbuf-query-loaders --update-cache fails without root when writing under /usr/lib, and Arch refreshes the cache automatically through a pacman hook after installing librsvg. --- bin/builders/BaseBuilder.ts | 3 ++- dist/cli.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index a4899b2781..9b72246615 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -42,7 +42,8 @@ const APPIMAGE_FAILURE_GUIDANCE = ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + - ' then: gdk-pixbuf-query-loaders --update-cache\n' + + ' then: sudo gdk-pixbuf-query-loaders --update-cache\n' + + ' (Arch refreshes the cache automatically via a pacman hook)\n' + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + 'Still stuck? Build a DEB instead: pake --targets deb\n' + diff --git a/dist/cli.js b/dist/cli.js index 04b2a0aa19..83033bb0b8 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -915,7 +915,8 @@ const APPIMAGE_FAILURE_GUIDANCE = `\n\n${APPIMAGE_BAR}\n` + ' Arch: sudo pacman -S gdk-pixbuf2 librsvg\n' + ' Debian: sudo apt install librsvg2-common gdk-pixbuf2.0-bin\n' + ' Fedora: sudo dnf install gdk-pixbuf2-modules librsvg2\n' + - ' then: gdk-pixbuf-query-loaders --update-cache\n' + + ' then: sudo gdk-pixbuf-query-loaders --update-cache\n' + + ' (Arch refreshes the cache automatically via a pacman hook)\n' + ' • Running in Docker/container: AppImage needs /dev/fuse:\n' + ' --privileged --device /dev/fuse --security-opt apparmor=unconfined\n\n' + 'Still stuck? Build a DEB instead: pake --targets deb\n' + From 2c378871295de135f055991d1c6af535404ec6f3 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:29:16 +0800 Subject: [PATCH 071/120] ci: allow manual npm publish via workflow_dispatch Lets pake-cli hotfixes ship to npm without pushing a V* tag, which would also trigger the full popular-apps and Docker release. The version check falls back to package.json when no tag ref exists. --- .github/workflows/npm-publish.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 65c4360abb..e8dd06bf2b 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -4,6 +4,10 @@ on: push: tags: - "V*" + # Manual npm-only publish for CLI hotfixes that do not need the full + # V* tag release (popular apps, Docker). Version files must already be + # bumped on main; the check step then validates against package.json. + workflow_dispatch: permissions: contents: read @@ -38,7 +42,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Check release version - run: node scripts/check-release-version.mjs "$GITHUB_REF_NAME" + run: node scripts/check-release-version.mjs "${{ github.ref_type == 'tag' && github.ref_name || '' }}" - name: Check formatting run: pnpm run format:check From 59ee437fc0f0ac8e52f579b35db51769857335ac Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:29:16 +0800 Subject: [PATCH 072/120] chore: bump version to V3.11.9 --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index 83033bb0b8..7c43f61677 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.8"; +var version = "3.11.9"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index e938509ab2..69e1c1c0d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.8", + "version": "3.11.9", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6e8628f319..bdee0d7a82 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.8" +version = "3.11.9" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dbd677ef2d..81c5c7578a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.8" +version = "3.11.9" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 6f9c3cba13..61ee855d1f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.8", + "version": "3.11.9", "app": { "withGlobalTauri": true, "trayIcon": { From 235e3d18731fb97c7572a71e447de6632e62696c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Fri, 12 Jun 2026 08:32:26 +0800 Subject: [PATCH 073/120] fix: version check used branch name as tag on workflow_dispatch --- scripts/check-release-version.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs index 4679af1275..1bf87b792c 100644 --- a/scripts/check-release-version.mjs +++ b/scripts/check-release-version.mjs @@ -4,7 +4,11 @@ import fs from "node:fs"; import path from "node:path"; const root = process.cwd(); -const tag = process.argv[2] || process.env.GITHUB_REF_NAME; +// Only trust GITHUB_REF_NAME when the workflow actually runs on a tag; +// on workflow_dispatch it holds the branch name, which is not a version. +const refTag = + process.env.GITHUB_REF_TYPE === "tag" ? process.env.GITHUB_REF_NAME : ""; +const tag = process.argv[2] || refTag; const errors = []; function readText(filePath) { From 3211c5e54564c0429f5adede86f7a250952dd1cd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 13 Jun 2026 07:48:56 +0800 Subject: [PATCH 074/120] docs: collapse sponsors wall and recommend mole for mac in support --- README.md | 5 +++++ README_CN.md | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/README.md b/README.md index 40cf514266..b2112806a6 100644 --- a/README.md +++ b/README.md @@ -205,8 +205,13 @@ Pake's development can not be without these Hackers. They contributed a lot of c - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. +- You can also support me by getting [Mole for Mac](https://mole.fit), my paid app that deep cleans and speeds up your Mac. +
+Thanks to everyone who has fed them 🥩 +

+ ## License diff --git a/README_CN.md b/README_CN.md index 7f74941c70..fd66e3bd70 100644 --- a/README_CN.md +++ b/README_CN.md @@ -203,12 +203,17 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 支持 +
+感谢这些喂过罐头的朋友 🥩 +
+
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 的网页也欢迎告诉我。 +5. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),也是对我最直接的支持。 ## 开源协议 From 1b5072554957fd57a68b0aa5e7f139c1fc28c1c1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 23:49:09 +0000 Subject: [PATCH 075/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 127 +++++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 58 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index b43e0a0903..c42abf3ee7 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,5 +1,5 @@ - - + + @@ -101,6 +101,17 @@ + + + + + + + + artrixdotdev + + + @@ -111,7 +122,7 @@ AielloChan - + @@ -122,7 +133,7 @@ YangguangZhou - + @@ -133,7 +144,7 @@ m1911star - + @@ -144,7 +155,18 @@ gxiang314 - + + + + + + + + + Ghraven + + + @@ -155,7 +177,7 @@ Pake-Actions - + @@ -166,7 +188,7 @@ exposir - + @@ -177,7 +199,7 @@ lkieryan - + @@ -188,7 +210,7 @@ g1eny0ung - + @@ -199,7 +221,7 @@ xinyii - + @@ -210,7 +232,7 @@ Tianj0o - + @@ -221,7 +243,7 @@ QingZ11 - + @@ -232,7 +254,7 @@ vaddisrinivas - + @@ -243,7 +265,7 @@ mattbajorek - + @@ -254,7 +276,7 @@ kittizz - + @@ -265,7 +287,7 @@ eltociear - + @@ -276,7 +298,7 @@ GoodbyeNJN - + @@ -287,7 +309,7 @@ AllDaGearNoIdea - + @@ -298,7 +320,7 @@ princemaple - + @@ -309,7 +331,7 @@ RoyRao2333 - + @@ -320,7 +342,7 @@ sebastianbreguel - + @@ -331,7 +353,7 @@ youxi798 - + @@ -342,7 +364,7 @@ fulldecent - + @@ -353,7 +375,7 @@ beautifulrem - + @@ -364,7 +386,7 @@ bocanhcam - + @@ -375,7 +397,7 @@ dbraendle - + @@ -386,7 +408,7 @@ geekvest - + @@ -397,7 +419,7 @@ lakca - + @@ -408,7 +430,7 @@ liudonghua123 - + @@ -419,7 +441,7 @@ liusishan - + @@ -430,7 +452,7 @@ piaoyidage - + @@ -441,7 +463,7 @@ enihsyou - + @@ -452,7 +474,7 @@ hetz - + @@ -463,7 +485,7 @@ pgoslatara - + @@ -474,7 +496,7 @@ Milo123459 - + @@ -485,7 +507,7 @@ Jason6987 - + @@ -496,7 +518,7 @@ JohannLai - + @@ -507,7 +529,7 @@ droid-Q - + @@ -518,18 +540,7 @@ ImgBotApp - - - - - - - - - Ghraven - - - + @@ -540,7 +551,7 @@ Fechin - + @@ -551,7 +562,7 @@ fvn-elmy - + @@ -562,7 +573,7 @@ turkyden - + @@ -573,7 +584,7 @@ kuishou68 - + @@ -584,7 +595,7 @@ nekomeowww - + @@ -595,7 +606,7 @@ kidylee - + @@ -606,7 +617,7 @@ ACGNnsj - + From d8c6b2d9aebcc58f8d12ba43000805d04d698a0a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 13 Jun 2026 07:53:58 +0800 Subject: [PATCH 076/120] docs: pair cat line with sponsors wall and reword collapsed summary --- README.md | 4 ++-- README_CN.md | 14 +++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index b2112806a6..4f50c83e0f 100644 --- a/README.md +++ b/README.md @@ -204,11 +204,11 @@ Pake's development can not be without these Hackers. They contributed a lot of c - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. -- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. - You can also support me by getting [Mole for Mac](https://mole.fit), my paid app that deep cleans and speeds up your Mac. +- I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩.
-Thanks to everyone who has fed them 🥩 +These lovely people already did 🐱

diff --git a/README_CN.md b/README_CN.md index fd66e3bd70..274e0b7f51 100644 --- a/README_CN.md +++ b/README_CN.md @@ -203,18 +203,18 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 支持 +1. 如果你喜欢 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) 给志同道合的朋友使用。 +2. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) 聊天群。 +3. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 +4. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),也是对我最直接的支持。 +5. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 +
-感谢这些喂过罐头的朋友 🥩 +这些可爱的朋友已经喂过了 🐱
-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 的网页也欢迎告诉我。 -5. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),也是对我最直接的支持。 - ## 开源协议 Pake 使用 GPL-3.0 协议开源,详见 [LICENSE](./LICENSE),用 Pake 打包生成的应用所有权完全归你,可以自由使用和分发;假如你想基于 fork 重新做一个 Pake 产品,为了避免误解,辛苦换一个名字,并注明来源。 From eb4761e3ef2ef3f665bbb99f361acc5eb3a7e1c4 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 13 Jun 2026 07:55:42 +0800 Subject: [PATCH 077/120] docs: lead support section with mole for mac --- README.md | 2 +- README_CN.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4f50c83e0f..edc9be32da 100644 --- a/README.md +++ b/README.md @@ -202,9 +202,9 @@ Pake's development can not be without these Hackers. They contributed a lot of c ## Support +- The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid app that deep cleans and speeds up your Mac. - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. -- You can also support me by getting [Mole for Mac](https://mole.fit), my paid app that deep cleans and speeds up your Mac. - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩.
diff --git a/README_CN.md b/README_CN.md index 274e0b7f51..91464ca2bd 100644 --- a/README_CN.md +++ b/README_CN.md @@ -203,10 +203,10 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 支持 -1. 如果你喜欢 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) 给志同道合的朋友使用。 -2. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) 聊天群。 -3. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 -4. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),也是对我最直接的支持。 +1. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),是对我最直接的支持。 +2. 如果你喜欢 Pake,可以在 Github Star,更欢迎 [推荐](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20一键打包网页生成轻量桌面应用,比%20Electron%20小%2020%20倍,支持%20macOS%20Windows%20Linux) 给志同道合的朋友使用。 +3. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) 聊天群。 +4. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 5. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩
From 13b2d5ae1548e1acc7f5a37d75cdc7c6954b2a9c Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 13 Jun 2026 18:06:42 +0800 Subject: [PATCH 078/120] docs: shorten mole for mac support line to avoid wrap --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index edc9be32da..11027c211a 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c ## Support -- The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid app that deep cleans and speeds up your Mac. +- The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid Mac cleanup app. - If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. - Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩. From 7eb7bd9fb1448550c6debaf872b9420174ed3311 Mon Sep 17 00:00:00 2001 From: Ghraven Date: Sat, 13 Jun 2026 22:43:07 +0800 Subject: [PATCH 079/120] fix: reject blank numeric CLI values --- bin/utils/validate.ts | 3 +++ dist/cli.js | 3 +++ tests/unit/cli-options.test.ts | 5 +++++ 3 files changed, 11 insertions(+) diff --git a/bin/utils/validate.ts b/bin/utils/validate.ts index b85153c133..3ed66d681b 100644 --- a/bin/utils/validate.ts +++ b/bin/utils/validate.ts @@ -3,6 +3,9 @@ import { InvalidArgumentError } from 'commander'; import { normalizeUrl } from './url'; export function validateNumberInput(value: string) { + if (value.trim() === '') { + throw new InvalidArgumentError('Not a number.'); + } const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); diff --git a/dist/cli.js b/dist/cli.js index 7c43f61677..9337e411a4 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2540,6 +2540,9 @@ const DEFAULT_PAKE_OPTIONS = { }; function validateNumberInput(value) { + if (value.trim() === '') { + throw new InvalidArgumentError('Not a number.'); + } const parsedValue = Number(value); if (!Number.isFinite(parsedValue)) { throw new InvalidArgumentError('Not a number.'); diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index dc79b4b37b..85969b664e 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -64,6 +64,11 @@ describe('CLI options', () => { expect(validateNumberInput('1200')).toBe(1200); }); + it('rejects blank numeric option values', () => { + expect(() => validateNumberInput('')).toThrow('Not a number.'); + expect(() => validateNumberInput(' ')).toThrow('Not a number.'); + }); + it('rejects negative numeric option values', () => { expect(() => validateNumberInput('-100')).toThrow('Must not be negative.'); expect(validateNumberInput('0')).toBe(0); From 3ebe77fbc75de6a0fe87fa487697bba8cadd1a7c Mon Sep 17 00:00:00 2001 From: a5677746shdh Date: Mon, 15 Jun 2026 00:32:05 +0800 Subject: [PATCH 080/120] feat: add min-width, min-height, and app-version to pake-cli workflow --- .github/workflows/pake-cli.yaml | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/pake-cli.yaml b/.github/workflows/pake-cli.yaml index e8caab6bd1..80dc92a1b6 100644 --- a/.github/workflows/pake-cli.yaml +++ b/.github/workflows/pake-cli.yaml @@ -33,6 +33,16 @@ on: description: "Window height (px)" required: false default: "780" + min_width: + description: "Minimum window width (px)" + required: false + min_height: + description: "Minimum window height (px)" + required: false + app_version: + description: "App version" + required: false + default: "1.0.0" fullscreen: description: "Start in fullscreen mode" required: false @@ -112,6 +122,18 @@ jobs: ARGS+=("--height" "${{ inputs.height }}") fi + if [ -n "${{ inputs.min_width }}" ]; then + ARGS+=("--min-width" "${{ inputs.min_width }}") + fi + + if [ -n "${{ inputs.min_height }}" ]; then + ARGS+=("--min-height" "${{ inputs.min_height }}") + fi + + if [ -n "${{ inputs.app_version }}" ]; then + ARGS+=("--app-version" "${{ inputs.app_version }}") + fi + if [ "${{ inputs.fullscreen }}" == "true" ]; then ARGS+=("--fullscreen") fi @@ -150,6 +172,18 @@ jobs: $args += "--height", "${{ inputs.height }}" } + if ("${{ inputs.min_width }}" -ne "") { + $args += "--min-width", "${{ inputs.min_width }}" + } + + if ("${{ inputs.min_height }}" -ne "") { + $args += "--min-height", "${{ inputs.min_height }}" + } + + if ("${{ inputs.app_version }}" -ne "") { + $args += "--app-version", "${{ inputs.app_version }}" + } + if ("${{ inputs.fullscreen }}" -eq "true") { $args += "--fullscreen" } From 0419e982df19e1ef68de6fc5eb769f5485077e1e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 15 Jun 2026 16:16:15 +0800 Subject: [PATCH 081/120] fix: avoid macOS auth popup crash --- src-tauri/src/inject/event.js | 45 ++++++++++++++++++---- tests/unit/event-link-guard.test.js | 59 ++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 7e327e70ad..163ebd3893 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -272,6 +272,33 @@ function shouldBypassPakeLinkHandling(rawHref) { ); } +function shouldNavigateAuthInCurrentWindow() { + return /macintosh|mac os x/i.test(navigator.userAgent); +} + +function canNavigateAuthUrl(url) { + const normalizedUrl = normalizeAnchorHref(url).toLowerCase(); + return normalizedUrl !== "" && normalizedUrl !== "about:blank"; +} + +function navigateInCurrentWindow(url) { + window.location.href = url; + return window; +} + +function openAuthNavigation(originalWindowOpen, url, name, specs) { + if (shouldNavigateAuthInCurrentWindow() && canNavigateAuthUrl(url)) { + return navigateInCurrentWindow(url); + } + + const authWindow = originalWindowOpen.call(window, url, name, specs); + if (!authWindow) { + return navigateInCurrentWindow(url); + } + + return authWindow; +} + document.addEventListener("DOMContentLoaded", () => { const tauri = window.__TAURI__; const appWindow = tauri.window.getCurrentWindow(); @@ -440,16 +467,12 @@ document.addEventListener("DOMContentLoaded", () => { e.preventDefault(); e.stopImmediatePropagation(); - const authWindow = originalWindowOpen.call( - window, + openAuthNavigation( + originalWindowOpen, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); - - if (!authWindow) { - window.location.href = absoluteUrl; - } } return; @@ -544,9 +567,15 @@ document.addEventListener("DOMContentLoaded", () => { return originalWindowOpen.call(window, url, name, specs); } - // Allow authentication popups to open normally + // Avoid macOS WebKit auth-popup crashes by navigating auth URLs in-place. if (window.isAuthPopup(url, name)) { - return originalWindowOpen.call(window, url, name, specs); + try { + const baseUrl = window.location.origin + window.location.pathname; + const absoluteUrl = new URL(url, baseUrl).href; + return openAuthNavigation(originalWindowOpen, absoluteUrl, name, specs); + } catch (error) { + return openAuthNavigation(originalWindowOpen, url, name, specs); + } } try { diff --git a/tests/unit/event-link-guard.test.js b/tests/unit/event-link-guard.test.js index 2172907a87..6f2c2d0a26 100644 --- a/tests/unit/event-link-guard.test.js +++ b/tests/unit/event-link-guard.test.js @@ -3,7 +3,10 @@ import path from "path"; import { runInNewContext } from "node:vm"; import { describe, expect, it } from "vitest"; -function loadEventHelpers({ withTauri = false } = {}) { +function loadEventHelpers({ + withTauri = false, + userAgent = "Mozilla/5.0", +} = {}) { const source = fs.readFileSync( path.join(process.cwd(), "src-tauri/src/inject/event.js"), "utf-8", @@ -24,7 +27,7 @@ function loadEventHelpers({ withTauri = false } = {}) { clearTimeout, scrollTo: () => {}, navigator: { - userAgent: "Mozilla/5.0", + userAgent, language: "en-US", }, window: { @@ -35,6 +38,7 @@ function loadEventHelpers({ withTauri = false } = {}) { location: { href: "https://example.com/app", origin: "https://example.com", + pathname: "/app", reload: () => {}, }, localStorage: { @@ -84,6 +88,57 @@ describe("event link guard", () => { ); }); + it("navigates macOS auth URLs in the current window", () => { + const { openAuthNavigation, window } = loadEventHelpers({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5)", + }); + const openCalls = []; + const originalWindowOpen = (url, name, specs) => { + openCalls.push({ url, name, specs }); + return {}; + }; + + const result = openAuthNavigation( + originalWindowOpen, + "https://www.linkedin.com/login", + "_blank", + "width=1200,height=800", + ); + + expect(openCalls).toEqual([]); + expect(window.location.href).toBe("https://www.linkedin.com/login"); + expect(result).toBe(window); + }); + + it("keeps blank macOS auth popups on the native popup path", () => { + const popup = {}; + const { openAuthNavigation, window } = loadEventHelpers({ + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_5)", + }); + const openCalls = []; + const originalWindowOpen = (url, name, specs) => { + openCalls.push({ url, name, specs }); + return popup; + }; + + const result = openAuthNavigation( + originalWindowOpen, + "about:blank", + "login", + "width=1200,height=800", + ); + + expect(openCalls).toEqual([ + { + url: "about:blank", + name: "login", + specs: "width=1200,height=800", + }, + ]); + expect(window.location.href).toBe("https://example.com/app"); + expect(result).toBe(popup); + }); + it("bridges Web Badging API calls to explicit badge commands", async () => { const { navigator, invokeCalls } = loadEventHelpers({ withTauri: true }); From 3ab4473eb101a94a8d0553dc66cd786b29949566 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 15 Jun 2026 16:28:52 +0800 Subject: [PATCH 082/120] test: relax CLI validation timeout --- tests/index.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/index.js b/tests/index.js index 6cf3dde0a4..63c5be29d3 100644 --- a/tests/index.js +++ b/tests/index.js @@ -136,11 +136,15 @@ class PakeTestRunner { try { execSync(`node "${config.CLI_PATH}" --version`, { encoding: "utf8", - timeout: 3000, + timeout: TIMEOUTS.QUICK, }); - console.log("[PASS] CLI is executable"); + console.log("[PASS] CLI responds"); } catch (error) { - console.log("[FAIL] CLI is not executable"); + const reason = + error.signal === "SIGTERM" + ? `timed out after ${TIMEOUTS.QUICK}ms` + : error.message; + console.log(`[FAIL] CLI did not respond: ${reason}`); process.exit(1); } From ca2accfdfcd8dcf32448d17c0370cce9f8fdcf71 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 15 Jun 2026 17:05:12 +0800 Subject: [PATCH 083/120] fix: harden zst and release packaging --- .github/workflows/npm-publish.yml | 8 ++++++ .github/workflows/pake-cli.yaml | 20 ++++++++++++++- README.md | 2 +- README_CN.md | 2 +- bin/builders/LinuxBuilder.ts | 25 ++++++++++++++---- bin/utils/targets.ts | 4 +++ dist/cli.js | 19 +++++++++++--- package.json | 1 + scripts/check-release-version.mjs | 6 +++++ tests/integration/workflow-paths.test.js | 32 +++++++++++++++++++++++- tests/unit/builders.test.ts | 8 ++++++ 11 files changed, 114 insertions(+), 13 deletions(-) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e8dd06bf2b..9661b4fce6 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -25,6 +25,14 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 + - name: Check manual publish branch + if: github.event_name == 'workflow_dispatch' + run: | + if [ "$GITHUB_REF" != "refs/heads/main" ]; then + echo "Manual npm publish must run from main, got ${GITHUB_REF}." + exit 1 + fi + - name: Install pnpm uses: pnpm/action-setup@v4 with: diff --git a/.github/workflows/pake-cli.yaml b/.github/workflows/pake-cli.yaml index 80dc92a1b6..41bd9981ef 100644 --- a/.github/workflows/pake-cli.yaml +++ b/.github/workflows/pake-cli.yaml @@ -59,7 +59,7 @@ on: type: boolean default: false targets: - description: "Package formats (comma-separated: deb,appimage,rpm)" + description: "Package formats (comma-separated: deb,appimage,rpm,zst)" required: false default: "deb" @@ -223,6 +223,24 @@ jobs: retention-days: 3 if-no-files-found: ignore + - name: Upload RPM (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.name }}-Linux-rpm + path: ${{ inputs.name }}.rpm + retention-days: 3 + if-no-files-found: ignore + + - name: Upload ZST (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v6 + with: + name: ${{ inputs.name }}-Linux-zst + path: ${{ inputs.name }}-*.pkg.tar.zst + retention-days: 3 + if-no-files-found: ignore + - name: Upload MSI (Windows) if: runner.os == 'Windows' uses: actions/upload-artifact@v6 diff --git a/README.md b/README.md index 11027c211a..26b5526f9b 100644 --- a/README.md +++ b/README.md @@ -215,4 +215,4 @@ Pake's development can not be without these Hackers. They contributed a lot of c ## License -Pake is open source under GPL-3.0, see [LICENSE](./LICENSE); apps you build with Pake are entirely yours to use and distribute. If you fork Pake into your own product, to avoid confusion please give it a different name and credit Pake as the source. +Pake is open source under GPL-3.0, see [LICENSE](./LICENSE) and [Pake Output Exception](./LICENSE-EXCEPTION); apps you build with Pake are entirely yours to use and distribute. If you fork Pake into your own product, to avoid confusion please give it a different name and credit Pake as the source. diff --git a/README_CN.md b/README_CN.md index 91464ca2bd..f5708e3b8a 100644 --- a/README_CN.md +++ b/README_CN.md @@ -217,4 +217,4 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ ## 开源协议 -Pake 使用 GPL-3.0 协议开源,详见 [LICENSE](./LICENSE),用 Pake 打包生成的应用所有权完全归你,可以自由使用和分发;假如你想基于 fork 重新做一个 Pake 产品,为了避免误解,辛苦换一个名字,并注明来源。 +Pake 使用 GPL-3.0 协议开源,详见 [LICENSE](./LICENSE) 和 [Pake Output Exception](./LICENSE-EXCEPTION),用 Pake 打包生成的应用所有权完全归你,可以自由使用和分发;假如你想基于 fork 重新做一个 Pake 产品,为了避免误解,辛苦换一个名字,并注明来源。 diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 0953215000..3af51e2f0f 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -5,7 +5,11 @@ import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; import { shellExec } from '@/utils/shell'; import { generateLinuxPackageName } from '@/utils/name'; -import { LINUX_TARGET_TYPES, filterLinuxTargets } from '@/utils/targets'; +import { + LINUX_TARGET_TYPES, + filterLinuxTargets, + needsTemporaryDebForZst, +} from '@/utils/targets'; import logger from '@/options/logger'; export default class LinuxBuilder extends BaseBuilder { @@ -66,12 +70,17 @@ export default class LinuxBuilder extends BaseBuilder { `No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`, ); } + const useTemporaryDebForZst = needsTemporaryDebForZst(targets); for (const target of targets) { this.currentBuildType = target; if (target === 'zst') { - await this.buildAndCopy(url, 'deb', false); - await this.createArchPackageFromDeb(); + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); } else { await this.buildAndCopy(url, target); } @@ -94,7 +103,11 @@ export default class LinuxBuilder extends BaseBuilder { } } - private async createArchPackageFromDeb() { + private async createArchPackageFromDeb({ + removeSourceDeb, + }: { + removeSourceDeb: boolean; + }) { const { name = 'pake-app' } = this.options; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; @@ -178,10 +191,12 @@ post_remove() { await shellExec( `bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`, ); - await fsExtra.remove(debPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', packagePath); } finally { + if (removeSourceDeb) { + await fsExtra.remove(debPath); + } await fsExtra.remove(workDir); } } diff --git a/bin/utils/targets.ts b/bin/utils/targets.ts index b3a9b61aff..99599d0e61 100644 --- a/bin/utils/targets.ts +++ b/bin/utils/targets.ts @@ -6,3 +6,7 @@ export function filterLinuxTargets(targets: string): string[] { const requested = targets.split(',').map((target) => target.trim()); return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); } + +export function needsTemporaryDebForZst(targets: string[]): boolean { + return targets.includes('zst') && !targets.includes('deb'); +} diff --git a/dist/cli.js b/dist/cli.js index 9337e411a4..a909f417a0 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -46,6 +46,7 @@ var keywords = [ "productivity" ]; var files = [ + "LICENSE-EXCEPTION", "dist", "src-tauri" ]; @@ -417,6 +418,9 @@ function filterLinuxTargets(targets) { const requested = targets.split(',').map((target) => target.trim()); return LINUX_TARGET_TYPES.filter((target) => requested.includes(target)); } +function needsTemporaryDebForZst(targets) { + return targets.includes('zst') && !targets.includes('deb'); +} /** * Pure transform from CLI options to the window-config slice that gets @@ -1407,11 +1411,16 @@ class LinuxBuilder extends BaseBuilder { if (targets.length === 0) { throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`); } + const useTemporaryDebForZst = needsTemporaryDebForZst(targets); for (const target of targets) { this.currentBuildType = target; if (target === 'zst') { - await this.buildAndCopy(url, 'deb', false); - await this.createArchPackageFromDeb(); + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); } else { await this.buildAndCopy(url, target); @@ -1432,7 +1441,7 @@ class LinuxBuilder extends BaseBuilder { } } } - async createArchPackageFromDeb() { + async createArchPackageFromDeb({ removeSourceDeb, }) { const { name = 'pake-app' } = this.options; const packageName = generateLinuxPackageName(name); const version = tauriConfig.version; @@ -1493,11 +1502,13 @@ post_remove() { } `); await shellExec(`bsdtar --zstd -cf "${packagePath}" -C "${dataDir}" .PKGINFO .INSTALL usr`); - await fsExtra.remove(debPath); logger.success('✔ Build success!'); logger.success('✔ App installer located in', packagePath); } finally { + if (removeSourceDeb) { + await fsExtra.remove(debPath); + } await fsExtra.remove(workDir); } } diff --git a/package.json b/package.json index 69e1c1c0d9..84b483c7ee 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "productivity" ], "files": [ + "LICENSE-EXCEPTION", "dist", "src-tauri" ], diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs index 1bf87b792c..5dcda6e01e 100644 --- a/scripts/check-release-version.mjs +++ b/scripts/check-release-version.mjs @@ -85,6 +85,12 @@ expectEqual( "git+https://github.com/tw93/Pake.git", ); +if (!packageJson.files?.includes("LICENSE-EXCEPTION")) { + errors.push( + "package.json files: LICENSE-EXCEPTION must be included in the npm package", + ); +} + if (errors.length > 0) { console.error("Release version check failed:"); for (const error of errors) { diff --git a/tests/integration/workflow-paths.test.js b/tests/integration/workflow-paths.test.js index 4d6603e6af..cc209dfb5f 100644 --- a/tests/integration/workflow-paths.test.js +++ b/tests/integration/workflow-paths.test.js @@ -6,6 +6,7 @@ */ import { describe, it, expect } from "vitest"; +import fs from "fs"; import path from "path"; describe("Workflow path integration", () => { @@ -27,6 +28,9 @@ describe("Workflow path integration", () => { primary: "appname.rpm", fallback: "src-tauri/target/release/bundle/rpm", }, + zst: { + primary: "appname-1.0.0-1-x86_64.pkg.tar.zst", + }, }; // Verify paths are defined @@ -34,6 +38,8 @@ describe("Workflow path integration", () => { expect(linuxPaths.deb.fallback).toBeTruthy(); expect(linuxPaths.appimage.primary).toBeTruthy(); expect(linuxPaths.appimage.fallback).toBeTruthy(); + expect(linuxPaths.rpm.primary).toBeTruthy(); + expect(linuxPaths.zst.primary).toBeTruthy(); }); it("should match Windows output paths", () => { @@ -99,12 +105,36 @@ describe("Workflow path integration", () => { it("should filter valid targets", () => { const targets = "deb,invalid,appimage"; const parsedTargets = targets.split(",").map((t) => t.trim()); - const validTargets = ["deb", "appimage", "rpm"]; + const validTargets = ["deb", "appimage", "rpm", "zst"]; const filtered = parsedTargets.filter((t) => validTargets.includes(t)); expect(filtered).toEqual(["deb", "appimage"]); expect(filtered).not.toContain("invalid"); }); + + it("should keep zst in valid Linux targets", () => { + const targets = "deb,zst"; + const parsedTargets = targets.split(",").map((t) => t.trim()); + const validTargets = ["deb", "appimage", "rpm", "zst"]; + const filtered = parsedTargets.filter((t) => validTargets.includes(t)); + + expect(filtered).toEqual(["deb", "zst"]); + }); + }); + + describe("Workflow artifact uploads", () => { + it("should upload every Linux workflow package format", () => { + const workflow = fs.readFileSync( + ".github/workflows/pake-cli.yaml", + "utf8", + ); + + expect(workflow).toContain("Upload DEB (Linux)"); + expect(workflow).toContain("Upload AppImage (Linux)"); + expect(workflow).toContain("Upload RPM (Linux)"); + expect(workflow).toContain("Upload ZST (Linux)"); + expect(workflow).toContain("path: ${{ inputs.name }}-*.pkg.tar.zst"); + }); }); describe("Architecture-specific paths", () => { diff --git a/tests/unit/builders.test.ts b/tests/unit/builders.test.ts index 7747f8a626..cafe1e059f 100644 --- a/tests/unit/builders.test.ts +++ b/tests/unit/builders.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'; import { LINUX_TARGET_TYPES, filterLinuxTargets, + needsTemporaryDebForZst, } from '../../bin/utils/targets.js'; describe('Linux target filtering', () => { @@ -57,4 +58,11 @@ describe('Linux target filtering', () => { it('covers exactly the supported Linux formats', () => { expect(LINUX_TARGET_TYPES).toEqual(['deb', 'appimage', 'rpm', 'zst']); }); + + it('uses a temporary deb only when zst is requested without deb', () => { + expect(needsTemporaryDebForZst(['zst'])).toBe(true); + expect(needsTemporaryDebForZst(['appimage', 'zst'])).toBe(true); + expect(needsTemporaryDebForZst(['deb', 'zst'])).toBe(false); + expect(needsTemporaryDebForZst(['deb', 'appimage'])).toBe(false); + }); }); From 98bcb50ffa922e6410f34e2d43f8850ee27c89ba Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 18 Jun 2026 10:49:07 +0800 Subject: [PATCH 084/120] fix: handle niri Wayland AppImage input --- docs/faq.md | 23 +++++++ docs/faq_CN.md | 23 +++++++ src-tauri/src/lib.rs | 144 ++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 182 insertions(+), 8 deletions(-) diff --git a/docs/faq.md b/docs/faq.md index c42214afea..2bd83eae74 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -197,6 +197,29 @@ The `NO_STRIP=1` environment variable is the official workaround recommended by --- +### Linux: AppImage Opens but Buttons or Keyboard Do Not Work on Wayland + +**Problem:** +On some pure Wayland compositors, especially niri, the AppImage can open but page buttons cannot be clicked or keyboard input does not reach the webview. + +**Solution:** +Pake automatically avoids the conservative WebKit rendering flags in niri sessions. To force the same native WebKit path manually, launch the app with: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./MyApp.AppImage +``` + +If your system shows a blank window instead, re-enable the conservative WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./MyApp.AppImage +``` + +**Why This Happens:** +Pake normally enables WebKitGTK workarounds that help blank-window cases on Linux, but those same flags can make input and window controls unreliable on some Wayland compositors. The `PAKE_LINUX_WEBKIT_SAFE_MODE` variable lets you choose the safer rendering mode for your compositor. + +--- + ### Linux: "cargo: command not found" After Installing Rust **Problem:** diff --git a/docs/faq_CN.md b/docs/faq_CN.md index 401a3fbb24..c72847e167 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -197,6 +197,29 @@ docker run --rm --privileged \ --- +### Linux:AppImage 打开后按钮或键盘在 Wayland 下不可用 + +**问题描述:** +在某些纯 Wayland 合成器上,尤其是 niri,AppImage 可以打开,但页面按钮无法点击,键盘输入也无法进入 webview。 + +**解决方案:** +Pake 会在 niri 会话中自动避开保守的 WebKit 渲染参数。也可以手动强制使用原生 WebKit 渲染路径: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./MyApp.AppImage +``` + +如果你的系统反而出现白屏,可以重新启用保守 WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./MyApp.AppImage +``` + +**原因:** +Pake 默认启用的 WebKitGTK workaround 可以缓解 Linux 白屏,但在部分 Wayland 合成器上,这些参数可能导致输入和窗口控件不可用。`PAKE_LINUX_WEBKIT_SAFE_MODE` 可以按当前合成器选择更合适的渲染模式。 + +--- + ### Linux:"cargo: command not found" 即使已安装 Rust **问题描述:** diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93c6c0f9e4..9fe7d2a57f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,12 @@ use tauri_plugin_window_state::StateFlags; use std::time::Duration; const WINDOW_SHOW_DELAY: u64 = 50; +#[cfg(target_os = "linux")] +const PAKE_LINUX_WEBKIT_SAFE_MODE: &str = "PAKE_LINUX_WEBKIT_SAFE_MODE"; +#[cfg(target_os = "linux")] +const WEBKIT_DISABLE_DMABUF_RENDERER: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; +#[cfg(target_os = "linux")] +const WEBKIT_DISABLE_COMPOSITING_MODE: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; use app::{ invoke::{ @@ -21,16 +27,83 @@ use app::{ }; use util::get_pake_config; +#[cfg(any(target_os = "linux", test))] +fn is_disabled_env_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "off" | "no" | "native" | "disabled" + ) +} + +#[cfg(any(target_os = "linux", test))] +fn is_non_empty_env_value(value: Option<&str>) -> bool { + value.map(|value| !value.trim().is_empty()).unwrap_or(false) +} + +#[cfg(any(target_os = "linux", test))] +fn contains_niri(value: &str) -> bool { + value + .split([':', ';', ',', ' ']) + .any(|part| part.eq_ignore_ascii_case("niri")) +} + +#[cfg(any(target_os = "linux", test))] +fn should_enable_linux_webkit_safe_mode_from_values( + safe_mode: Option<&str>, + niri_socket: Option<&str>, + desktop_values: &[Option<&str>], +) -> bool { + if let Some(value) = safe_mode.filter(|value| !value.trim().is_empty()) { + return !is_disabled_env_value(value); + } + + let is_niri_session = is_non_empty_env_value(niri_socket) + || desktop_values + .iter() + .flatten() + .any(|value| contains_niri(value)); + + !is_niri_session +} + +#[cfg(target_os = "linux")] +fn apply_linux_webkit_runtime_flags() { + let safe_mode = std::env::var(PAKE_LINUX_WEBKIT_SAFE_MODE).ok(); + if safe_mode.as_deref().is_some_and(is_disabled_env_value) { + std::env::remove_var(WEBKIT_DISABLE_DMABUF_RENDERER); + std::env::remove_var(WEBKIT_DISABLE_COMPOSITING_MODE); + return; + } + + let desktop_values = [ + std::env::var("XDG_CURRENT_DESKTOP").ok(), + std::env::var("XDG_SESSION_DESKTOP").ok(), + std::env::var("DESKTOP_SESSION").ok(), + ]; + let desktop_refs = desktop_values + .iter() + .map(|value| value.as_deref()) + .collect::>(); + + if !should_enable_linux_webkit_safe_mode_from_values( + safe_mode.as_deref(), + std::env::var("NIRI_SOCKET").ok().as_deref(), + &desktop_refs, + ) { + return; + } + + if std::env::var(WEBKIT_DISABLE_DMABUF_RENDERER).is_err() { + std::env::set_var(WEBKIT_DISABLE_DMABUF_RENDERER, "1"); + } + if std::env::var(WEBKIT_DISABLE_COMPOSITING_MODE).is_err() { + std::env::set_var(WEBKIT_DISABLE_COMPOSITING_MODE, "1"); + } +} + pub fn run_app() { #[cfg(target_os = "linux")] - { - if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); - } - } + apply_linux_webkit_runtime_flags(); let (pake_config, tauri_config) = get_pake_config(); let tauri_app = tauri::Builder::default(); @@ -202,3 +275,58 @@ pub fn run_app() { pub fn run() { run_app() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn linux_webkit_safe_mode_stays_on_by_default() { + assert!(should_enable_linux_webkit_safe_mode_from_values( + None, + None, + &[None, None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_is_disabled_for_niri_socket() { + assert!(!should_enable_linux_webkit_safe_mode_from_values( + None, + Some("/run/user/501/niri.sock"), + &[None, None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_is_disabled_for_niri_desktop() { + assert!(!should_enable_linux_webkit_safe_mode_from_values( + None, + None, + &[Some("niri"), None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_can_be_forced_on_for_niri() { + assert!(should_enable_linux_webkit_safe_mode_from_values( + Some("1"), + Some("/run/user/501/niri.sock"), + &[Some("niri"), None, None] + )); + } + + #[test] + fn linux_webkit_safe_mode_can_be_disabled_explicitly() { + for value in ["0", "false", "off", "no", "native", "disabled"] { + assert!( + !should_enable_linux_webkit_safe_mode_from_values( + Some(value), + None, + &[None, None, None] + ), + "expected {value} to disable safe mode" + ); + } + } +} From 922799a22947a9b1bed0219e65b29f4d21baac2e Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 18 Jun 2026 10:49:18 +0800 Subject: [PATCH 085/120] chore: bump version to V3.11.10 --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index a909f417a0..57f4c35a81 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.9"; +var version = "3.11.10"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index 84b483c7ee..df0e8f07d2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.9", + "version": "3.11.10", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bdee0d7a82..a85a3d2939 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.9" +version = "3.11.10" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 81c5c7578a..0f9efa4872 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.9" +version = "3.11.10" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 61ee855d1f..f922d57da5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.9", + "version": "3.11.10", "app": { "withGlobalTauri": true, "trayIcon": { From 7282870df4e7323ccb6bb901986b9f1cf17378e7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:49:44 +0000 Subject: [PATCH 086/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 91 +++++++++++++++++++++++++++--------------------- 1 file changed, 51 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index c42abf3ee7..6f28aeeb9a 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -135,35 +135,35 @@ - + - - - m1911star + + + Ghraven - + - - - gxiang314 + + + m1911star - + - - - Ghraven + + + gxiang314 @@ -310,17 +310,6 @@ - - - - - - - - princemaple - - - @@ -331,7 +320,7 @@ RoyRao2333 - + @@ -342,7 +331,7 @@ sebastianbreguel - + @@ -353,7 +342,7 @@ youxi798 - + @@ -364,6 +353,17 @@ fulldecent + + + + + + + + + a5677746shdh + + @@ -475,6 +475,17 @@ + + + + + + + + princemaple + + + @@ -485,7 +496,7 @@ pgoslatara - + @@ -496,7 +507,7 @@ Milo123459 - + @@ -507,7 +518,7 @@ Jason6987 - + @@ -518,7 +529,7 @@ JohannLai - + @@ -529,7 +540,7 @@ droid-Q - + @@ -540,7 +551,7 @@ ImgBotApp - + @@ -551,7 +562,7 @@ Fechin - + @@ -562,7 +573,7 @@ fvn-elmy - + @@ -573,7 +584,7 @@ turkyden - + @@ -584,7 +595,7 @@ kuishou68 - + @@ -595,7 +606,7 @@ nekomeowww - + @@ -606,7 +617,7 @@ kidylee - + @@ -617,7 +628,7 @@ ACGNnsj - + From 708dcfd367cca14b7c394041b08b2a1adb1fdc98 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Thu, 18 Jun 2026 11:02:22 +0800 Subject: [PATCH 087/120] docs: harden agent release guidance --- .agents/skills/code-review/SKILL.md | 4 ++++ .agents/skills/release/SKILL.md | 34 ++++++++++++++++++++--------- .agents/skills/use-pake/SKILL.md | 16 ++++++++++++++ .claude/rules/rust.md | 2 ++ .claude/skills/release/SKILL.md | 34 ++++++++++++++++++++--------- AGENTS.md | 15 +++++++++++-- 6 files changed, 83 insertions(+), 22 deletions(-) diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index e352a7f148..a651400682 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -20,11 +20,15 @@ Use Waza `/check` for the generic review method. This adapter adds Pake-specific - [ ] Changes to package metadata embedded by Rollup (`package.json` name/version/repository/bin/scripts/exports) rebuild and commit `dist/cli.js`. - [ ] Release version bumps keep `package.json`, `src-tauri/Cargo.toml`, `src-tauri/Cargo.lock`, and `src-tauri/tauri.conf.json` in sync. - [ ] npm release workflow changes preserve Trusted Publishing: `.github/workflows/npm-publish.yml`, `id-token: write`, canonical `git+https://github.com/tw93/Pake.git`, and `scripts/check-release-version.mjs`. +- [ ] Release/status changes keep npm registry, GitHub Release/assets, workflow run state, and issue closeout as separate truth surfaces. +- [ ] `workflow_dispatch` release logic does not infer the release tag from `headBranch`, run title, or compare UI; use an explicit tag/ref and verify the package `gitHead`. - [ ] No new `tauriConf: any` or other untyped config objects; use `PakeTauriConfig`. - [ ] No user-reachable `panic!` or `.unwrap()` on config, CLI, or event paths. - [ ] Silent `catch {}` blocks surface the real error through `logger.warn`. - [ ] New helper in `bin/utils/` or `bin/helpers/` has a matching `tests/unit/.test.ts`. - [ ] Binary parsers have a round-trip test, not only builder assertions. +- [ ] Linux WebKit/AppImage runtime flag changes keep the default conservative, add or update tests for the decision logic, and update `docs/faq*.md` when users need a fallback command. +- [ ] macOS `--new-window` or auth URL changes include targeted tests for popup/auth routing in `src-tauri/src/inject/event.js`. ## Quick Review Commands diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 33d5e5180f..2dc7b6dee1 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -28,13 +28,14 @@ Four files must be updated in sync — never update one without the others: ### Pre-Release 1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) -2. [ ] Update all four version files above -3. [ ] Run `pnpm run format` — must pass cleanly -4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. -5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). -6. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run -7. [ ] No uncommitted changes: `git status` -8. [ ] Commit version bump with message: `chore: bump version to VX.X.X` +2. [ ] Confirm the version is not already on npm: `npm view pake-cli@X.Y.Z version` should return 404 before publishing +3. [ ] Update all four version files above +4. [ ] Run `pnpm run format` — must pass cleanly +5. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +6. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +7. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +8. [ ] No uncommitted changes: `git status` +9. [ ] Commit version bump with message: `chore: bump version to VX.X.X` ### Tagging (triggers CI) @@ -49,19 +50,32 @@ Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. 1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` 2. [ ] Watch CI status: `gh run watch` -3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X --json tagName,url,assets` 4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` 5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` -6. [ ] Verify npm published the package: `npm view pake-cli version` and `npm view pake-cli@X.Y.Z dist.tarball` +6. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` +7. [ ] Verify latest now resolves to the release: `npm view pake-cli version` +8. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. +Keep release surfaces separate in the final status: + +- npm registry: the authority for `pake-cli` installability and CLI/npm issue closeout. +- GitHub Release/assets: the authority for app installers and popular-app artifact availability. +- Quality workflow: the authority for post-push CI health, but it can continue after npm has already shipped. +- Source/tag: the authority for what code was intended to ship. + +Do not collapse these into "released" without naming which surface was verified. If GitHub Release assets are visible while `gh run list` still reports the release workflow as queued or in progress, trust `gh release view` for asset state and report the workflow state separately. + ## Trusted Publishing Notes - The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. - npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. - If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. -- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. `npm view pake-cli version` alone is not enough because `latest` can point at a different commit than the fix under review. +- A `workflow_dispatch` run may execute on `main`; do not treat `headBranch`, run title, or compare UI as the release tag. Check the pushed tag and published package `gitHead`. +- If CI creates `chore: update contributors [skip ci]` after the tag is pushed, fast-forward local `main` after the release. Do not retag just to include generated contributor art. ## Build Commands (local only) diff --git a/.agents/skills/use-pake/SKILL.md b/.agents/skills/use-pake/SKILL.md index ad7097b1ce..8aa1f898b2 100644 --- a/.agents/skills/use-pake/SKILL.md +++ b/.agents/skills/use-pake/SKILL.md @@ -136,6 +136,22 @@ After build, confirm: Not supported. Pake uses system WebView (WKWebView on macOS, WebView2 on Windows, WebKitGTK on Linux), not a full Chrome browser. Use `--inject` to add custom JS/CSS as an alternative. +### Linux Wayland Input Issues + +If an AppImage opens but buttons cannot be clicked or keyboard input does not reach the page on a pure Wayland compositor, especially niri, first rebuild with the latest `pake-cli`. Then try the native WebKit path: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=0 ./YourApp.AppImage +``` + +If that produces a blank window on the same system, re-enable the conservative WebKit workaround: + +```bash +PAKE_LINUX_WEBKIT_SAFE_MODE=1 ./YourApp.AppImage +``` + +Do not diagnose this from GTK, appindicator, or GStreamer warnings alone; those can be optional runtime warnings unrelated to the input failure. + ## Common Patterns ### Website behind proxy (icon also needs proxy) diff --git a/.claude/rules/rust.md b/.claude/rules/rust.md index afaab53db9..873bb54b04 100644 --- a/.claude/rules/rust.md +++ b/.claude/rules/rust.md @@ -34,5 +34,7 @@ ### Platform sensitivity - WebKit compositing on Linux/Wayland is platform-sensitive. Don't change defaults without testing on the affected platform or documenting the risk. +- Linux WebKit runtime flags live in `src-tauri/src/lib.rs`. Keep the default conservative; compositor-specific exceptions need unit tests for the decision function and FAQ guidance for users. +- AppImage logs often contain optional GTK, appindicator, or GStreamer warnings. Do not treat those warnings as the root cause unless the user-visible symptom and target path confirm it. - `--incognito` trades persistence for clean private sessions; be deliberate around login / cookies / local storage / embedded-WebView detection. - Google OAuth and other embedded-WebView restrictions may still apply even with `--new-window` / `--multi-window`. diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 33d5e5180f..2dc7b6dee1 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -28,13 +28,14 @@ Four files must be updated in sync — never update one without the others: ### Pre-Release 1. [ ] Confirm the new version number (check current: `cat package.json | jq .version`) -2. [ ] Update all four version files above -3. [ ] Run `pnpm run format` — must pass cleanly -4. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. -5. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). -6. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run -7. [ ] No uncommitted changes: `git status` -8. [ ] Commit version bump with message: `chore: bump version to VX.X.X` +2. [ ] Confirm the version is not already on npm: `npm view pake-cli@X.Y.Z version` should return 404 before publishing +3. [ ] Update all four version files above +4. [ ] Run `pnpm run format` — must pass cleanly +5. [ ] Run `pnpm test` — must pass cleanly. If the release workflow step fails with `pnpm install ... exit code 1` against the CN mirror, re-run once; a single transient flake is acceptable, two consecutive failures is not. +6. [ ] Run `pnpm run cli:build` — Rollup + TS must pass (catches type errors that `format` misses). +7. [ ] Run `pnpm run release:check` — verifies version sync, package contents, and npm dry-run +8. [ ] No uncommitted changes: `git status` +9. [ ] Commit version bump with message: `chore: bump version to VX.X.X` ### Tagging (triggers CI) @@ -49,19 +50,32 @@ Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. 1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` 2. [ ] Watch CI status: `gh run watch` -3. [ ] Verify GitHub Release was created: `gh release view VX.X.X` +3. [ ] Verify GitHub Release was created: `gh release view VX.X.X --json tagName,url,assets` 4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` 5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` -6. [ ] Verify npm published the package: `npm view pake-cli version` and `npm view pake-cli@X.Y.Z dist.tarball` +6. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` +7. [ ] Verify latest now resolves to the release: `npm view pake-cli version` +8. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. +Keep release surfaces separate in the final status: + +- npm registry: the authority for `pake-cli` installability and CLI/npm issue closeout. +- GitHub Release/assets: the authority for app installers and popular-app artifact availability. +- Quality workflow: the authority for post-push CI health, but it can continue after npm has already shipped. +- Source/tag: the authority for what code was intended to ship. + +Do not collapse these into "released" without naming which surface was verified. If GitHub Release assets are visible while `gh run list` still reports the release workflow as queued or in progress, trust `gh release view` for asset state and report the workflow state separately. + ## Trusted Publishing Notes - The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. - npm package settings should use the strict publishing option: require two-factor authentication and disallow tokens. Trusted Publishing still works with this setting. - If local fallback is unavoidable, prefer `npm exec --yes --package=pnpm@10.26.2 -- npm publish --registry=https://registry.npmjs.org` so `prepublishOnly` can find the pinned pnpm version. -- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. +- Do not reply to GitHub issues or close them as released until `npm view pake-cli@X.Y.Z version` returns the expected version. `npm view pake-cli version` alone is not enough because `latest` can point at a different commit than the fix under review. +- A `workflow_dispatch` run may execute on `main`; do not treat `headBranch`, run title, or compare UI as the release tag. Check the pushed tag and published package `gitHead`. +- If CI creates `chore: update contributors [skip ci]` after the tag is pushed, fast-forward local `main` after the release. Do not retag just to include generated contributor art. ## Build Commands (local only) diff --git a/AGENTS.md b/AGENTS.md index db780cb164..4485c356ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,7 @@ Execution rules: - Start with the smallest plausible file set - Prefer targeted search (`rg `) over repository-wide scans - Ignore generated or output-heavy areas unless the task directly targets them, especially `dist/`, `node_modules/`, `src-tauri/target/`, `.app/`, `src-tauri/icons/`, and `src-tauri/png/`. Exception: `dist/cli.js` is the shipped CLI build artifact (see `package.json` `files`); when you change anything under `bin/`, rebuild it via `pnpm run cli:build` and commit the regenerated `dist/cli.js` alongside the source change +- If a task touches release status, issue closeout, npm delivery, or GitHub assets, verify live surfaces separately: source commit/tag, workflow run, npm registry, GitHub Release/assets, and issue state. Do not let one passing surface imply another - Keep changes local to one subsystem when possible - Run the narrowest relevant verification first, expand only if needed - If key context is missing, make one reasonable assumption and proceed @@ -95,8 +96,11 @@ Execution rules: - Recent window/runtime options include `--incognito`, `--new-window`, `--min-width`, `--min-height`, `--maximize`, multi-window behavior, notification click handling, and Linux/Wayland WebKit compositing defaults. - `--incognito` intentionally trades persistence for clean private sessions; be careful around login, cookies, local storage, and WeChat-style WebView detection. - `--new-window` and `--multi-window` do not bypass every provider policy. Google OAuth and similar embedded-WebView restrictions may still require a normal browser or native client. +- macOS auth-popup behavior is fragile. Auth/sign-in URLs that trigger WebKit `SOAuthorization` popup creation should stay in the current window when that path can abort the app; changes in `src-tauri/src/inject/event.js` need targeted tests. - Notification flows cross injected JS, Tauri invokes, capabilities, and native notification plugins. Verify the Rust capability and JS caller together. -- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Do not change defaults without testing the affected platform path or documenting the risk. +- WebKit compositing behavior is platform-sensitive on Linux/Wayland. Runtime flag decisions live in `src-tauri/src/lib.rs`; keep the default conservative, cover compositor exceptions with unit tests, and document user-facing fallbacks in `docs/faq*.md`. +- Linux AppImage reports often include harmless GTK, appindicator, or GStreamer warnings. Separate optional runtime warnings from the actual symptom before changing code; input/click failures on pure Wayland compositors are not the same class as blank-window failures. +- Release state can be split. npm Trusted Publishing can succeed before the popular-app release workflow finishes, and GitHub Release assets can exist while a workflow run still shows queued or in progress. Report each surface explicitly. ## Platform-Specific Development @@ -150,7 +154,14 @@ The workflow can also be triggered manually via `workflow_dispatch` with options Pushing the same `V*` tag also triggers `.github/workflows/npm-publish.yml`, which publishes `pake-cli` to npm through Trusted Publishing. Configure the npm package's Trusted Publisher as GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, with no environment. Local `npm publish` is only a fallback when CI or npm registry state blocks the trusted path. -Before treating an npm release as shipped, verify both `gh workflow list --all | grep "Publish npm Package"` and `npm view pake-cli@X.Y.Z version`. Do not reply to or close GitHub issues as released until the public registry returns the expected version. +Before treating an npm release as shipped, verify both `gh workflow list --all | grep "Publish npm Package"` and `npm view pake-cli@X.Y.Z version`. Prefer `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` so the published package can be tied back to the intended commit. Do not reply to or close GitHub issues as released until the public registry returns the expected version. + +For release follow-through, keep these boundaries explicit: + +- `workflow_dispatch` runs on a branch unless a tag ref or input is supplied. Do not infer a release tag from the branch name, run title, or compare UI. +- For CLI/npm issue closeout, the npm registry is the decisive public surface. GitHub app release assets and quality workflows should still be reported, but they are separate surfaces. +- For app-release claims, inspect the GitHub Release directly with `gh release view --json assets` and check asset count/state instead of trusting source state or workflow names alone. +- If CI pushes an automatic `chore: update contributors [skip ci]` commit after release, fast-forward local `main`; do not move an already pushed release tag to include it. `.github/workflows/quality-and-test.yml` runs auto-format on push, Rust quality checks, and CLI/build validation across Linux, Windows, and macOS. From 4c7581ca6fa96de8fb27eedb20d3b7091567f095 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 13:24:02 +0800 Subject: [PATCH 088/120] fix: show all CLI options in help --- bin/dev.ts | 7 +++++++ bin/helpers/cli-program.ts | 14 ++++++++++---- dist/cli.js | 14 ++++++++++---- docs/cli-usage.md | 4 +++- docs/cli-usage_CN.md | 4 +++- tests/unit/cli-options.test.ts | 15 +++++++++++++++ 6 files changed, 48 insertions(+), 10 deletions(-) diff --git a/bin/dev.ts b/bin/dev.ts index 4e4f1b113a..02ec81b34a 100644 --- a/bin/dev.ts +++ b/bin/dev.ts @@ -7,6 +7,13 @@ import { getCliProgram } from './helpers/cli-program'; const program = getCliProgram(); program.action(async (url: string, options: PakeCliOptions) => { + if (!url) { + program.help({ + error: false, + }); + return; + } + log.setDefaultLevel('debug'); const appOptions = await handleInputOptions(options, url); diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index 5460a8baea..8d84e1ff69 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -16,6 +16,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with return program .addHelpText('beforeAll', logo) .usage(`[url] [options]`) + .helpOption('-h, --help', 'Show all CLI options') .showHelpAfterError() .argument('[url]', 'The web URL you want to package', validateUrlInput) .option('--name ', 'Application name') @@ -277,14 +278,19 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .version(packageJson.version, '-v, --version') .configureHelp({ sortSubcommands: true, + visibleOptions: (command) => { + const options = [...command.options]; + const helpOption = (command as unknown as { _helpOption?: Option }) + ._helpOption; + if (helpOption) { + options.push(helpOption); + } + return options; + }, optionTerm: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.flags; }, optionDescription: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.description; }, }); diff --git a/dist/cli.js b/dist/cli.js index 57f4c35a81..d6e8dd24fa 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2590,6 +2590,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with return program$1 .addHelpText('beforeAll', logo) .usage(`[url] [options]`) + .helpOption('-h, --help', 'Show all CLI options') .showHelpAfterError() .argument('[url]', 'The web URL you want to package', validateUrlInput) .option('--name ', 'Application name') @@ -2729,14 +2730,19 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .version(packageJson.version, '-v, --version') .configureHelp({ sortSubcommands: true, + visibleOptions: (command) => { + const options = [...command.options]; + const helpOption = command + ._helpOption; + if (helpOption) { + options.push(helpOption); + } + return options; + }, optionTerm: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.flags; }, optionDescription: (option) => { - if (option.flags === '-v, --version' || option.flags === '-h, --help') - return ''; return option.description; }, }); diff --git a/docs/cli-usage.md b/docs/cli-usage.md index b296141587..5c7b794bae 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -70,7 +70,7 @@ The URL is the link to the web page you want to package or the path to a local H ### [options] -Various options are available for customization. Here are the most commonly used ones: +Various options are available for customization. `pake --help` shows every supported CLI option. This page is the complete reference. | Option | Description | Example | | ------------------ | ----------------------------------------------- | ---------------------------------------------- | @@ -80,6 +80,8 @@ Various options are available for customization. Here are the most commonly used | `--height` | Window height (default: 780px) | `--height 900` | | `--hide-title-bar` | Immersive header (macOS only) | `--hide-title-bar` | | `--debug` | Enable development tools | `--debug` | +| `--help` | Show all CLI options | `--help` | +| `--version` | Show CLI version | `--version` | For complete options, see detailed sections below. diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 2a8a49a2b6..c23db80bc5 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -70,7 +70,7 @@ pake [url] [options] ### [options] -您可以通过传递以下选项来定制打包过程。以下是最常用的选项: +您可以通过传递以下选项来定制打包过程。`pake --help` 展示全部支持的 CLI 选项。本文档是完整参考。 | 选项 | 描述 | 示例 | | ------------------ | ------------------------------------ | ---------------------------------------------- | @@ -80,6 +80,8 @@ pake [url] [options] | `--height` | 窗口高度(默认:780px) | `--height 900` | | `--hide-title-bar` | 沉浸式标题栏(仅macOS) | `--hide-title-bar` | | `--debug` | 启用开发者工具 | `--debug` | +| `--help` | 显示全部 CLI 选项 | `--help` | +| `--version` | 显示 CLI 版本 | `--version` | 完整选项请参见下面的详细说明: diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 85969b664e..51dcbe3d59 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -5,6 +5,21 @@ import { validateNumberInput } from '../../bin/utils/validate.js'; describe('CLI options', () => { const program = getCliProgram(); + it('shows meta options in help', () => { + const help = program.helpInformation(); + + expect(help).toContain('-h, --help'); + expect(help).toContain('-v, --version'); + }); + + it('shows advanced options in help', () => { + const help = program.helpInformation(); + + expect(help).toContain('--enable-find'); + expect(help).toContain('--internal-url-regex'); + expect(help).toContain('--hide-on-close'); + }); + it('registers hidden --multi-window option', () => { const option = program.options.find( (item) => item.long === '--multi-window', From b1bc210eb24410d02c311dfa94fd0ca475ad1712 Mon Sep 17 00:00:00 2001 From: Andrew Barnes Date: Sun, 21 Jun 2026 02:42:08 -0400 Subject: [PATCH 089/120] fix: respect custom Cargo target directories Co-authored-by: Andrew Barnes --- bin/builders/BaseBuilder.ts | 26 +++++++++++----- bin/builders/LinuxBuilder.ts | 14 +++++++-- bin/builders/MacBuilder.ts | 11 +++++-- bin/builders/WinBuilder.ts | 23 ++++++++++++-- dist/cli.js | 54 ++++++++++++++++++++++++++------- tests/unit/base-builder.test.ts | 48 +++++++++++++++++++++++++++++ 6 files changed, 152 insertions(+), 24 deletions(-) diff --git a/bin/builders/BaseBuilder.ts b/bin/builders/BaseBuilder.ts index 9b72246615..130eb7af2b 100644 --- a/bin/builders/BaseBuilder.ts +++ b/bin/builders/BaseBuilder.ts @@ -404,9 +404,19 @@ export default abstract class BaseBuilder { } } + protected getCargoTargetDir(): string { + return process.env.CARGO_TARGET_DIR || path.join('src-tauri', 'target'); + } + + protected resolveBuildPath(npmDirectory: string, buildPath: string): string { + return path.isAbsolute(buildPath) + ? buildPath + : path.join(npmDirectory, buildPath); + } + protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; - return `src-tauri/target/${basePath}/bundle/`; + return path.join(this.getCargoTargetDir(), basePath, 'bundle'); } protected getBuildAppPath( @@ -418,8 +428,7 @@ export default abstract class BaseBuilder { const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); return path.join( - npmDirectory, - this.getBasePath(), + this.resolveBuildPath(npmDirectory, this.getBasePath()), bundleDir, `${fileName}.${fileType}`, ); @@ -459,14 +468,17 @@ export default abstract class BaseBuilder { // Handle cross-platform builds if (this.options.multiArch || this.hasArchSpecificTarget()) { return path.join( - npmDirectory, - this.getArchSpecificPath(), + this.resolveBuildPath(npmDirectory, this.getArchSpecificPath()), basePath, binaryName, ); } - return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName); + return path.join( + this.resolveBuildPath(npmDirectory, this.getCargoTargetDir()), + basePath, + binaryName, + ); } /** @@ -503,6 +515,6 @@ export default abstract class BaseBuilder { * Get architecture-specific path for binary */ protected getArchSpecificPath(): string { - return 'src-tauri/target'; // Override in subclasses if needed + return this.getCargoTargetDir(); // Override in subclasses if needed } } diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 3af51e2f0f..6c3138e170 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -260,7 +260,12 @@ post_remove() { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Linux`, + ); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } return super.getBasePath(); @@ -280,7 +285,12 @@ post_remove() { protected getArchSpecificPath(): string { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Linux`, + ); + } + return path.join(this.getCargoTargetDir(), target); } return super.getArchSpecificPath(); } diff --git a/bin/builders/MacBuilder.ts b/bin/builders/MacBuilder.ts index ebf061d469..1bf52989c3 100644 --- a/bin/builders/MacBuilder.ts +++ b/bin/builders/MacBuilder.ts @@ -76,7 +76,11 @@ export default class MacBuilder extends BaseBuilder { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}/${basePath}/bundle`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } protected hasArchSpecificTarget(): boolean { @@ -86,6 +90,9 @@ export default class MacBuilder extends BaseBuilder { protected getArchSpecificPath(): string { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target); } } diff --git a/bin/builders/WinBuilder.ts b/bin/builders/WinBuilder.ts index 4a3ffc809e..7b1f85e00d 100644 --- a/bin/builders/WinBuilder.ts +++ b/bin/builders/WinBuilder.ts @@ -2,6 +2,7 @@ import path from 'path'; import BaseBuilder from './BaseBuilder'; import { PakeAppOptions } from '@/types'; import tauriConfig from '@/helpers/tauriConfig'; +import { generateIdentifierSafeName } from '@/utils/name'; export default class WinBuilder extends BaseBuilder { private buildFormat: string = 'msi'; @@ -39,7 +40,12 @@ export default class WinBuilder extends BaseBuilder { protected getBasePath(): string { const basePath = this.options.debug ? 'debug' : 'release'; const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Windows`, + ); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } protected hasArchSpecificTarget(): boolean { @@ -48,6 +54,19 @@ export default class WinBuilder extends BaseBuilder { protected getArchSpecificPath(): string { const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error( + `Unsupported architecture: ${this.buildArch} for Windows`, + ); + } + return path.join(this.getCargoTargetDir(), target); + } + + protected getRawBinaryPath(appName: string): string { + return `${appName}.exe`; + } + + protected getBinaryName(appName: string): string { + return `pake-${generateIdentifierSafeName(appName)}.exe`; } } diff --git a/dist/cli.js b/dist/cli.js index d6e8dd24fa..cac395ae99 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1162,14 +1162,22 @@ class BaseBuilder { return 0; // Disable proxy feature if version detection fails } } + getCargoTargetDir() { + return process.env.CARGO_TARGET_DIR || path.join('src-tauri', 'target'); + } + resolveBuildPath(npmDirectory, buildPath) { + return path.isAbsolute(buildPath) + ? buildPath + : path.join(npmDirectory, buildPath); + } getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; - return `src-tauri/target/${basePath}/bundle/`; + return path.join(this.getCargoTargetDir(), basePath, 'bundle'); } getBuildAppPath(npmDirectory, fileName, fileType) { // For app bundles on macOS, the directory is 'macos', not 'app' const bundleDir = fileType.toLowerCase() === 'app' ? 'macos' : fileType.toLowerCase(); - return path.join(npmDirectory, this.getBasePath(), bundleDir, `${fileName}.${fileType}`); + return path.join(this.resolveBuildPath(npmDirectory, this.getBasePath()), bundleDir, `${fileName}.${fileType}`); } /** * Copy raw binary file to output directory @@ -1196,9 +1204,9 @@ class BaseBuilder { const binaryName = this.getBinaryName(appName); // Handle cross-platform builds if (this.options.multiArch || this.hasArchSpecificTarget()) { - return path.join(npmDirectory, this.getArchSpecificPath(), basePath, binaryName); + return path.join(this.resolveBuildPath(npmDirectory, this.getArchSpecificPath()), basePath, binaryName); } - return path.join(npmDirectory, 'src-tauri/target', basePath, binaryName); + return path.join(this.resolveBuildPath(npmDirectory, this.getCargoTargetDir()), basePath, binaryName); } /** * Get the output path for the raw binary file @@ -1229,7 +1237,7 @@ class BaseBuilder { * Get architecture-specific path for binary */ getArchSpecificPath() { - return 'src-tauri/target'; // Override in subclasses if needed + return this.getCargoTargetDir(); // Override in subclasses if needed } } BaseBuilder.ARCH_MAPPINGS = { @@ -1315,7 +1323,10 @@ class MacBuilder extends BaseBuilder { const basePath = this.options.debug ? 'debug' : 'release'; const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}/${basePath}/bundle`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } hasArchSpecificTarget() { return true; @@ -1323,7 +1334,10 @@ class MacBuilder extends BaseBuilder { getArchSpecificPath() { const actualArch = this.getActualArch(); const target = this.getTauriTarget(actualArch, 'darwin'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${actualArch} for macOS`); + } + return path.join(this.getCargoTargetDir(), target); } } @@ -1354,14 +1368,26 @@ class WinBuilder extends BaseBuilder { getBasePath() { const basePath = this.options.debug ? 'debug' : 'release'; const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } hasArchSpecificTarget() { return true; } getArchSpecificPath() { const target = this.getTauriTarget(this.buildArch, 'win32'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Windows`); + } + return path.join(this.getCargoTargetDir(), target); + } + getRawBinaryPath(appName) { + return `${appName}.exe`; + } + getBinaryName(appName) { + return `pake-${generateIdentifierSafeName(appName)}.exe`; } } @@ -1556,7 +1582,10 @@ post_remove() { const basePath = this.options.debug ? 'debug' : 'release'; if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}/${basePath}/bundle/`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Linux`); + } + return path.join(this.getCargoTargetDir(), target, basePath, 'bundle'); } return super.getBasePath(); } @@ -1572,7 +1601,10 @@ post_remove() { getArchSpecificPath() { if (this.buildArch === 'arm64') { const target = this.getTauriTarget(this.buildArch, 'linux'); - return `src-tauri/target/${target}`; + if (!target) { + throw new Error(`Unsupported architecture: ${this.buildArch} for Linux`); + } + return path.join(this.getCargoTargetDir(), target); } return super.getArchSpecificPath(); } diff --git a/tests/unit/base-builder.test.ts b/tests/unit/base-builder.test.ts index 6e97fe0abd..380e64e8a7 100644 --- a/tests/unit/base-builder.test.ts +++ b/tests/unit/base-builder.test.ts @@ -15,6 +15,7 @@ vi.mock('@/utils/dir', () => ({ })); import BaseBuilder from '@/builders/BaseBuilder'; +import WinBuilder from '@/builders/WinBuilder'; import { _resetPackageManagerCache, configureCargoRegistry, @@ -32,6 +33,7 @@ class TestBuilder extends BaseBuilder { } const originalCnMirrorEnv = process.env[CN_MIRROR_ENV]; +const originalCargoTargetDir = process.env.CARGO_TARGET_DIR; const tempDirs: string[] = []; const GENERATED_MIRROR_CONFIG = `[source.crates-io] @@ -95,6 +97,12 @@ describe('BaseBuilder guards', () => { process.env[CN_MIRROR_ENV] = originalCnMirrorEnv; } + if (originalCargoTargetDir === undefined) { + delete process.env.CARGO_TARGET_DIR; + } else { + process.env.CARGO_TARGET_DIR = originalCargoTargetDir; + } + await Promise.all(tempDirs.splice(0).map((dir) => fsExtra.remove(dir))); }); @@ -300,6 +308,46 @@ describe('BaseBuilder guards', () => { expect(command).toContain('--features cli-build'); }); + it('copies Windows build artifacts from CARGO_TARGET_DIR when it is set', () => { + const cargoTargetDir = path.join(process.cwd(), '.short-cargo-target'); + process.env.CARGO_TARGET_DIR = cargoTargetDir; + + const builder = new WinBuilder({ + debug: false, + name: 'ChatGPT', + targets: 'x64', + } as any); + + const appPath = (builder as any).getBuildAppPath( + process.cwd(), + 'ChatGPT_1.0.0_x64_en-US', + 'msi', + ); + const binaryPath = (builder as any).getRawBinarySourcePath( + process.cwd(), + 'ChatGPT', + ); + + expect(appPath).toBe( + path.join( + cargoTargetDir, + 'x86_64-pc-windows-msvc', + 'release', + 'bundle', + 'msi', + 'ChatGPT_1.0.0_x64_en-US.msi', + ), + ); + expect(binaryPath).toBe( + path.join( + cargoTargetDir, + 'x86_64-pc-windows-msvc', + 'release', + 'pake-chatgpt.exe', + ), + ); + }); + it('tracks generated Pake config files in the Cargo build script', async () => { const buildScript = await fsExtra.readFile( path.join(process.cwd(), 'src-tauri', 'build.rs'), From f21156e4a75016794d932194152e9e35138489b5 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 14:42:40 +0800 Subject: [PATCH 090/120] fix: ignore hide title bar outside macOS --- bin/helpers/merge.ts | 9 ++++++++- dist/cli.js | 6 +++++- docs/advanced-usage.md | 2 ++ docs/advanced-usage_CN.md | 2 ++ tests/unit/merge-window-options.test.ts | 21 +++++++++++++++++++++ 5 files changed, 38 insertions(+), 2 deletions(-) diff --git a/bin/helpers/merge.ts b/bin/helpers/merge.ts index 70c2c6b832..10f587504e 100644 --- a/bin/helpers/merge.ts +++ b/bin/helpers/merge.ts @@ -31,13 +31,15 @@ export function buildWindowConfigOverrides( platform: SupportedPlatform = asSupportedPlatform(process.platform), ): Partial { const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + const platformHideTitleBar = + platform === 'darwin' ? options.hideTitleBar : false; return { width: options.width, height: options.height, fullscreen: options.fullscreen, maximize: options.maximize, resizable: options.resizable ?? true, - hide_title_bar: options.hideTitleBar, + hide_title_bar: platformHideTitleBar, activation_shortcut: options.activationShortcut, always_on_top: options.alwaysOnTop, dark_mode: options.darkMode, @@ -439,6 +441,11 @@ export async function mergeConfig( } = options; const platform = asSupportedPlatform(process.platform); + if (options.hideTitleBar && platform !== 'darwin') { + logger.warn( + '✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.', + ); + } const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); diff --git a/dist/cli.js b/dist/cli.js index cac395ae99..3fda686017 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -431,13 +431,14 @@ function needsTemporaryDebForZst(targets) { */ function buildWindowConfigOverrides(options, platform = asSupportedPlatform(process.platform)) { const platformHideOnClose = options.hideOnClose ?? platform === 'darwin'; + const platformHideTitleBar = platform === 'darwin' ? options.hideTitleBar : false; return { width: options.width, height: options.height, fullscreen: options.fullscreen, maximize: options.maximize, resizable: options.resizable ?? true, - hide_title_bar: options.hideTitleBar, + hide_title_bar: platformHideTitleBar, activation_shortcut: options.activationShortcut, always_on_top: options.alwaysOnTop, dark_mode: options.darkMode, @@ -712,6 +713,9 @@ async function mergeConfig(url, options, tauriConf) { await copyTemplateConfigs(); const { appVersion, userAgent, showSystemTray, useLocalFile, identifier, name = 'pake-app', installerLanguage, wasm, camera, microphone, } = options; const platform = asSupportedPlatform(process.platform); + if (options.hideTitleBar && platform !== 'darwin') { + logger.warn('✼ --hide-title-bar is only supported on macOS and will be ignored on this platform.'); + } const tauriConfWindowOptions = buildWindowConfigOverrides(options, platform); Object.assign(tauriConf.pake.windows[0], { url, ...tauriConfWindowOptions }); tauriConf.productName = name; diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 9db2034b0c..94cddffabf 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -114,6 +114,8 @@ Configure window properties in `pake.json`: } ``` +`hideTitleBar` is only supported on macOS. It is ignored on Windows and Linux. + ## Static File Packaging Package local HTML/CSS/JS files: diff --git a/docs/advanced-usage_CN.md b/docs/advanced-usage_CN.md index 8c8deaf359..0f90843b0c 100644 --- a/docs/advanced-usage_CN.md +++ b/docs/advanced-usage_CN.md @@ -114,6 +114,8 @@ fn handle_scroll(scroll_y: f64, scroll_x: f64) { } ``` +`hideTitleBar` 仅支持 macOS,在 Windows 和 Linux 上会被忽略。 + ## 静态文件打包 打包本地 HTML/CSS/JS 文件: diff --git a/tests/unit/merge-window-options.test.ts b/tests/unit/merge-window-options.test.ts index 387d887ed7..6e453a5add 100644 --- a/tests/unit/merge-window-options.test.ts +++ b/tests/unit/merge-window-options.test.ts @@ -58,6 +58,27 @@ describe('buildWindowConfigOverrides', () => { ).toBe(false); }); + it('only forwards hideTitleBar on macOS', () => { + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'darwin', + ).hide_title_bar, + ).toBe(true); + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'linux', + ).hide_title_bar, + ).toBe(false); + expect( + buildWindowConfigOverrides( + { ...makeOptions(), hideTitleBar: true }, + 'win32', + ).hide_title_bar, + ).toBe(false); + }); + it('only enables start_to_tray when both flag and tray are on', () => { expect( buildWindowConfigOverrides( From 30e35f2693fdb9d3570cefc31d0f8c5289f80015 Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sun, 21 Jun 2026 15:02:41 +0800 Subject: [PATCH 091/120] feat: support enterprise SSO navigation Co-authored-by: Xan Torres --- bin/defaults.ts | 1 + bin/helpers/cli-program.ts | 18 ++++----- bin/options/index.ts | 7 +++- bin/types.ts | 3 ++ bin/utils/url.ts | 15 ++++++++ dist/cli.js | 32 +++++++++++----- src-tauri/src/inject/auth.js | 6 +++ src-tauri/src/inject/event.js | 29 ++++++++++++--- tests/unit/auth-sso-patterns.test.js | 50 +++++++++++++++++++++++++ tests/unit/cli-options.test.ts | 31 +++++++++++++++- tests/unit/safe-domains.test.ts | 55 ++++++++++++++++++++++++++++ 11 files changed, 222 insertions(+), 25 deletions(-) create mode 100644 tests/unit/auth-sso-patterns.test.js create mode 100644 tests/unit/safe-domains.test.ts diff --git a/bin/defaults.ts b/bin/defaults.ts index c927754deb..a77d3a932a 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -44,6 +44,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + safeDomain: '', enableFind: false, iterativeBuild: false, zoom: 100, diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index 8d84e1ff69..a806f13fe5 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -184,17 +184,19 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option( '--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers', - ) - .default(DEFAULT.forceInternalNavigation) - .hideHelp(), + ).default(DEFAULT.forceInternalNavigation), ) .addOption( new Option( '--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal', - ) - .default(DEFAULT.internalUrlRegex) - .hideHelp(), + ).default(DEFAULT.internalUrlRegex), + ) + .addOption( + new Option( + '--safe-domain ', + 'Comma-separated domains kept inside the app (e.g. SSO/workspace callbacks)', + ).default(DEFAULT.safeDomain), ) .addOption( new Option( @@ -253,9 +255,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with new Option( '--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)', - ) - .default(DEFAULT.newWindow) - .hideHelp(), + ).default(DEFAULT.newWindow), ) .addOption( new Option( diff --git a/bin/options/index.ts b/bin/options/index.ts index bcb6a0aa8d..73fb4955fe 100644 --- a/bin/options/index.ts +++ b/bin/options/index.ts @@ -3,7 +3,7 @@ import fsExtra from 'fs-extra'; import logger from '@/options/logger'; import { handleIcon } from './icon'; -import { getDomain } from '@/utils/url'; +import { getDomain, safeDomainsToRegex } from '@/utils/url'; import { promptText, capitalizeFirstLetter, @@ -86,6 +86,11 @@ export default async function handleOptions( identifier: resolveIdentifier(url, options.name, options.identifier), }; + // --safe-domain is sugar over --internal-url-regex; an explicit regex wins. + if (!options.internalUrlRegex && options.safeDomain) { + appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain); + } + const iconPath = await handleIcon(appOptions, url); appOptions.icon = iconPath || ''; diff --git a/bin/types.ts b/bin/types.ts index cb53715fa6..bd96647759 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -108,6 +108,9 @@ export interface PakeCliOptions { // Regex pattern to match URLs that should be considered internal internalUrlRegex: string; + // Comma-separated domains kept inside the app, compiled into internalUrlRegex, default empty + safeDomain: string; + // Enable in-page Find UI and Cmd/Ctrl+F/G shortcuts, default false enableFind: boolean; diff --git a/bin/utils/url.ts b/bin/utils/url.ts index 18292ba4bf..0bfca9dc77 100644 --- a/bin/utils/url.ts +++ b/bin/utils/url.ts @@ -40,3 +40,18 @@ export function normalizeUrl(urlToNormalize: string): string { ); } } + +// Compiles a comma-separated domain list into a regex source for +// internal_url_regex. Each domain is escaped and matched against the URL host +// and its subdomains so path or query text cannot accidentally opt a link in. +// Returns '' for empty input. +export function safeDomainsToRegex(domains: string): string { + const escaped = domains + .split(',') + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) + .map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return escaped.length + ? `^https?:\\/\\/(?:[^/?#@]+\\.)*(?:${escaped.join('|')})(?::\\d+)?(?:[/?#]|$)` + : ''; +} diff --git a/dist/cli.js b/dist/cli.js index 3fda686017..9602cc5aaa 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2446,6 +2446,20 @@ function normalizeUrl(urlToNormalize) { throw new Error(`Your url "${urlWithProtocol}" is invalid: ${err.message}`); } } +// Compiles a comma-separated domain list into a regex source for +// internal_url_regex. Each domain is escaped and matched against the URL host +// and its subdomains so path or query text cannot accidentally opt a link in. +// Returns '' for empty input. +function safeDomainsToRegex(domains) { + const escaped = domains + .split(',') + .map((domain) => domain.trim().toLowerCase()) + .filter(Boolean) + .map((domain) => domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + return escaped.length + ? `^https?:\\/\\/(?:[^/?#@]+\\.)*(?:${escaped.join('|')})(?::\\d+)?(?:[/?#]|$)` + : ''; +} /** * Error class used for user-facing CLI errors. @@ -2526,6 +2540,10 @@ async function handleOptions(options, url) { name: resolvedName, identifier: resolveIdentifier(url, options.name, options.identifier), }; + // --safe-domain is sugar over --internal-url-regex; an explicit regex wins. + if (!options.internalUrlRegex && options.safeDomain) { + appOptions.internalUrlRegex = safeDomainsToRegex(options.safeDomain); + } const iconPath = await handleIcon(appOptions, url); appOptions.icon = iconPath || ''; return appOptions; @@ -2574,6 +2592,7 @@ const DEFAULT_PAKE_OPTIONS = { startToTray: false, forceInternalNavigation: false, internalUrlRegex: '', + safeDomain: '', enableFind: false, iterativeBuild: false, zoom: 100, @@ -2715,12 +2734,9 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--start-to-tray', 'Start app minimized to tray') .default(DEFAULT_PAKE_OPTIONS.startToTray) .hideHelp()) - .addOption(new Option('--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers') - .default(DEFAULT_PAKE_OPTIONS.forceInternalNavigation) - .hideHelp()) - .addOption(new Option('--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal') - .default(DEFAULT_PAKE_OPTIONS.internalUrlRegex) - .hideHelp()) + .addOption(new Option('--force-internal-navigation', 'Keep every link inside the Pake window instead of opening external handlers').default(DEFAULT_PAKE_OPTIONS.forceInternalNavigation)) + .addOption(new Option('--internal-url-regex ', 'Regex pattern to match URLs that should be considered internal').default(DEFAULT_PAKE_OPTIONS.internalUrlRegex)) + .addOption(new Option('--safe-domain ', 'Comma-separated domains kept inside the app (e.g. SSO/workspace callbacks)').default(DEFAULT_PAKE_OPTIONS.safeDomain)) .addOption(new Option('--enable-find', 'Enable in-page Find UI with Cmd/Ctrl+F/G shortcuts') .default(DEFAULT_PAKE_OPTIONS.enableFind) .hideHelp()) @@ -2751,9 +2767,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--iterative-build', 'Turn on rapid build mode (app only, no dmg/deb/msi), good for debugging') .default(DEFAULT_PAKE_OPTIONS.iterativeBuild) .hideHelp()) - .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)') - .default(DEFAULT_PAKE_OPTIONS.newWindow) - .hideHelp()) + .addOption(new Option('--new-window', 'Allow sites to open new windows (for auth flows, tabs, branches)').default(DEFAULT_PAKE_OPTIONS.newWindow)) .addOption(new Option('--install', 'Auto-install app to /Applications (macOS) after build and remove local bundle') .default(DEFAULT_PAKE_OPTIONS.install) .hideHelp()) diff --git a/src-tauri/src/inject/auth.js b/src-tauri/src/inject/auth.js index c045baa0db..3d9140df5f 100644 --- a/src-tauri/src/inject/auth.js +++ b/src-tauri/src/inject/auth.js @@ -17,6 +17,12 @@ function matchesAuthUrl(url, baseUrl = window.location.href) { /facebook\.com\/.*\/dialog/, /twitter\.com\/oauth/, /appleid\.apple\.com/, + // Enterprise SSO providers and SAML/ADFS endpoints + /\.okta\.com/, + /\.onelogin\.com/, + /\/saml\//, + /\/sso\//, + /adfs\/ls/, /\/oauth\//, /\/auth\//, /\/authorize/, diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 163ebd3893..40ea29aecd 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -459,20 +459,23 @@ document.addEventListener("DOMContentLoaded", () => { const absoluteUrl = hrefUrl.href; let filename = anchorElement.download || getFilenameFromUrl(absoluteUrl); - // Keep OAuth/authentication flows inside the app when popup support is enabled. + // Keep OAuth/authentication flows inside the app. Without --new-window, + // navigate in place so the SSO redirect chain and callback stay in the + // webview instead of falling through to the system browser. if (window.isAuthLink(absoluteUrl)) { console.log("[Pake] Handling OAuth navigation in-app:", absoluteUrl); + e.preventDefault(); + e.stopImmediatePropagation(); if (window.pakeConfig?.new_window) { - e.preventDefault(); - e.stopImmediatePropagation(); - openAuthNavigation( originalWindowOpen, absoluteUrl, "_blank", "width=1200,height=800,scrollbars=yes,resizable=yes", ); + } else { + window.location.href = absoluteUrl; } return; @@ -488,7 +491,15 @@ document.addEventListener("DOMContentLoaded", () => { } if (isInternalUrl(absoluteUrl)) { - // For internal links (based on regex or domain), let the browser handle it naturally + // With --new-window the Rust on_new_window handler opens an in-app + // window; without it, deferring to the native handler sends the + // _blank target to the system browser and strands SSO callbacks. + // Navigate in place so internal links stay inside the webview. + if (!window.pakeConfig?.new_window) { + e.preventDefault(); + e.stopImmediatePropagation(); + window.location.href = absoluteUrl; + } return; } @@ -592,6 +603,14 @@ document.addEventListener("DOMContentLoaded", () => { return null; } + // With --new-window the native handler opens an in-app window; without it, + // originalWindowOpen would route the internal target to the system browser + // and strand SSO callbacks, so navigate in place instead. + if (!window.pakeConfig?.new_window) { + window.location.href = absoluteUrl; + return window; + } + return originalWindowOpen.call(window, absoluteUrl, name, specs); } catch (error) { return originalWindowOpen.call(window, url, name, specs); diff --git a/tests/unit/auth-sso-patterns.test.js b/tests/unit/auth-sso-patterns.test.js new file mode 100644 index 0000000000..95fbd40731 --- /dev/null +++ b/tests/unit/auth-sso-patterns.test.js @@ -0,0 +1,50 @@ +import fs from "fs"; +import path from "path"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it } from "vitest"; + +function loadAuthHelpers() { + const source = fs.readFileSync( + path.join(process.cwd(), "src-tauri/src/inject/auth.js"), + "utf-8", + ); + + const context = { + console, + URL, + window: { + location: { href: "https://example.com/app" }, + }, + }; + + runInNewContext(source, context); + return context.window; +} + +describe("auth SSO patterns", () => { + const { isAuthLink, isAuthPopup } = loadAuthHelpers(); + + it("matches enterprise SSO providers and endpoints", () => { + expect(isAuthLink("https://mycompany.okta.com/app/sign-on")).toBe(true); + expect(isAuthLink("https://acme.onelogin.com/login")).toBe(true); + expect(isAuthLink("https://idp.example.com/saml/acs")).toBe(true); + expect(isAuthLink("https://idp.example.com/sso/redirect")).toBe(true); + expect(isAuthLink("https://fs.example.com/adfs/ls/?wa=wsignin1.0")).toBe( + true, + ); + }); + + it("still matches the original OAuth providers", () => { + expect(isAuthLink("https://accounts.google.com/o/oauth2/auth")).toBe(true); + expect(isAuthLink("https://login.microsoftonline.com/common")).toBe(true); + }); + + it("does not flag ordinary application URLs", () => { + expect(isAuthLink("https://example.com/dashboard")).toBe(false); + expect(isAuthLink("https://example.com/reports/q3")).toBe(false); + }); + + it("treats known auth window names as popups", () => { + expect(isAuthPopup("https://example.com/dashboard", "oauth2")).toBe(true); + }); +}); diff --git a/tests/unit/cli-options.test.ts b/tests/unit/cli-options.test.ts index 51dcbe3d59..0b8a3376e1 100644 --- a/tests/unit/cli-options.test.ts +++ b/tests/unit/cli-options.test.ts @@ -29,13 +29,42 @@ describe('CLI options', () => { expect(option?.defaultValue).toBe(false); }); - it('registers hidden --internal-url-regex option', () => { + it('exposes --internal-url-regex option', () => { const option = program.options.find( (item) => item.long === '--internal-url-regex', ); expect(option).toBeDefined(); expect(option?.defaultValue).toBe(''); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --safe-domain option', () => { + const option = program.options.find( + (item) => item.long === '--safe-domain', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(''); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --force-internal-navigation option', () => { + const option = program.options.find( + (item) => item.long === '--force-internal-navigation', + ); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBeFalsy(); + }); + + it('exposes --new-window option', () => { + const option = program.options.find((item) => item.long === '--new-window'); + + expect(option).toBeDefined(); + expect(option?.defaultValue).toBe(false); + expect(option?.hidden).toBeFalsy(); }); it('registers hidden --identifier option', () => { diff --git a/tests/unit/safe-domains.test.ts b/tests/unit/safe-domains.test.ts new file mode 100644 index 0000000000..580cc5514e --- /dev/null +++ b/tests/unit/safe-domains.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { safeDomainsToRegex } from '../../bin/utils/url.js'; + +describe('safeDomainsToRegex', () => { + it('builds a host-bound regex for a single domain', () => { + expect(safeDomainsToRegex('slack.com')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('joins multiple domains with alternation', () => { + expect(safeDomainsToRegex('slack.com,acme.com')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com|acme\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('trims whitespace and drops empty entries', () => { + expect(safeDomainsToRegex(' slack.com , , acme.com ')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:slack\\.com|acme\\.com)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('returns an empty string for blank input', () => { + expect(safeDomainsToRegex('')).toBe(''); + expect(safeDomainsToRegex(' , , ')).toBe(''); + }); + + it('escapes regex metacharacters in domains', () => { + expect(safeDomainsToRegex('a.b+c')).toBe( + '^https?:\\/\\/(?:[^/?#@]+\\.)*(?:a\\.b\\+c)(?::\\d+)?(?:[/?#]|$)', + ); + }); + + it('compiles to a regex that matches allowed URL hosts', () => { + const pattern = new RegExp(safeDomainsToRegex('slack.com,okta.com')); + + expect(pattern.test('https://slack.com')).toBe(true); + expect(pattern.test('https://mycompany.okta.com/sso')).toBe(true); + expect(pattern.test('https://app.slack.com/client')).toBe(true); + expect(pattern.test('https://slack.com:443/client')).toBe(true); + expect(pattern.test('https://example.com/dashboard')).toBe(false); + }); + + it('does not match domains embedded in unrelated hosts or URL text', () => { + const pattern = new RegExp(safeDomainsToRegex('slack.com,okta.com')); + + expect(pattern.test('https://evilslack.com')).toBe(false); + expect(pattern.test('https://slack.com.evil.example')).toBe(false); + expect(pattern.test('https://okta.com.evil.example/sso')).toBe(false); + expect( + pattern.test('https://example.com/callback?next=https://okta.com'), + ).toBe(false); + expect(pattern.test('https://okta.com@evil.example/sso')).toBe(false); + }); +}); From 6efafd47122c9b316ec110248ac9d34699b03ddf Mon Sep 17 00:00:00 2001 From: Xan Torres Date: Sun, 21 Jun 2026 15:29:25 +0800 Subject: [PATCH 092/120] fix: scope SSO auth detection Scope enterprise SSO detection to trusted hosts and endpoint-shaped paths, document --safe-domain, and cover in-app auth navigation. --- .agents/skills/use-pake/SKILL.md | 1 + docs/cli-usage.md | 13 +++ docs/cli-usage_CN.md | 13 +++ src-tauri/src/inject/auth.js | 32 ++++--- tests/unit/auth-sso-patterns.test.js | 21 +++++ tests/unit/event-link-guard.test.js | 125 +++++++++++++++++++++++++-- 6 files changed, 184 insertions(+), 21 deletions(-) diff --git a/.agents/skills/use-pake/SKILL.md b/.agents/skills/use-pake/SKILL.md index 8aa1f898b2..9a77e52124 100644 --- a/.agents/skills/use-pake/SKILL.md +++ b/.agents/skills/use-pake/SKILL.md @@ -111,6 +111,7 @@ After build, confirm: | `--multi-instance` | false | Allow multiple app instances | | `--multi-window` | false | Multiple windows in one instance | | `--new-window` | false | Allow popup windows (needed for OAuth flows) | +| `--safe-domain ` | none | Keep trusted SSO/workspace domains in-app | | `--incognito` | false | Private browsing mode | | `--dark-mode` | false | Force macOS dark mode | | `--zoom ` | 100 | Initial zoom level (50-200) | diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 5c7b794bae..263135f4ad 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -262,6 +262,19 @@ Set a regex pattern to determine which URLs should be considered internal (opene --internal-url-regex "^https://(app|api)\\.example\\.com" ``` +#### [safe-domain] + +A simpler way to keep trusted domains and their subdomains inside the app. This is useful for workspace callbacks and enterprise SSO flows, for example Slack plus Okta. Pake compiles this list into `internal_url_regex`; if `--internal-url-regex` is also set, the explicit regex wins. + +`--safe-domain` matches URL hosts only, not arbitrary path or query text. + +```shell +--safe-domain + +# Keep Slack and Okta auth redirects inside the app +--safe-domain slack.com,okta.com +``` + #### [multi-arch] Package the application to support both Intel and M1 chips, exclusively for macOS. Default is `false`. diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index c23db80bc5..dccacf9c12 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -260,6 +260,19 @@ pake https://github.com --name GitHub --internal-url-regex "^https://(app|api)\\.example\\.com" ``` +#### [safe-domain] + +更简单地把可信域名及其子域名保留在应用内打开。适合工作区回调和企业 SSO 登录流程,例如 Slack 加 Okta。Pake 会把这个列表编译成 `internal_url_regex`;如果同时设置了 `--internal-url-regex`,则以显式正则为准。 + +`--safe-domain` 只匹配 URL 的 host,不会因为路径或查询参数里出现域名就误判为内部链接。 + +```shell +--safe-domain + +# 将 Slack 和 Okta 的认证跳转保留在应用内 +--safe-domain slack.com,okta.com +``` + #### [multi-arch] 设置打包结果同时支持 Intel 和 M1 芯片,仅适用于 macOS,默认为 `false`。 diff --git a/src-tauri/src/inject/auth.js b/src-tauri/src/inject/auth.js index 3d9140df5f..4df84d7ff7 100644 --- a/src-tauri/src/inject/auth.js +++ b/src-tauri/src/inject/auth.js @@ -17,12 +17,6 @@ function matchesAuthUrl(url, baseUrl = window.location.href) { /facebook\.com\/.*\/dialog/, /twitter\.com\/oauth/, /appleid\.apple\.com/, - // Enterprise SSO providers and SAML/ADFS endpoints - /\.okta\.com/, - /\.onelogin\.com/, - /\/saml\//, - /\/sso\//, - /adfs\/ls/, /\/oauth\//, /\/auth\//, /\/authorize/, @@ -33,12 +27,26 @@ function matchesAuthUrl(url, baseUrl = window.location.href) { /\/o\/oauth2/, ]; - const isMatch = oauthPatterns.some( - (pattern) => - pattern.test(hostname) || - pattern.test(pathname) || - pattern.test(fullUrl), - ); + // Enterprise SSO. Match identity providers on the host, and SAML/SSO/ADFS on + // the pathname with endpoint-shaped patterns only, so ordinary pages such as + // /settings/sso/providers (or a query string carrying an SSO URL) are not + // misread as authentication. + const enterpriseHostPatterns = [/(^|\.)okta\.com$/, /(^|\.)onelogin\.com$/]; + const enterprisePathPatterns = [ + /\/saml2?\/(sso|acs|login|metadata|consume|redirect|callback|continue)/, + /\/sso\/(saml|oidc|oauth|login|authorize|redirect|callback|acs|start|continue|metadata)/, + /\/adfs\/ls\b/, + ]; + + const isMatch = + oauthPatterns.some( + (pattern) => + pattern.test(hostname) || + pattern.test(pathname) || + pattern.test(fullUrl), + ) || + enterpriseHostPatterns.some((pattern) => pattern.test(hostname)) || + enterprisePathPatterns.some((pattern) => pattern.test(pathname)); if (isMatch) { console.log("[Pake] OAuth URL detected:", url); diff --git a/tests/unit/auth-sso-patterns.test.js b/tests/unit/auth-sso-patterns.test.js index 95fbd40731..35bf65fb17 100644 --- a/tests/unit/auth-sso-patterns.test.js +++ b/tests/unit/auth-sso-patterns.test.js @@ -44,6 +44,27 @@ describe("auth SSO patterns", () => { expect(isAuthLink("https://example.com/reports/q3")).toBe(false); }); + it("does not flag ordinary pages that merely contain sso or saml in the path", () => { + expect(isAuthLink("https://app.example.com/settings/sso/providers")).toBe( + false, + ); + expect(isAuthLink("https://app.example.com/docs/saml/overview")).toBe( + false, + ); + }); + + it("does not flag a query string that carries an SSO URL", () => { + expect( + isAuthLink( + "https://app.example.com/?next=https://idp.example.com/sso/saml", + ), + ).toBe(false); + }); + + it("does not flag look-alike provider suffix hosts", () => { + expect(isAuthLink("https://okta.com.evil.test/app")).toBe(false); + }); + it("treats known auth window names as popups", () => { expect(isAuthPopup("https://example.com/dashboard", "oauth2")).toBe(true); }); diff --git a/tests/unit/event-link-guard.test.js b/tests/unit/event-link-guard.test.js index 6f2c2d0a26..5eb3c6e11c 100644 --- a/tests/unit/event-link-guard.test.js +++ b/tests/unit/event-link-guard.test.js @@ -1,7 +1,7 @@ import fs from "fs"; import path from "path"; import { runInNewContext } from "node:vm"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; function loadEventHelpers({ withTauri = false, @@ -17,6 +17,36 @@ function loadEventHelpers({ invokeCalls.push([command, payload]); return Promise.resolve(); }; + const eventListeners = {}; + const elementsById = new Map(); + const registerListener = (type, handler, options) => { + eventListeners[type] = eventListeners[type] || []; + eventListeners[type].push({ handler, options }); + }; + const createElement = (tagName = "div") => ({ + tagName: tagName.toUpperCase(), + style: {}, + children: [], + addEventListener: () => {}, + appendChild(child) { + this.children.push(child); + if (child.id) elementsById.set(child.id, child); + }, + removeChild(child) { + this.children = this.children.filter((item) => item !== child); + if (child.id) elementsById.delete(child.id); + }, + click: () => {}, + set id(value) { + this._id = value; + elementsById.set(value, this); + }, + get id() { + return this._id; + }, + }); + const body = createElement("body"); + body.scrollHeight = 0; const context = { console, @@ -45,26 +75,67 @@ function loadEventHelpers({ getItem: () => null, setItem: () => {}, }, - addEventListener: () => {}, + addEventListener: registerListener, dispatchEvent: () => {}, + open: () => ({}), + isAuthLink: () => false, + isAuthPopup: () => false, + pakeConfig: {}, }, document: { - addEventListener: () => {}, + addEventListener: registerListener, + createElement, + getElementById: (id) => elementsById.get(id) || null, getElementsByTagName: () => [{ style: {} }], - body: { - style: {}, - scrollHeight: 0, - }, + body, execCommand: () => {}, }, }; context.window.navigator = context.navigator; if (withTauri) { - context.window.__TAURI__ = { core: { invoke } }; + context.window.__TAURI__ = { + core: { invoke }, + window: { + getCurrentWindow: () => ({ + startDragging: () => {}, + isFullscreen: () => Promise.resolve(false), + setFullscreen: () => {}, + }), + }, + }; } runInNewContext(source, context); - return { ...context, invokeCalls }; + return { ...context, eventListeners, invokeCalls }; +} + +function runDomReady(context) { + context.eventListeners.DOMContentLoaded[0].handler(); +} + +function getClickGuard(context) { + return context.eventListeners.click.find( + ({ handler }) => handler.name === "detectAnchorElementClick", + ).handler; +} + +function makeAnchor(href, target = "_blank") { + return { + href, + target, + download: "", + getAttribute: (name) => (name === "href" ? href : ""), + }; +} + +function makeClickEvent(anchor) { + return { + target: { + closest: () => anchor, + }, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + }; } describe("event link guard", () => { @@ -139,6 +210,42 @@ describe("event link guard", () => { expect(result).toBe(popup); }); + it("navigates target blank auth links in-place when new-window is disabled", () => { + const context = loadEventHelpers({ withTauri: true }); + context.window.pakeConfig = { new_window: false }; + context.window.isAuthLink = (url) => url.includes("okta.com"); + runDomReady(context); + + const event = makeClickEvent( + makeAnchor("https://mycompany.okta.com/sso", "_blank"), + ); + getClickGuard(context)(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + expect(context.window.location.href).toBe("https://mycompany.okta.com/sso"); + }); + + it("navigates target blank internal links in-place when new-window is disabled", () => { + const context = loadEventHelpers({ withTauri: true }); + context.window.pakeConfig = { + new_window: false, + internal_url_regex: "^https://app\\.example\\.com", + }; + runDomReady(context); + + const event = makeClickEvent( + makeAnchor("https://app.example.com/callback", "_blank"), + ); + getClickGuard(context)(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopImmediatePropagation).toHaveBeenCalled(); + expect(context.window.location.href).toBe( + "https://app.example.com/callback", + ); + }); + it("bridges Web Badging API calls to explicit badge commands", async () => { const { navigator, invokeCalls } = loadEventHelpers({ withTauri: true }); From ffa98cc4e4fc5d4ef27d658b4125aca8096cc847 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 07:29:39 +0000 Subject: [PATCH 093/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 74 +++++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 26 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 6f28aeeb9a..8b9ce15f4a 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -354,6 +354,17 @@ + + + + + + + + xantorres + + + @@ -364,7 +375,7 @@ a5677746shdh - + @@ -375,7 +386,7 @@ beautifulrem - + @@ -386,7 +397,7 @@ bocanhcam - + @@ -397,7 +408,7 @@ dbraendle - + @@ -408,7 +419,7 @@ geekvest - + @@ -419,7 +430,7 @@ lakca - + @@ -430,7 +441,7 @@ liudonghua123 - + @@ -441,7 +452,7 @@ liusishan - + @@ -452,7 +463,7 @@ piaoyidage - + @@ -463,7 +474,7 @@ enihsyou - + @@ -474,7 +485,7 @@ hetz - + @@ -485,7 +496,7 @@ princemaple - + @@ -496,7 +507,7 @@ pgoslatara - + @@ -507,7 +518,7 @@ Milo123459 - + @@ -518,7 +529,7 @@ Jason6987 - + @@ -529,7 +540,7 @@ JohannLai - + @@ -540,7 +551,7 @@ droid-Q - + @@ -551,7 +562,7 @@ ImgBotApp - + @@ -562,7 +573,7 @@ Fechin - + @@ -573,7 +584,7 @@ fvn-elmy - + @@ -584,7 +595,7 @@ turkyden - + @@ -595,7 +606,7 @@ kuishou68 - + @@ -606,7 +617,18 @@ nekomeowww - + + + + + + + + + Bortlesboat + + + @@ -617,7 +639,7 @@ kidylee - + @@ -628,7 +650,7 @@ ACGNnsj - + From aec07637ed3a178b2d2ebc928f29852fefd4e381 Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Sun, 21 Jun 2026 01:38:39 -0600 Subject: [PATCH 094/120] feat: support cross-platform dark mode on Windows and Linux --- src-tauri/src/app/invoke.rs | 23 +++++++---------------- src-tauri/src/app/window.rs | 11 +++++++++-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index 4a6d5c319e..91326726d3 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -7,7 +7,6 @@ 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; static BADGE_COUNT: AtomicI64 = AtomicI64::new(0); @@ -183,21 +182,13 @@ pub fn set_dock_badge_label(app: AppHandle, label: Option) -> Result<(), #[command] pub async fn update_theme_mode(app: AppHandle, mode: String) { - #[cfg(target_os = "macos")] - { - if let Some(window) = app.get_webview_window("pake") { - let theme = if mode == "dark" { - Theme::Dark - } else { - Theme::Light - }; - let _ = window.set_theme(Some(theme)); - } - } - #[cfg(not(target_os = "macos"))] - { - let _ = app; - let _ = mode; + 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)); } } diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 11f94af375..4f8670d437 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -12,8 +12,10 @@ use tauri::{ AppHandle, Config, Manager, Url, WebviewUrl, WebviewWindow, WebviewWindowBuilder, }; +use tauri::Theme; + #[cfg(target_os = "macos")] -use tauri::{Theme, TitleBarStyle}; +use tauri::TitleBarStyle; #[cfg(target_os = "windows")] fn build_proxy_browser_arg(url: &Url) -> Option { @@ -366,7 +368,12 @@ fn build_window( // Windows and Linux: set data_directory before proxy_url #[cfg(not(target_os = "macos"))] { - window_builder = window_builder.data_directory(_data_dir).theme(None); + let theme = if window_config.dark_mode { + Some(Theme::Dark) + } else { + None // Follow system theme + }; + window_builder = window_builder.data_directory(_data_dir).theme(theme); if !config.proxy_url.is_empty() { if let Ok(proxy_url) = Url::from_str(&config.proxy_url) { From d6c6bb5920ab2aaaef588ca9372c9d88b29c9984 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 15:54:30 +0800 Subject: [PATCH 095/120] chore: bump version to V3.12.0 --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index 9602cc5aaa..83c4c551aa 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.11.10"; +var version = "3.12.0"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index df0e8f07d2..8b0ab385bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.11.10", + "version": "3.12.0", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a85a3d2939..51ec494193 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.11.10" +version = "3.12.0" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f9efa4872..32c23034ef 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.11.10" +version = "3.12.0" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f922d57da5..51f94a5422 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.11.10", + "version": "3.12.0", "app": { "withGlobalTauri": true, "trayIcon": { From 44936ac82807053dede235ebdcdb9ab8522dec2e Mon Sep 17 00:00:00 2001 From: Marvin Galdamez Date: Sun, 21 Jun 2026 02:06:17 -0600 Subject: [PATCH 096/120] docs: update dark-mode option description for cross-platform support --- bin/helpers/cli-program.ts | 2 +- bin/types.ts | 2 +- dist/cli.js | 2 +- docs/cli-usage.md | 2 +- docs/cli-usage_CN.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index a806f13fe5..cbc6ef4066 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -103,7 +103,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .hideHelp(), ) .addOption( - new Option('--dark-mode', 'Force Mac app to use dark mode') + new Option('--dark-mode', 'Force app to use dark mode (supports macOS, Windows, and Linux)') .default(DEFAULT.darkMode) .hideHelp(), ) diff --git a/bin/types.ts b/bin/types.ts index bd96647759..b6318be45b 100644 --- a/bin/types.ts +++ b/bin/types.ts @@ -38,7 +38,7 @@ export interface PakeCliOptions { // App version, the same as package.json version, default 1.0.0 appVersion: string; - // Force Mac to use dark mode, default false + // Force app to use dark mode (supports macOS, Windows, and Linux), default false darkMode: boolean; // Disable web shortcuts, default false diff --git a/dist/cli.js b/dist/cli.js index 9602cc5aaa..0c48fab695 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -2685,7 +2685,7 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .addOption(new Option('--maximize', 'Start window maximized') .default(DEFAULT_PAKE_OPTIONS.maximize) .hideHelp()) - .addOption(new Option('--dark-mode', 'Force Mac app to use dark mode') + .addOption(new Option('--dark-mode', 'Force app to use dark mode (supports macOS, Windows, and Linux)') .default(DEFAULT_PAKE_OPTIONS.darkMode) .hideHelp()) .addOption(new Option('--disabled-web-shortcuts', 'Disabled webPage shortcuts') diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 263135f4ad..78336e3b4c 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -218,7 +218,7 @@ Set the version number of the packaged application to be consistent with the nam #### [dark-mode] -Force Mac to package applications using dark mode, default is `false`. +Force packaging applications using dark mode (supports macOS, Windows, and Linux), default is `false`. ```shell --dark-mode diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index dccacf9c12..58fde523aa 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -216,7 +216,7 @@ pake https://github.com --name GitHub #### [dark-mode] -强制 Mac 打包应用使用黑暗模式,默认为 `false`。 +强制打包应用使用黑暗模式(支持 macOS、Windows 和 Linux),默认为 `false`。 ```shell --dark-mode From 7fde73fdca2ec62dd9660ed2da170d9e2d069c88 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:46:47 +0000 Subject: [PATCH 097/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 90 ++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 8b9ce15f4a..7ded6774d5 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -222,6 +222,17 @@ + + + + + + + + xantorres + + + @@ -232,7 +243,7 @@ Tianj0o - + @@ -243,7 +254,7 @@ QingZ11 - + @@ -254,7 +265,7 @@ vaddisrinivas - + @@ -265,7 +276,7 @@ mattbajorek - + @@ -276,7 +287,7 @@ kittizz - + @@ -287,7 +298,7 @@ eltociear - + @@ -298,7 +309,7 @@ GoodbyeNJN - + @@ -309,7 +320,18 @@ AllDaGearNoIdea - + + + + + + + + + princemaple + + + @@ -320,7 +342,7 @@ RoyRao2333 - + @@ -331,7 +353,7 @@ sebastianbreguel - + @@ -342,7 +364,7 @@ youxi798 - + @@ -353,18 +375,7 @@ fulldecent - - - - - - - - - xantorres - - - + @@ -375,7 +386,7 @@ a5677746shdh - + @@ -386,7 +397,7 @@ beautifulrem - + @@ -397,7 +408,7 @@ bocanhcam - + @@ -408,7 +419,7 @@ dbraendle - + @@ -419,7 +430,7 @@ geekvest - + @@ -430,7 +441,7 @@ lakca - + @@ -441,7 +452,7 @@ liudonghua123 - + @@ -452,7 +463,7 @@ liusishan - + @@ -463,7 +474,7 @@ piaoyidage - + @@ -474,7 +485,7 @@ enihsyou - + @@ -485,17 +496,6 @@ hetz - - - - - - - - - princemaple - - From a49b142142ea6d5c840767f6f9ddf8b4939c399a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 16:51:54 +0800 Subject: [PATCH 098/120] docs: pin github release notes format in runbook --- .claude/skills/release/SKILL.md | 45 +++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 5 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 2dc7b6dee1..02f9ad3b91 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -51,11 +51,12 @@ Tag format: uppercase `V` prefix (e.g. `V3.11.0`), not `v3.11.0`. 1. [ ] Confirm CI triggered: `gh run list --workflow=release.yml` 2. [ ] Watch CI status: `gh run watch` 3. [ ] Verify GitHub Release was created: `gh release view VX.X.X --json tagName,url,assets` -4. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` -5. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` -6. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` -7. [ ] Verify latest now resolves to the release: `npm view pake-cli version` -8. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` +4. [ ] Fill the GitHub Release title and body from the template in **GitHub Release Notes** below. CI's `create-release` step only makes a bare placeholder (title = `VX.X.X`, empty body); do not leave it bare. +5. [ ] Confirm npm workflow exists and is active: `gh workflow list --all | grep "Publish npm Package"` +6. [ ] Confirm npm Trusted Publishing triggered: `gh run list --workflow=npm-publish.yml` +7. [ ] Verify npm published the exact package: `npm view pake-cli@X.Y.Z version gitHead dist.tarball --json` +8. [ ] Verify latest now resolves to the release: `npm view pake-cli version` +9. [ ] Record Quality & Testing status separately: `gh run list --workflow=quality-and-test.yml --limit 3` npm publishes through Trusted Publishing from `.github/workflows/npm-publish.yml`. Configure npm package settings with GitHub Actions, `tw93/Pake`, workflow file `npm-publish.yml`, and no environment. Local `npm publish` is only a fallback if CI or registry state blocks the trusted path. @@ -68,6 +69,40 @@ Keep release surfaces separate in the final status: Do not collapse these into "released" without naming which surface was verified. If GitHub Release assets are visible while `gh run list` still reports the release workflow as queued or in progress, trust `gh release view` for asset state and report the workflow state separately. +## GitHub Release Notes + +CI only creates a bare placeholder release. Every published release must be edited to match the house format, or it looks broken next to the others. Two failure modes to avoid: a bare version title with no codename, and a body missing the logo header / star line / repo footer (see `V3.11.10` and `V3.12.0`, both fixed after the fact). + +### Title format + +`V ` — version, then a single English codename word, optionally with one emoji. Examples: `V3.11.8 Polish`, `V3.11.10 Bedrock`, `V3.12.0 Gateway`, `V3.11.0 Evolve 👻`. The codename is the maintainer's call; pick one that fits the release theme. Even patch releases get a codename. + +### Body template + +Fill in the version, the two changelog lists (English + 中文, same items in the same order, numbered), the thanks line (credit the reporters/PR authors behind the release), and keep the logo header and repo footer verbatim: + +```markdown +
+Pake Logo +

Pake VX.Y.Z

+

Turn any webpage into a desktop app with one command.

+
+ +### Changelog + +1. ... + +### 更新日志 + +1. ... + +Special thanks to @user for the reports and PRs behind this release. If Pake helps you, please consider giving it a star and recommending it to your friends. + +> https://github.com/tw93/Pake +``` + +Apply with a notes file to avoid shell escaping: `gh release edit VX.Y.Z --title "VX.Y.Z Codename" --notes-file notes.md`. Source changelog items from the real commit range (`git log VPREV..VX.Y.Z`), keep them user-facing, and drop pure CI/refactor/docs noise. + ## Trusted Publishing Notes - The first real Trusted Publishing test must use a new version and a new `V*` tag; do not retry an already-published version. From 6698ee3ba105f4d0a037a4064d0f4424b7dbfb7a Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 16:51:54 +0800 Subject: [PATCH 099/120] docs: note linux dark mode relies on webkitgtk --- docs/cli-usage.md | 2 ++ docs/cli-usage_CN.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 78336e3b4c..477d0b4628 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -224,6 +224,8 @@ Force packaging applications using dark mode (supports macOS, Windows, and Linux --dark-mode ``` +On Linux this goes through WebKitGTK, so whether a page renders dark also depends on the WebKitGTK build honoring the window theme and the site implementing `prefers-color-scheme: dark`. + #### [disabled-web-shortcuts] Sets whether to disable web shortcuts in the original Pake container, defaults to `false`. diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 58fde523aa..4ced7e74d8 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -222,6 +222,8 @@ pake https://github.com --name GitHub --dark-mode ``` +在 Linux 上黑暗模式经由 WebKitGTK 实现,页面是否真正渲染为暗色还取决于 WebKitGTK 是否尊重窗口主题以及站点是否实现了 `prefers-color-scheme: dark`。 + #### [disabled-web-shortcuts] 设置是否禁用原有 Pake 容器里面的网页操作快捷键,默认为 `false`。 From e848696242032429011d54343adf1abd8fd916f0 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 16:52:13 +0800 Subject: [PATCH 100/120] refactor: hoist shared dark mode theme out of platform blocks --- src-tauri/src/app/window.rs | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/app/window.rs b/src-tauri/src/app/window.rs index 4f8670d437..173f1b8c60 100644 --- a/src-tauri/src/app/window.rs +++ b/src-tauri/src/app/window.rs @@ -346,6 +346,14 @@ fn build_window( let mut parsed_proxy_url: Option = None; + // Default to following the system theme (None), only force dark when explicitly set. + // Computed once; the matching platform block below is the sole consumer. + let theme = if window_config.dark_mode { + Some(Theme::Dark) + } else { + None // Follow system theme + }; + // Platform-specific configuration must be set before proxy on Windows/Linux #[cfg(target_os = "macos")] { @@ -355,24 +363,12 @@ fn build_window( TitleBarStyle::Visible }; window_builder = window_builder.title_bar_style(title_bar_style); - - // Default to following system theme (None), only force dark when explicitly set - let theme = if window_config.dark_mode { - Some(Theme::Dark) - } else { - None // Follow system theme - }; window_builder = window_builder.theme(theme); } // Windows and Linux: set data_directory before proxy_url #[cfg(not(target_os = "macos"))] { - let theme = if window_config.dark_mode { - Some(Theme::Dark) - } else { - None // Follow system theme - }; window_builder = window_builder.data_directory(_data_dir).theme(theme); if !config.proxy_url.is_empty() { From 85e4a0deefa4eab964eadd27e1992dd4acaaa0a6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 21 Jun 2026 08:53:03 +0000 Subject: [PATCH 101/120] Auto-fix formatting issues --- bin/helpers/cli-program.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/helpers/cli-program.ts b/bin/helpers/cli-program.ts index cbc6ef4066..5e00f1f702 100644 --- a/bin/helpers/cli-program.ts +++ b/bin/helpers/cli-program.ts @@ -103,7 +103,10 @@ ${green('|_| \\__,_|_|\\_\\___| can turn any webpage into a desktop app with .hideHelp(), ) .addOption( - new Option('--dark-mode', 'Force app to use dark mode (supports macOS, Windows, and Linux)') + new Option( + '--dark-mode', + 'Force app to use dark mode (supports macOS, Windows, and Linux)', + ) .default(DEFAULT.darkMode) .hideHelp(), ) From fcd70b709445d9ecd4e3983f058026464f402076 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sun, 21 Jun 2026 18:07:27 +0800 Subject: [PATCH 102/120] chore: remove dead code and fix stale cli dev doc --- bin/defaults.ts | 8 -------- bin/utils/name.ts | 11 ----------- docs/advanced-usage.md | 12 +----------- docs/advanced-usage_CN.md | 12 +----------- src-tauri/src/app/invoke.rs | 20 -------------------- src-tauri/src/lib.rs | 5 ++--- 6 files changed, 4 insertions(+), 64 deletions(-) diff --git a/bin/defaults.ts b/bin/defaults.ts index a77d3a932a..c079e5f18d 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -56,11 +56,3 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { camera: false, microphone: false, }; - -// Just for cli development -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: 'https://weekly.tw93.fun/en', - name: 'Weekly', - hideTitleBar: true, -}; diff --git a/bin/utils/name.ts b/bin/utils/name.ts index 2105d1aba2..abf71e5f9a 100644 --- a/bin/utils/name.ts +++ b/bin/utils/name.ts @@ -42,14 +42,3 @@ export function generateIdentifierSafeName(name: string): string { return cleaned; } - -export function generateWindowsFilename(name: string): string { - return name - .replace(/[<>:"/\\|?*]/g, '_') - .replace(/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i, '$&_') - .slice(0, 255); -} - -export function generateMacOSFilename(name: string): string { - return name.replace(/[:]/g, '_').slice(0, 255); -} diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index 94cddffabf..724c99a7a1 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -267,17 +267,7 @@ pnpm run dev #### CLI Development -For CLI development with hot reloading, modify the `DEFAULT_DEV_PAKE_OPTIONS` configuration in `bin/defaults.ts`: - -```typescript -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: "https://weekly.tw93.fun/en", - name: "Weekly", -}; -``` - -Then run: +For CLI development with hot reloading, run: ```bash pnpm run cli:dev diff --git a/docs/advanced-usage_CN.md b/docs/advanced-usage_CN.md index 0f90843b0c..e523e4480c 100644 --- a/docs/advanced-usage_CN.md +++ b/docs/advanced-usage_CN.md @@ -267,17 +267,7 @@ pnpm run dev #### CLI 开发调试 -对于需要热重载的 CLI 开发,可修改 `bin/defaults.ts` 中的 `DEFAULT_DEV_PAKE_OPTIONS` 配置: - -```typescript -export const DEFAULT_DEV_PAKE_OPTIONS: PakeCliOptions & { url: string } = { - ...DEFAULT_PAKE_OPTIONS, - url: "https://weekly.tw93.fun/en", - name: "Weekly", -}; -``` - -然后运行: +对于需要热重载的 CLI 开发,运行: ```bash pnpm run cli:dev diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index 91326726d3..e928f30002 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -191,23 +191,3 @@ pub async fn update_theme_mode(app: AppHandle, mode: String) { let _ = window.set_theme(Some(theme)); } } - -#[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()) - } -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fe7d2a57f..0b60064f79 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,8 +19,8 @@ const WEBKIT_DISABLE_COMPOSITING_MODE: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; use app::{ invoke::{ - clear_cache_and_restart, clear_dock_badge, download_file, increment_dock_badge, - send_notification, set_dock_badge, set_dock_badge_label, update_theme_mode, + clear_dock_badge, download_file, increment_dock_badge, send_notification, + set_dock_badge, set_dock_badge_label, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, @@ -159,7 +159,6 @@ pub fn run_app() { set_dock_badge_label, clear_dock_badge, update_theme_mode, - clear_cache_and_restart, ]) .setup(move |app| { app.manage(MultiWindowState::new( From 54eeb380bc611f724942dadea3511ff6bb488c00 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:09:37 +0000 Subject: [PATCH 103/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 135 +++++++++++++++++++++++++---------------------- 1 file changed, 73 insertions(+), 62 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 7ded6774d5..fb2b8012ce 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -113,49 +113,60 @@ - + - - - AielloChan + + + YangguangZhou - + - - - YangguangZhou + + + AielloChan - + - - - Ghraven + + + m1911star - + - - - m1911star + + + mgaldamez + + + + + + + + Ghraven + + + @@ -166,7 +177,7 @@ gxiang314 - + @@ -177,7 +188,7 @@ Pake-Actions - + @@ -188,7 +199,7 @@ exposir - + @@ -199,7 +210,7 @@ lkieryan - + @@ -210,7 +221,7 @@ g1eny0ung - + @@ -221,7 +232,7 @@ xinyii - + @@ -232,7 +243,7 @@ xantorres - + @@ -243,7 +254,7 @@ Tianj0o - + @@ -254,7 +265,7 @@ QingZ11 - + @@ -265,7 +276,7 @@ vaddisrinivas - + @@ -276,7 +287,7 @@ mattbajorek - + @@ -287,7 +298,7 @@ kittizz - + @@ -298,7 +309,7 @@ eltociear - + @@ -309,7 +320,7 @@ GoodbyeNJN - + @@ -320,7 +331,7 @@ AllDaGearNoIdea - + @@ -331,7 +342,7 @@ princemaple - + @@ -342,7 +353,7 @@ RoyRao2333 - + @@ -353,7 +364,7 @@ sebastianbreguel - + @@ -364,7 +375,7 @@ youxi798 - + @@ -375,7 +386,7 @@ fulldecent - + @@ -386,7 +397,7 @@ a5677746shdh - + @@ -397,7 +408,7 @@ beautifulrem - + @@ -408,7 +419,7 @@ bocanhcam - + @@ -419,7 +430,7 @@ dbraendle - + @@ -430,7 +441,7 @@ geekvest - + @@ -441,7 +452,7 @@ lakca - + @@ -452,7 +463,7 @@ liudonghua123 - + @@ -463,7 +474,7 @@ liusishan - + @@ -474,7 +485,7 @@ piaoyidage - + @@ -485,7 +496,7 @@ enihsyou - + @@ -496,7 +507,7 @@ hetz - + @@ -507,7 +518,7 @@ pgoslatara - + @@ -518,7 +529,7 @@ Milo123459 - + @@ -529,7 +540,7 @@ Jason6987 - + @@ -540,7 +551,7 @@ JohannLai - + @@ -551,7 +562,7 @@ droid-Q - + @@ -562,7 +573,7 @@ ImgBotApp - + @@ -573,7 +584,7 @@ Fechin - + @@ -584,7 +595,7 @@ fvn-elmy - + @@ -595,7 +606,7 @@ turkyden - + @@ -606,7 +617,7 @@ kuishou68 - + @@ -617,7 +628,7 @@ nekomeowww - + @@ -628,7 +639,7 @@ Bortlesboat - + @@ -639,7 +650,7 @@ kidylee - + @@ -650,7 +661,7 @@ ACGNnsj - + From 1255b8000185e5c361a9037dc68a7bb8003dde6f Mon Sep 17 00:00:00 2001 From: Erwann Mest Date: Sun, 21 Jun 2026 12:38:11 +0100 Subject: [PATCH 104/120] feat: honour --targets app on macOS for app-only builds --- bin/builders/MacBuilder.ts | 2 ++ dist/cli.js | 4 ++- docs/cli-usage.md | 4 ++- docs/cli-usage_CN.md | 4 ++- tests/unit/mac-builder-targets.test.ts | 38 ++++++++++++++++++++++++++ 5 files changed, 49 insertions(+), 3 deletions(-) create mode 100644 tests/unit/mac-builder-targets.test.ts diff --git a/bin/builders/MacBuilder.ts b/bin/builders/MacBuilder.ts index 1bf52989c3..1fbf776ae3 100644 --- a/bin/builders/MacBuilder.ts +++ b/bin/builders/MacBuilder.ts @@ -15,7 +15,9 @@ export default class MacBuilder extends BaseBuilder { ? options.targets : 'auto'; + // `app` is a valid macOS bundle target (see merge.ts); honour it explicitly. if ( + options.targets === 'app' || options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1' diff --git a/dist/cli.js b/dist/cli.js index d650c8bc26..26faafb182 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -1272,7 +1272,9 @@ class MacBuilder extends BaseBuilder { this.buildArch = validArchs.includes(options.targets || '') ? options.targets : 'auto'; - if (options.iterativeBuild || + // `app` is a valid macOS bundle target (see merge.ts); honour it explicitly. + if (options.targets === 'app' || + options.iterativeBuild || options.install || process.env.PAKE_CREATE_APP === '1') { this.buildFormat = 'app'; diff --git a/docs/cli-usage.md b/docs/cli-usage.md index 477d0b4628..e5e3705531 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -308,7 +308,7 @@ Specify the build target architecture or format: - **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: `deb`, `appimage`) - **Windows**: `x64`, `arm64` (auto-detects if not specified) -- **macOS**: `intel`, `apple`, `universal` (auto-detects if not specified) +- **macOS**: `intel`, `apple`, `universal` (architecture, auto-detects if not specified); `app`, `dmg` (output format, default: `dmg`) ```shell --targets @@ -319,6 +319,8 @@ Specify the build target architecture or format: --targets universal # macOS Universal (Intel + Apple Silicon) --targets apple # macOS Apple Silicon only --targets intel # macOS Intel only +--targets app # macOS app bundle only (.app, skips the DMG step) +--targets dmg # macOS DMG installer (default) --targets deb # Linux DEB package (x64) --targets rpm # Linux RPM package (x64) --targets appimage # Linux AppImage (x64) diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 4ced7e74d8..6310d5b4f8 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -306,7 +306,7 @@ pake https://github.com --name GitHub - **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(默认:`deb`, `appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) -- **macOS**: `intel`, `apple`, `universal`(未指定时自动检测) +- **macOS**: `intel`, `apple`, `universal`(架构,未指定时自动检测);`app`, `dmg`(输出格式,默认:`dmg`) ```shell --targets @@ -317,6 +317,8 @@ pake https://github.com --name GitHub --targets universal # macOS 通用版本(Intel + Apple Silicon) --targets apple # 仅 macOS Apple Silicon --targets intel # 仅 macOS Intel +--targets app # 仅 macOS 应用包(.app,跳过 DMG 步骤) +--targets dmg # macOS DMG 安装包(默认) --targets deb # Linux DEB 包(x64) --targets rpm # Linux RPM 包(x64) --targets appimage # Linux AppImage(x64) diff --git a/tests/unit/mac-builder-targets.test.ts b/tests/unit/mac-builder-targets.test.ts new file mode 100644 index 0000000000..128a29e26e --- /dev/null +++ b/tests/unit/mac-builder-targets.test.ts @@ -0,0 +1,38 @@ +import path from 'path'; +import { describe, it, expect, vi } from 'vitest'; + +// tauriConfig.ts reads pake.json at module load, keyed off npmDirectory. +// Point it at the repo root so the import chain resolves under vitest. +vi.mock('@/utils/dir', () => ({ + npmDirectory: process.cwd(), + tauriConfigDirectory: path.join(process.cwd(), 'src-tauri', '.pake'), +})); + +import MacBuilder from '@/builders/MacBuilder'; +import { PakeAppOptions } from '@/types'; + +const makeBuilder = (targets?: string) => + new MacBuilder({ name: 'Demo', targets } as PakeAppOptions); + +describe('MacBuilder target selection', () => { + it('builds an app bundle when --targets app is requested', () => { + // The app format ships a bare `.app`, so the file name carries no + // version/arch suffix. This proves `--targets app` is honoured rather + // than silently coerced to the default DMG. + expect(makeBuilder('app').getFileName()).toBe('Demo'); + }); + + it('builds a DMG when --targets dmg is requested', () => { + expect(makeBuilder('dmg').getFileName()).toMatch(/^Demo_.+/); + }); + + it('defaults to a DMG when no target is given', () => { + expect(makeBuilder(undefined).getFileName()).toMatch(/^Demo_.+/); + }); + + it('keeps treating arch values as DMG builds with an arch suffix', () => { + expect(makeBuilder('apple').getFileName()).toMatch(/_aarch64$/); + expect(makeBuilder('intel').getFileName()).toMatch(/_x64$/); + expect(makeBuilder('universal').getFileName()).toMatch(/_universal$/); + }); +}); From 53b9db8ccd80eb6abc412c25913650bbb33b00ab Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 08:45:34 +0800 Subject: [PATCH 105/120] docs: update Telegram community link --- README.md | 2 +- README_CN.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 26b5526f9b..92c8df0c1c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@
twitter - + telegram GitHub downloads diff --git a/README_CN.md b/README_CN.md index f5708e3b8a..bedb7e0a73 100644 --- a/README_CN.md +++ b/README_CN.md @@ -7,7 +7,7 @@
twitter - + telegram GitHub downloads @@ -205,7 +205,7 @@ Pake 的发展离不开这些优秀的贡献者 ❤️ 1. 购买我做的 Mac 清理应用 [Mole for Mac](https://mole.fit),是对我最直接的支持。 2. 如果你喜欢 Pake,可以在 Github Star,更欢迎 [推荐](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20一键打包网页生成轻量桌面应用,比%20Electron%20小%2020%20倍,支持%20macOS%20Windows%20Linux) 给志同道合的朋友使用。 -3. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+GclQS9ZnxyI2ODQ1) 聊天群。 +3. 可以关注我的 [Twitter](https://twitter.com/HiTw93) 获取最新的 Pake 更新消息,也欢迎加入 [Telegram](https://t.me/+9f9gf4ZrFSQ2OWVl) 聊天群。 4. 希望大伙玩的过程中有一种学习新技术的喜悦感,发现适合做成桌面 App 的网页也欢迎告诉我。 5. 我有两只猫,一只叫汤圆,一只可乐,假如 Pake 让你生活更美好,可以给她们 喂罐头 🥩。 From 84f164637402f42bc3dba1700a71383207b5397b Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 22 Jun 2026 00:46:46 +0000 Subject: [PATCH 106/120] Auto-fix formatting issues --- src-tauri/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0b60064f79..9feb346267 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -19,8 +19,8 @@ const WEBKIT_DISABLE_COMPOSITING_MODE: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; use app::{ invoke::{ - clear_dock_badge, download_file, increment_dock_badge, send_notification, - set_dock_badge, set_dock_badge_label, update_theme_mode, + clear_dock_badge, download_file, increment_dock_badge, send_notification, set_dock_badge, + set_dock_badge_label, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, From 7e280d3faa8b96bcf918eaed2327391d788dc6a8 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 10:16:42 +0800 Subject: [PATCH 107/120] docs: clarify 5M footprint refers to installer size, not RAM Issue #1249 confused the ~5M package-size claim with runtime memory. The WebKit render process memory is owned by the wrapped site, not Pake. --- README.md | 2 +- README_CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 92c8df0c1c..f046b4d5a1 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## Features -- 🎐 **Lightweight**: Nearly 20 times smaller than Electron packages, typically around 5M +- 🎐 **Lightweight**: Installer is nearly 20 times smaller than Electron packages, typically around 5M on disk - 🚀 **Fast**: Built with Rust Tauri, much faster than traditional JS frameworks with lower memory usage - ⚡ **Easy to use**: One-command packaging via CLI or online building, no complex configuration needed - 📦 **Feature-rich**: Supports shortcuts, immersive windows, drag & drop, style customization, ad removal diff --git a/README_CN.md b/README_CN.md index bedb7e0a73..23c400103c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -19,7 +19,7 @@ ## 特征 -- 🎐 **体积小巧**:相比 Electron 应用小近 20 倍,通常只有 5M 左右 +- 🎐 **体积小巧**:安装包相比 Electron 应用小近 20 倍,通常只有 5M 左右 - 🚀 **性能优异**:基于 Rust Tauri,比传统 JS 框架更快,内存占用更少 - ⚡ **使用简单**:命令行一键打包,或在线构建,无需复杂配置 - 📦 **功能丰富**:支持快捷键透传、沉浸式窗口、拖拽、样式定制、去广告 From 42b7ab5bfa92332c0ac8826456950e69974d022d Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 10:20:13 +0800 Subject: [PATCH 108/120] docs: state package size as under 10M for real-world accuracy Real packages (macOS universal, bundled icons) often exceed 5M; an upper bound of under 10M is more honest while still ~20x smaller than Electron. --- README.md | 2 +- README_CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f046b4d5a1..a762120a72 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ ## Features -- 🎐 **Lightweight**: Installer is nearly 20 times smaller than Electron packages, typically around 5M on disk +- 🎐 **Lightweight**: Installer is nearly 20 times smaller than Electron packages, typically under 10M on disk - 🚀 **Fast**: Built with Rust Tauri, much faster than traditional JS frameworks with lower memory usage - ⚡ **Easy to use**: One-command packaging via CLI or online building, no complex configuration needed - 📦 **Feature-rich**: Supports shortcuts, immersive windows, drag & drop, style customization, ad removal diff --git a/README_CN.md b/README_CN.md index 23c400103c..ec97872b4c 100644 --- a/README_CN.md +++ b/README_CN.md @@ -19,7 +19,7 @@ ## 特征 -- 🎐 **体积小巧**:安装包相比 Electron 应用小近 20 倍,通常只有 5M 左右 +- 🎐 **体积小巧**:安装包相比 Electron 应用小近 20 倍,通常小于 10M - 🚀 **性能优异**:基于 Rust Tauri,比传统 JS 框架更快,内存占用更少 - ⚡ **使用简单**:命令行一键打包,或在线构建,无需复杂配置 - 📦 **功能丰富**:支持快捷键透传、沉浸式窗口、拖拽、样式定制、去广告 From 3a83f04b45cacbc71abbd3ca9392a7eafc0f12e7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 10:22:08 +0800 Subject: [PATCH 109/120] docs: add FAQ for AppImage WebKitNetworkProcess crash on non-Debian builds --- docs/faq.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/faq_CN.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/docs/faq.md b/docs/faq.md index 2bd83eae74..2f96e52e73 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -10,6 +10,7 @@ Common issues and solutions when using Pake. - [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: AppImage Crashes at Launch with WebKitNetworkProcess Not Found](#linux-appimage-crashes-at-launch-with-webkitnetworkprocess-not-found) - [Linux: "cargo: command not found" After Installing Rust](#linux-cargo-command-not-found-after-installing-rust) - [Windows: Installation Timeout During First Build](#windows-installation-timeout-during-first-build) - [Windows: Missing Visual Studio Build Tools](#windows-missing-visual-studio-build-tools) @@ -197,6 +198,54 @@ The `NO_STRIP=1` environment variable is the official workaround recommended by --- +### Linux: AppImage Crashes at Launch with WebKitNetworkProcess Not Found + +**Problem:** +The AppImage builds successfully but crashes immediately at launch: + +```txt +** ERROR **: Unable to spawn a new child process: Failed to spawn child process +"././/lib/webkit2gtk-4.1/WebKitNetworkProcess" (No such file or directory) +``` + +This only affects AppImages built locally on a non-Debian distribution (Arch, Fedora, etc.). Pake's official AppImage releases are built in a Debian-based environment and are not affected. + +**Why This Happens:** +This is an upstream Tauri bundler limitation ([tauri-apps/tauri#5292](https://github.com/tauri-apps/tauri/issues/5292)). When bundling, Tauri rewrites the absolute WebKit helper path baked into `libwebkit2gtk*.so` to a relative `././...` form, and copies the helper binaries based on the Debian library layout (`/usr/lib//webkit2gtk-4.1`). On Arch the helpers live in `/usr/lib/webkit2gtk-4.1` with no architecture triple, so the patched relative path points at a `lib/webkit2gtk-4.1` directory that does not exist inside the bundle, and `WebKitNetworkProcess` can never be found. Pake does not control this step: the AppDir layout and path patching are produced entirely by `tauri build`. + +**Solution 1: Use the Arch native package (recommended on Arch)** + +```bash +pake https://example.com --name MyApp --targets zst +``` + +This produces a pacman package (`*.pkg.tar.zst`) that installs to system paths, so WebKit resolves its helper processes natively and there is no relocation problem. Install it with `sudo pacman -U MyApp-*.pkg.tar.zst`. + +**Solution 2: Build the AppImage in Docker (Debian-based)** + +Building inside Pake's Docker image matches the library layout the AppImage bundler expects: + +```bash +docker run --rm --privileged \ + --device /dev/fuse \ + --security-opt apparmor=unconfined \ + -v $(pwd)/output:/output \ + ghcr.io/tw93/pake:latest \ + https://example.com --name MyApp --targets appimage +``` + +**Workaround for an already-built AppImage:** +Extract it, add the missing symlink, then launch the inner `AppRun`: + +```bash +./MyApp.AppImage --appimage-extract +cd squashfs-root +mkdir -p lib && ln -s ../usr/lib/webkit2gtk-4.1 lib/webkit2gtk-4.1 +./AppRun +``` + +--- + ### Linux: AppImage Opens but Buttons or Keyboard Do Not Work on Wayland **Problem:** diff --git a/docs/faq_CN.md b/docs/faq_CN.md index c72847e167..d80c00f86f 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -10,6 +10,7 @@ - [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:AppImage 启动即崩溃,提示找不到 WebKitNetworkProcess](#linuxappimage-启动即崩溃提示找不到-webkitnetworkprocess) - [Linux:"cargo: command not found" 即使已安装 Rust](#linuxcargo-command-not-found-即使已安装-rust) - [Windows:首次构建时安装超时](#windows首次构建时安装超时) - [Windows:缺少 Visual Studio 构建工具](#windows缺少-visual-studio-构建工具) @@ -197,6 +198,54 @@ docker run --rm --privileged \ --- +### Linux:AppImage 启动即崩溃,提示找不到 WebKitNetworkProcess + +**问题描述:** +AppImage 构建成功,但启动时立即崩溃: + +```txt +** ERROR **: Unable to spawn a new child process: Failed to spawn child process +"././/lib/webkit2gtk-4.1/WebKitNetworkProcess" (No such file or directory) +``` + +这只影响在非 Debian 发行版(Arch、Fedora 等)本地构建出来的 AppImage。Pake 官方发布的 AppImage 在基于 Debian 的环境中构建,不受影响。 + +**原因:** +这是 Tauri 打包器的上游限制([tauri-apps/tauri#5292](https://github.com/tauri-apps/tauri/issues/5292))。打包时 Tauri 会把编译进 `libwebkit2gtk*.so` 的 WebKit 辅助进程绝对路径改写成相对的 `././...` 形式,并按 Debian 的库布局(`/usr/lib/<架构三元组>/webkit2gtk-4.1`)复制这些辅助二进制。Arch 上 WebKit 位于 `/usr/lib/webkit2gtk-4.1`,没有架构三元组,于是改写后的相对路径指向了 bundle 内并不存在的 `lib/webkit2gtk-4.1` 目录,`WebKitNetworkProcess` 永远找不到。Pake 不参与这一步:AppDir 布局和路径改写完全由 `tauri build` 生成。 + +**解决方案 1:使用 Arch 原生包(Arch 上推荐)** + +```bash +pake https://example.com --name MyApp --targets zst +``` + +这会生成 pacman 包(`*.pkg.tar.zst`),安装到系统路径,WebKit 按系统原生路径解析辅助进程,不存在重定位问题。用 `sudo pacman -U MyApp-*.pkg.tar.zst` 安装。 + +**解决方案 2:在 Docker(基于 Debian)中构建 AppImage** + +在 Pake 的 Docker 镜像中构建,库布局正好符合 AppImage 打包器的预期: + +```bash +docker run --rm --privileged \ + --device /dev/fuse \ + --security-opt apparmor=unconfined \ + -v $(pwd)/output:/output \ + ghcr.io/tw93/pake:latest \ + https://example.com --name MyApp --targets appimage +``` + +**已构建 AppImage 的临时绕过方法:** +解压后补上缺失的软链接,再运行内部的 `AppRun`: + +```bash +./MyApp.AppImage --appimage-extract +cd squashfs-root +mkdir -p lib && ln -s ../usr/lib/webkit2gtk-4.1 lib/webkit2gtk-4.1 +./AppRun +``` + +--- + ### Linux:AppImage 打开后按钮或键盘在 Wayland 下不可用 **问题描述:** From 65976be185331e0ac4ad2ede8400d6d5345cf5ef Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 11:09:15 +0800 Subject: [PATCH 110/120] fix: force wayland gdk backend on pure wayland compositors On pure Wayland compositors without XWayland (e.g. Niri), $DISPLAY is unset and GTK defaults to the X11 backend, aborting with "Failed to initialize GTK". Force GDK_BACKEND=wayland in that case while still respecting an explicit user override. --- src-tauri/src/lib.rs | 76 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9feb346267..76776b537a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,8 @@ const PAKE_LINUX_WEBKIT_SAFE_MODE: &str = "PAKE_LINUX_WEBKIT_SAFE_MODE"; const WEBKIT_DISABLE_DMABUF_RENDERER: &str = "WEBKIT_DISABLE_DMABUF_RENDERER"; #[cfg(target_os = "linux")] const WEBKIT_DISABLE_COMPOSITING_MODE: &str = "WEBKIT_DISABLE_COMPOSITING_MODE"; +#[cfg(target_os = "linux")] +const GDK_BACKEND: &str = "GDK_BACKEND"; use app::{ invoke::{ @@ -66,6 +68,34 @@ fn should_enable_linux_webkit_safe_mode_from_values( !is_niri_session } +#[cfg(any(target_os = "linux", test))] +fn should_force_wayland_gdk_backend( + gdk_backend: Option<&str>, + wayland_display: Option<&str>, + display: Option<&str>, +) -> bool { + // Respect an explicit user choice. + if is_non_empty_env_value(gdk_backend) { + return false; + } + + // On pure Wayland compositors without XWayland (e.g. Niri), $DISPLAY is unset + // and GTK defaults to the X11 backend, which aborts with "Failed to initialize + // GTK". Wayland is then the only viable backend, so forcing it is safe. + is_non_empty_env_value(wayland_display) && !is_non_empty_env_value(display) +} + +#[cfg(target_os = "linux")] +fn apply_linux_gdk_backend() { + if should_force_wayland_gdk_backend( + std::env::var(GDK_BACKEND).ok().as_deref(), + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + std::env::var("DISPLAY").ok().as_deref(), + ) { + std::env::set_var(GDK_BACKEND, "wayland"); + } +} + #[cfg(target_os = "linux")] fn apply_linux_webkit_runtime_flags() { let safe_mode = std::env::var(PAKE_LINUX_WEBKIT_SAFE_MODE).ok(); @@ -103,7 +133,10 @@ fn apply_linux_webkit_runtime_flags() { pub fn run_app() { #[cfg(target_os = "linux")] - apply_linux_webkit_runtime_flags(); + { + apply_linux_gdk_backend(); + apply_linux_webkit_runtime_flags(); + } let (pake_config, tauri_config) = get_pake_config(); let tauri_app = tauri::Builder::default(); @@ -328,4 +361,45 @@ mod tests { ); } } + + #[test] + fn forces_wayland_backend_on_pure_wayland() { + assert!(should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + None + )); + } + + #[test] + fn forces_wayland_backend_when_display_is_blank() { + assert!(should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + Some(" ") + )); + } + + #[test] + fn keeps_default_backend_when_x11_display_present() { + assert!(!should_force_wayland_gdk_backend( + None, + Some("wayland-0"), + Some(":0") + )); + } + + #[test] + fn keeps_default_backend_without_wayland_display() { + assert!(!should_force_wayland_gdk_backend(None, None, None)); + } + + #[test] + fn respects_explicit_gdk_backend_override() { + assert!(!should_force_wayland_gdk_backend( + Some("x11"), + Some("wayland-0"), + None + )); + } } From 0f8a68c8171926c07d69a378f41d65b45d7b876b Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 11:21:15 +0800 Subject: [PATCH 111/120] style: clear clippy lints in macos menu and tray handlers Local clippy (-D warnings) flagged three pre-existing lints unrelated to behavior: a duplicate target_os cfg already gated in app/mod.rs, an if-let-Ok over is_ok(), and a single-arm match better written as if let. Equivalent rewrites so the lint pass is clean locally. --- src-tauri/src/app/menu.rs | 6 ++---- src-tauri/src/app/setup.rs | 5 ++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/app/menu.rs b/src-tauri/src/app/menu.rs index e9537bbc04..db59b9e3f0 100644 --- a/src-tauri/src/app/menu.rs +++ b/src-tauri/src/app/menu.rs @@ -1,6 +1,4 @@ -// Menu functionality is only used on macOS -#![cfg(target_os = "macos")] - +// Menu functionality is only used on macOS; the module is gated in app/mod.rs. use crate::app::window::open_additional_window_safe; use tauri::menu::{AboutMetadata, Menu, MenuItem, PredefinedMenuItem, Submenu}; use tauri::{AppHandle, Manager, Wry}; @@ -308,7 +306,7 @@ pub fn handle_menu_click(app_handle: &AppHandle, id: &str) { } "clear_cache_restart" => { if let Some(window) = app_handle.get_webview_window("pake") { - if let Ok(_) = window.clear_all_browsing_data() { + if window.clear_all_browsing_data().is_ok() { app_handle.restart(); } } diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index 87f27bb8e5..b94ff571ef 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -66,8 +66,8 @@ pub fn set_system_tray( } _ => (), }) - .on_tray_icon_event(move |tray, event| match event { - TrayIconEvent::Click { button, .. } => { + .on_tray_icon_event(move |tray, event| { + if let TrayIconEvent::Click { button, .. } = event { if button == tauri::tray::MouseButton::Left { if let Some(window) = tray.app_handle().get_webview_window("pake") { let is_visible = window.is_visible().unwrap_or(false); @@ -84,7 +84,6 @@ pub fn set_system_tray( } } } - _ => {} }); let resolved_icon = if tray_icon_path.is_empty() { From e7b07bc7ae27e52de81da0de582e2dc2f2970e9c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 22 Jun 2026 03:21:54 +0000 Subject: [PATCH 112/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index fb2b8012ce..6431933a90 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -596,6 +596,17 @@ + + + + + + + + kud + + + @@ -606,7 +617,7 @@ turkyden - + @@ -617,7 +628,7 @@ kuishou68 - + @@ -628,7 +639,7 @@ nekomeowww - + @@ -639,7 +650,7 @@ Bortlesboat - + @@ -650,7 +661,7 @@ kidylee - + @@ -661,7 +672,7 @@ ACGNnsj - + From ba7ff5893f331a680d8f0cbe6e5b5123dcfc83bd Mon Sep 17 00:00:00 2001 From: Tw93 Date: Mon, 22 Jun 2026 14:10:33 +0800 Subject: [PATCH 113/120] release: 3.12.1 npm-only publish via the npm-publish workflow_dispatch path; no V tag or GitHub release this round. Ships the macOS --targets app fix (#1248) and the pure-Wayland GDK_BACKEND startup fix for AppImage (#1251). --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index 26faafb182..c18d529a42 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import { InvalidArgumentError, program as program$1, Option } from 'commander'; import fs$1 from 'fs'; var name = "pake-cli"; -var version = "3.12.0"; +var version = "3.12.1"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index 8b0ab385bb..d0ac38b0fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.12.0", + "version": "3.12.1", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 51ec494193..e8e172a320 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.12.0" +version = "3.12.1" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 32c23034ef..dec2c28766 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.12.0" +version = "3.12.1" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 51f94a5422..2d051714ac 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.12.0", + "version": "3.12.1", "app": { "withGlobalTauri": true, "trayIcon": { From aad510fd723b896fb014f2b532383d74b445c695 Mon Sep 17 00:00:00 2001 From: ekishion <66163050+ekishion@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:48:21 +0800 Subject: [PATCH 114/120] fix: exclude FULLSCREEN from window state tracking when not using --fullscreen When not built with --fullscreen, exclude FULLSCREEN from the window-state plugin flags so a prior --fullscreen build's persisted state can no longer force fullscreen on a rebuild. Match the tray quit save logic to the plugin init. Closes #1259 --- src-tauri/src/app/setup.rs | 7 ++++++- src-tauri/src/lib.rs | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/app/setup.rs b/src-tauri/src/app/setup.rs index b94ff571ef..8fc4aaaa96 100644 --- a/src-tauri/src/app/setup.rs +++ b/src-tauri/src/app/setup.rs @@ -61,7 +61,12 @@ pub fn set_system_tray( } } "quit" => { - let _ = app.save_window_state(StateFlags::all()); + let flags = if _init_fullscreen { + StateFlags::all() + } else { + StateFlags::all() & !StateFlags::FULLSCREEN + }; + let _ = app.save_window_state(flags); app.exit(0); } _ => (), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 76776b537a..77f64e849b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -155,7 +155,9 @@ pub fn run_app() { StateFlags::FULLSCREEN } else { // Prevent flickering on the first open. - StateFlags::all() & !StateFlags::VISIBLE + // Exclude FULLSCREEN so a prior --fullscreen build's persisted state + // doesn't force fullscreen on a rebuild without --fullscreen. + StateFlags::all() & !StateFlags::VISIBLE & !StateFlags::FULLSCREEN }) .build(); From 51c7b5d581d55ad2ef25faebbaf3cfcda07139cc Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 27 Jun 2026 07:51:33 +0800 Subject: [PATCH 115/120] fix: use native WebView zoom to fix ChatGPT layout breaking on zoom Pake faked page zoom in injected JS: `transform: scale` on Windows and `html.style.zoom` on macOS/Linux. Both break complex SPAs like ChatGPT. The page shifts right on Windows (#1264) and fixed or GPU-composited layers stop repainting on macOS (#1160), and the prior `resize` dispatch could not force a repaint. Switch to the WebView native zoom via `WebviewWindow::set_zoom`, which maps to WKWebView pageZoom, WebView2 ZoomFactor, and WebKitGTK zoom level, the same browser zoom that recalculates layout correctly on every platform. --- src-tauri/src/app/invoke.rs | 12 ++++++++++++ src-tauri/src/inject/event.js | 20 +++++++------------- src-tauri/src/lib.rs | 3 ++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src-tauri/src/app/invoke.rs b/src-tauri/src/app/invoke.rs index e928f30002..667f25155d 100644 --- a/src-tauri/src/app/invoke.rs +++ b/src-tauri/src/app/invoke.rs @@ -191,3 +191,15 @@ pub async fn update_theme_mode(app: AppHandle, mode: String) { let _ = window.set_theme(Some(theme)); } } + +// Apply native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor / WebKitGTK +// zoom level) instead of CSS hacks. CSS `transform: scale` and `html.style.zoom` +// break complex SPAs like ChatGPT (fixed positioning shifts, unrepainted layers); +// native zoom recalculates layout the same way a browser does for Cmd/Ctrl +/-. +#[command] +pub fn set_zoom(window: WebviewWindow, percent: f64) -> Result<(), String> { + let factor = (percent / 100.0).clamp(0.3, 2.0); + window + .set_zoom(factor) + .map_err(|e| format!("Failed to set zoom: {}", e)) +} diff --git a/src-tauri/src/inject/event.js b/src-tauri/src/inject/event.js index 40ea29aecd..c00962f8d4 100644 --- a/src-tauri/src/inject/event.js +++ b/src-tauri/src/inject/event.js @@ -11,19 +11,13 @@ const shortcuts = { }; function setZoom(zoom) { - const html = document.getElementsByTagName("html")[0]; - const body = document.body; - const zoomValue = parseFloat(zoom) / 100; - const isWindows = /windows/i.test(navigator.userAgent); - - if (isWindows) { - body.style.transform = `scale(${zoomValue})`; - body.style.transformOrigin = "top left"; - body.style.width = `${100 / zoomValue}%`; - body.style.height = `${100 / zoomValue}%`; - } else { - html.style.zoom = zoom; - window.dispatchEvent(new Event("resize")); + // Use native WebView zoom (WKWebView pageZoom / WebView2 ZoomFactor) instead of + // CSS hacks. `transform: scale` and `html.style.zoom` break complex SPAs like + // ChatGPT: the page shifts right on Windows and parts of the UI stop repainting + // on macOS. Native zoom recalculates layout exactly like a browser does. + const invoke = window.__TAURI__?.core?.invoke; + if (invoke) { + invoke("set_zoom", { percent: parseFloat(zoom) }).catch(() => {}); } window.localStorage.setItem("htmlZoom", zoom); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 77f64e849b..d3ed106095 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -22,7 +22,7 @@ const GDK_BACKEND: &str = "GDK_BACKEND"; use app::{ invoke::{ clear_dock_badge, download_file, increment_dock_badge, send_notification, set_dock_badge, - set_dock_badge_label, update_theme_mode, + set_dock_badge_label, set_zoom, update_theme_mode, }, setup::{set_global_shortcut, set_system_tray}, window::{open_additional_window_safe, set_window, MultiWindowState}, @@ -194,6 +194,7 @@ pub fn run_app() { set_dock_badge_label, clear_dock_badge, update_theme_mode, + set_zoom, ]) .setup(move |app| { app.manage(MultiWindowState::new( From 4b9bda4f06fef1ad2ffabeee50d996e4d5f21537 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 27 Jun 2026 08:01:55 +0800 Subject: [PATCH 116/120] feat: choose Linux default bundle target by distro family RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) were always built through the .deb path because the Linux default target was hardcoded to deb,appimage. Their package manager cannot install a .deb, and on newer distros the deb bundler can abort outright, taking the would-be-usable AppImage down with it since the multi-target loop failed fast. Detect the package family from /etc/os-release and default to rpm,appimage on RPM systems while keeping deb,appimage on Debian/Ubuntu. When multiple targets are requested, a single target failure no longer aborts the rest, so a portable AppImage still ships when the native bundler fails; explicit single targets keep fail-fast behavior. Refs #1262 --- bin/builders/LinuxBuilder.ts | 49 +++++++++-- bin/defaults.ts | 3 +- bin/utils/platform.ts | 96 +++++++++++++++++++++ dist/cli.js | 144 ++++++++++++++++++++++++++++---- docs/cli-usage.md | 2 +- docs/cli-usage_CN.md | 2 +- docs/faq.md | 41 +++++++++ docs/faq_CN.md | 38 +++++++++ tests/unit/linux-distro.test.ts | 46 ++++++++++ 9 files changed, 395 insertions(+), 26 deletions(-) create mode 100644 tests/unit/linux-distro.test.ts diff --git a/bin/builders/LinuxBuilder.ts b/bin/builders/LinuxBuilder.ts index 6c3138e170..b38b0111f7 100644 --- a/bin/builders/LinuxBuilder.ts +++ b/bin/builders/LinuxBuilder.ts @@ -72,19 +72,52 @@ export default class LinuxBuilder extends BaseBuilder { } const useTemporaryDebForZst = needsTemporaryDebForZst(targets); + // With a single explicit target, fail fast. With multiple targets (the + // distro-aware default, or an explicit comma list) keep building the rest + // when one fails, so a usable installer is still produced, e.g. AppImage + // survives a .deb bundler abort on RPM-based distros. + const isolateFailures = targets.length > 1; + const failed: string[] = []; + let firstError: Error | null = null; + for (const target of targets) { this.currentBuildType = target; - if (target === 'zst') { - if (useTemporaryDebForZst) { - await this.buildAndCopy(url, 'deb', false); + try { + if (target === 'zst') { + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); + } else { + await this.buildAndCopy(url, target); } - await this.createArchPackageFromDeb({ - removeSourceDeb: useTemporaryDebForZst, - }); - } else { - await this.buildAndCopy(url, target); + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (!isolateFailures) { + throw err; + } + if (!firstError) { + firstError = err; + } + failed.push(target); + logger.warn( + `✼ Failed to build "${target}" target: ${err.message.split('\n')[0]}`, + ); } } + + // Every requested target failed: surface the first real error. + if (firstError && failed.length === targets.length) { + throw firstError; + } + + if (failed.length > 0) { + logger.warn( + `✼ Skipped failed Linux targets: ${failed.join(', ')}. Other formats built successfully.`, + ); + } } private async ensureArchPackagingTools() { diff --git a/bin/defaults.ts b/bin/defaults.ts index c079e5f18d..a9539d9a8a 100644 --- a/bin/defaults.ts +++ b/bin/defaults.ts @@ -1,4 +1,5 @@ import { PakeCliOptions } from './types.js'; +import { getDefaultLinuxTargets } from './utils/platform.js'; export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { icon: '', @@ -19,7 +20,7 @@ export const DEFAULT_PAKE_OPTIONS: PakeCliOptions = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return getDefaultLinuxTargets(); case 'darwin': return 'dmg'; case 'win32': diff --git a/bin/utils/platform.ts b/bin/utils/platform.ts index 261581612c..155a522703 100644 --- a/bin/utils/platform.ts +++ b/bin/utils/platform.ts @@ -1,5 +1,101 @@ +import fs from 'fs'; + const { platform } = process; export const IS_MAC = platform === 'darwin'; export const IS_WIN = platform === 'win32'; export const IS_LINUX = platform === 'linux'; + +export type LinuxPackageFamily = 'deb' | 'rpm'; + +// Distro IDs / ID_LIKE families that ship an RPM-based package manager. +const RPM_FAMILY_IDS = new Set([ + 'rhel', + 'fedora', + 'centos', + 'rocky', + 'almalinux', + 'ol', // Oracle Linux + 'oracle', + 'amzn', // Amazon Linux + 'mariner', + 'azurelinux', + 'suse', + 'opensuse', + 'opensuse-leap', + 'opensuse-tumbleweed', + 'sles', +]); + +// Distro IDs / ID_LIKE families that ship a DEB-based package manager. +const DEB_FAMILY_IDS = new Set([ + 'debian', + 'ubuntu', + 'linuxmint', + 'pop', + 'elementary', + 'kali', + 'raspbian', + 'devuan', +]); + +// Parse the shell-style key=value pairs of an /etc/os-release file, stripping +// the optional surrounding quotes around values. +function parseOsRelease(content: string): Record { + const fields: Record = {}; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const separator = line.indexOf('='); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if ( + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) + ) { + value = value.slice(1, -1); + } + if (key) fields[key] = value; + } + return fields; +} + +// Detect the package family from /etc/os-release. The distro's own ID wins over +// ID_LIKE hints, and an unknown distro falls back to 'deb' to preserve Pake's +// historical default. Accepts content directly so the decision is unit-testable +// without a real /etc/os-release. +export function detectLinuxPackageFamily( + osReleaseContent?: string, +): LinuxPackageFamily { + let content = osReleaseContent; + if (content === undefined) { + try { + content = fs.readFileSync('/etc/os-release', 'utf-8'); + } catch { + return 'deb'; + } + } + + const fields = parseOsRelease(content); + const id = (fields.ID ?? '').toLowerCase().trim(); + const idLike = (fields.ID_LIKE ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + + for (const token of [id, ...idLike]) { + if (DEB_FAMILY_IDS.has(token)) return 'deb'; + if (RPM_FAMILY_IDS.has(token)) return 'rpm'; + } + return 'deb'; +} + +// Default Linux bundle targets, chosen by the host distro's package family so +// RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) get a native .rpm +// instead of a .deb their package manager cannot install. AppImage stays as a +// universal fallback in both cases. +export function getDefaultLinuxTargets(): string { + return detectLinuxPackageFamily() === 'rpm' ? 'rpm,appimage' : 'deb,appimage'; +} diff --git a/dist/cli.js b/dist/cli.js index c18d529a42..d954510e8f 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -10,14 +10,14 @@ import os from 'os'; import { execa, execaSync } from 'execa'; import crypto from 'crypto'; import ora from 'ora'; -import fs from 'fs/promises'; +import fs from 'fs'; +import fs$1 from 'fs/promises'; import { dir } from 'tmp-promise'; import { fileTypeFromBuffer } from 'file-type'; import icongen from 'icon-gen'; import sharp from 'sharp'; import * as psl from 'psl'; import { InvalidArgumentError, program as program$1, Option } from 'commander'; -import fs$1 from 'fs'; var name = "pake-cli"; var version = "3.12.1"; @@ -230,6 +230,93 @@ const { platform: platform$1 } = process; const IS_MAC = platform$1 === 'darwin'; const IS_WIN = platform$1 === 'win32'; const IS_LINUX = platform$1 === 'linux'; +// Distro IDs / ID_LIKE families that ship an RPM-based package manager. +const RPM_FAMILY_IDS = new Set([ + 'rhel', + 'fedora', + 'centos', + 'rocky', + 'almalinux', + 'ol', // Oracle Linux + 'oracle', + 'amzn', // Amazon Linux + 'mariner', + 'azurelinux', + 'suse', + 'opensuse', + 'opensuse-leap', + 'opensuse-tumbleweed', + 'sles', +]); +// Distro IDs / ID_LIKE families that ship a DEB-based package manager. +const DEB_FAMILY_IDS = new Set([ + 'debian', + 'ubuntu', + 'linuxmint', + 'pop', + 'elementary', + 'kali', + 'raspbian', + 'devuan', +]); +// Parse the shell-style key=value pairs of an /etc/os-release file, stripping +// the optional surrounding quotes around values. +function parseOsRelease(content) { + const fields = {}; + for (const rawLine of content.split('\n')) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) + continue; + const separator = line.indexOf('='); + if (separator === -1) + continue; + const key = line.slice(0, separator).trim(); + let value = line.slice(separator + 1).trim(); + if (value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")))) { + value = value.slice(1, -1); + } + if (key) + fields[key] = value; + } + return fields; +} +// Detect the package family from /etc/os-release. The distro's own ID wins over +// ID_LIKE hints, and an unknown distro falls back to 'deb' to preserve Pake's +// historical default. Accepts content directly so the decision is unit-testable +// without a real /etc/os-release. +function detectLinuxPackageFamily(osReleaseContent) { + let content = osReleaseContent; + if (content === undefined) { + try { + content = fs.readFileSync('/etc/os-release', 'utf-8'); + } + catch { + return 'deb'; + } + } + const fields = parseOsRelease(content); + const id = (fields.ID ?? '').toLowerCase().trim(); + const idLike = (fields.ID_LIKE ?? '') + .toLowerCase() + .split(/\s+/) + .filter(Boolean); + for (const token of [id, ...idLike]) { + if (DEB_FAMILY_IDS.has(token)) + return 'deb'; + if (RPM_FAMILY_IDS.has(token)) + return 'rpm'; + } + return 'deb'; +} +// Default Linux bundle targets, chosen by the host distro's package family so +// RPM-based distros (Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) get a native .rpm +// instead of a .deb their package manager cannot install. AppImage stays as a +// universal fallback in both cases. +function getDefaultLinuxTargets() { + return detectLinuxPackageFamily() === 'rpm' ? 'rpm,appimage' : 'deb,appimage'; +} async function shellExec(command, timeout = 300000, env) { try { @@ -339,7 +426,7 @@ function checkRustInstalled() { async function combineFiles(files, output) { const contents = await Promise.all(files.map(async (file) => { if (file.endsWith('.css')) { - const fileContent = await fs.readFile(file, 'utf-8'); + const fileContent = await fs$1.readFile(file, 'utf-8'); return `window.addEventListener('DOMContentLoaded', (_event) => { const css = ${JSON.stringify(fileContent)}; const style = document.createElement('style'); @@ -347,12 +434,12 @@ async function combineFiles(files, output) { document.head.appendChild(style); });`; } - const fileContent = await fs.readFile(file); + const fileContent = await fs$1.readFile(file); return ("window.addEventListener('DOMContentLoaded', (_event) => { " + fileContent + ' });'); })); - await fs.writeFile(output, contents.join('\n')); + await fs$1.writeFile(output, contents.join('\n')); return files; } @@ -1444,20 +1531,47 @@ class LinuxBuilder extends BaseBuilder { throw new Error(`No valid Linux target in "${this.options.targets}". Valid targets: ${LINUX_TARGET_TYPES.join(', ')}.`); } const useTemporaryDebForZst = needsTemporaryDebForZst(targets); + // With a single explicit target, fail fast. With multiple targets (the + // distro-aware default, or an explicit comma list) keep building the rest + // when one fails, so a usable installer is still produced, e.g. AppImage + // survives a .deb bundler abort on RPM-based distros. + const isolateFailures = targets.length > 1; + const failed = []; + let firstError = null; for (const target of targets) { this.currentBuildType = target; - if (target === 'zst') { - if (useTemporaryDebForZst) { - await this.buildAndCopy(url, 'deb', false); + try { + if (target === 'zst') { + if (useTemporaryDebForZst) { + await this.buildAndCopy(url, 'deb', false); + } + await this.createArchPackageFromDeb({ + removeSourceDeb: useTemporaryDebForZst, + }); + } + else { + await this.buildAndCopy(url, target); } - await this.createArchPackageFromDeb({ - removeSourceDeb: useTemporaryDebForZst, - }); } - else { - await this.buildAndCopy(url, target); + catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + if (!isolateFailures) { + throw err; + } + if (!firstError) { + firstError = err; + } + failed.push(target); + logger.warn(`✼ Failed to build "${target}" target: ${err.message.split('\n')[0]}`); } } + // Every requested target failed: surface the first real error. + if (firstError && failed.length === targets.length) { + throw firstError; + } + if (failed.length > 0) { + logger.warn(`✼ Skipped failed Linux targets: ${failed.join(', ')}. Other formats built successfully.`); + } } async ensureArchPackagingTools() { const requiredTools = [ @@ -2569,7 +2683,7 @@ const DEFAULT_PAKE_OPTIONS = { targets: (() => { switch (process.platform) { case 'linux': - return 'deb,appimage'; + return getDefaultLinuxTargets(); case 'darwin': return 'dmg'; case 'win32': @@ -2621,7 +2735,7 @@ function validateNumberInput(value) { return parsedValue; } function validateUrlInput(url) { - const isFile = fs$1.existsSync(url); + const isFile = fs.existsSync(url); if (!isFile) { try { return normalizeUrl(url); diff --git a/docs/cli-usage.md b/docs/cli-usage.md index e5e3705531..e53641d523 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -306,7 +306,7 @@ Package the application to support both Intel and M1 chips, exclusively for macO Specify the build target architecture or format: -- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: `deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64` (default: distro-aware, `deb, appimage` on Debian/Ubuntu and `rpm, appimage` on Fedora/RHEL/Oracle/Rocky/Alma/openSUSE) - **Windows**: `x64`, `arm64` (auto-detects if not specified) - **macOS**: `intel`, `apple`, `universal` (architecture, auto-detects if not specified); `app`, `dmg` (output format, default: `dmg`) diff --git a/docs/cli-usage_CN.md b/docs/cli-usage_CN.md index 6310d5b4f8..d013e2d394 100644 --- a/docs/cli-usage_CN.md +++ b/docs/cli-usage_CN.md @@ -304,7 +304,7 @@ pake https://github.com --name GitHub 指定构建目标架构或格式: -- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(默认:`deb`, `appimage`) +- **Linux**: `deb`, `appimage`, `rpm`, `zst`, `deb-arm64`, `appimage-arm64`, `rpm-arm64`, `zst-arm64`(默认:按发行版自适应,Debian/Ubuntu 为 `deb, appimage`,Fedora/RHEL/Oracle/Rocky/Alma/openSUSE 为 `rpm, appimage`) - **Windows**: `x64`, `arm64`(未指定时自动检测) - **macOS**: `intel`, `apple`, `universal`(架构,未指定时自动检测);`app`, `dmg`(输出格式,默认:`dmg`) diff --git a/docs/faq.md b/docs/faq.md index 2f96e52e73..da23320f47 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -9,6 +9,7 @@ Common issues and solutions when using Pake. - [Build Issues](#build-issues) - [Rust Version Error: "feature 'edition2024' is required"](#rust-version-error-feature-edition2024-is-required) - [Linux: Build Error "Can't detect any appindicator library" on Ubuntu 24.04](#linux-build-error-cant-detect-any-appindicator-library-on-ubuntu-2404) + - [Linux: Installing on Fedora / RHEL / Oracle Linux (RPM-based distros)](#linux-installing-on-fedora--rhel--oracle-linux-rpm-based-distros) - [Linux: AppImage Build Fails with "failed to run linuxdeploy"](#linux-appimage-build-fails-with-failed-to-run-linuxdeploy) - [Linux: AppImage Crashes at Launch with WebKitNetworkProcess Not Found](#linux-appimage-crashes-at-launch-with-webkitnetworkprocess-not-found) - [Linux: "cargo: command not found" After Installing Rust](#linux-cargo-command-not-found-after-installing-rust) @@ -101,6 +102,46 @@ sudo apt-get install -y libayatana-appindicator3-dev --- +### Linux: Installing on Fedora / RHEL / Oracle Linux (RPM-based distros) + +**Problem:** +On RPM-based distros (Fedora, RHEL, Oracle Linux, Rocky, AlmaLinux, openSUSE) a +`.deb` package cannot be installed by the system package manager, and older Pake +versions always built a `.deb` first. + +**Solution:** + +Pake now picks the default bundle target from `/etc/os-release`: RPM-based +distros default to `rpm, appimage`, while Debian/Ubuntu keep `deb, appimage`. So +the basic command already produces an installable package: + +```bash +pake https://github.com --name GitHub +sudo dnf install ./GitHub.rpm # or: sudo rpm -i ./GitHub.rpm +``` + +You can also choose the format explicitly at any time: + +```bash +pake https://github.com --name GitHub --targets rpm # RPM package +pake https://github.com --name GitHub --targets appimage # portable AppImage +``` + +When several targets build (the default), one format failing no longer aborts +the others: if the `.rpm`/`.deb` bundler fails, the AppImage is still produced as +a portable fallback. AppImage runs without installation: + +```bash +chmod +x ./GitHub.AppImage +./GitHub.AppImage +``` + +> Building an `.rpm` requires `rpm-build` (`sudo dnf install rpm-build`). If you +> only need a runnable app without packaging, add `--keep-binary` to also copy +> the raw executable next to the installer. + +--- + ### Linux: AppImage Build Fails with "failed to run linuxdeploy" **Problem:** diff --git a/docs/faq_CN.md b/docs/faq_CN.md index d80c00f86f..e9b31172be 100644 --- a/docs/faq_CN.md +++ b/docs/faq_CN.md @@ -9,6 +9,7 @@ - [构建问题](#构建问题) - [Rust 版本错误:"feature 'edition2024' is required"](#rust-版本错误feature-edition2024-is-required) - [Linux:Ubuntu 24.04 构建报错 "Can't detect any appindicator library"](#linuxubuntu-2404-构建报错-cant-detect-any-appindicator-library) + - [Linux:在 Fedora / RHEL / Oracle Linux 等 RPM 系发行版上安装](#linux在-fedora--rhel--oracle-linux-等-rpm-系发行版上安装) - [Linux:AppImage 构建失败,提示 "failed to run linuxdeploy"](#linuxappimage-构建失败提示-failed-to-run-linuxdeploy) - [Linux:AppImage 启动即崩溃,提示找不到 WebKitNetworkProcess](#linuxappimage-启动即崩溃提示找不到-webkitnetworkprocess) - [Linux:"cargo: command not found" 即使已安装 Rust](#linuxcargo-command-not-found-即使已安装-rust) @@ -101,6 +102,43 @@ sudo apt-get install -y libayatana-appindicator3-dev --- +### Linux:在 Fedora / RHEL / Oracle Linux 等 RPM 系发行版上安装 + +**问题:** +在 RPM 系发行版(Fedora、RHEL、Oracle Linux、Rocky、AlmaLinux、openSUSE)上, +`.deb` 包无法被系统包管理器安装,而旧版本 Pake 总是先构建 `.deb`。 + +**解决方法:** + +Pake 现在会读取 `/etc/os-release` 来决定默认打包目标:RPM 系发行版默认使用 +`rpm, appimage`,Debian/Ubuntu 仍然是 `deb, appimage`。所以基础命令就能直接产出 +可安装的包: + +```bash +pake https://github.com --name GitHub +sudo dnf install ./GitHub.rpm # 或:sudo rpm -i ./GitHub.rpm +``` + +你也可以随时显式指定格式: + +```bash +pake https://github.com --name GitHub --targets rpm # RPM 包 +pake https://github.com --name GitHub --targets appimage # 便携 AppImage +``` + +默认会构建多个目标,此时单个格式失败不再中断其余格式:如果 `.rpm`/`.deb` 打包失败, +仍会产出 AppImage 作为便携回退方案。AppImage 无需安装即可运行: + +```bash +chmod +x ./GitHub.AppImage +./GitHub.AppImage +``` + +> 构建 `.rpm` 需要 `rpm-build`(`sudo dnf install rpm-build`)。如果你只想要一个可运行 +> 的程序而不需要打包,可加上 `--keep-binary`,它会把原始可执行文件复制到安装包旁边。 + +--- + ### Linux:AppImage 构建失败,提示 "failed to run linuxdeploy" **问题描述:** diff --git a/tests/unit/linux-distro.test.ts b/tests/unit/linux-distro.test.ts new file mode 100644 index 0000000000..e9513d46db --- /dev/null +++ b/tests/unit/linux-distro.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { detectLinuxPackageFamily } from '../../bin/utils/platform.js'; + +describe('detectLinuxPackageFamily', () => { + it('detects Debian/Ubuntu families as deb', () => { + expect(detectLinuxPackageFamily('ID=debian')).toBe('deb'); + expect(detectLinuxPackageFamily('ID=ubuntu\nID_LIKE=debian')).toBe('deb'); + expect( + detectLinuxPackageFamily('ID=linuxmint\nID_LIKE="ubuntu debian"'), + ).toBe('deb'); + }); + + it('detects Fedora/RHEL families as rpm', () => { + expect(detectLinuxPackageFamily('ID=fedora')).toBe('rpm'); + expect(detectLinuxPackageFamily('ID=rhel')).toBe('rpm'); + expect( + detectLinuxPackageFamily('ID=rocky\nID_LIKE="rhel centos fedora"'), + ).toBe('rpm'); + }); + + it('detects Oracle Linux (ID=ol) as rpm', () => { + expect( + detectLinuxPackageFamily('ID="ol"\nID_LIKE="fedora"\nVERSION_ID="10.1"'), + ).toBe('rpm'); + }); + + it('detects openSUSE/SLES as rpm', () => { + expect( + detectLinuxPackageFamily('ID=opensuse-leap\nID_LIKE="suse opensuse"'), + ).toBe('rpm'); + expect(detectLinuxPackageFamily('ID=sles')).toBe('rpm'); + }); + + it('prefers the distro ID over ID_LIKE hints', () => { + // A deb-based distro that lists no rpm hint stays deb. + expect(detectLinuxPackageFamily('ID=ubuntu')).toBe('deb'); + // A distro whose own ID is rpm-based wins even with mixed-looking input. + expect(detectLinuxPackageFamily('ID=fedora\nID_LIKE=')).toBe('rpm'); + }); + + it('falls back to deb for unknown or empty os-release', () => { + expect(detectLinuxPackageFamily('')).toBe('deb'); + expect(detectLinuxPackageFamily('ID=arch')).toBe('deb'); + expect(detectLinuxPackageFamily('# just a comment')).toBe('deb'); + }); +}); From e2a82a48c4a85e38a4a482e9e1f3517cc2dc7cc7 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 27 Jun 2026 08:45:05 +0800 Subject: [PATCH 117/120] test: isolate PAKE_CREATE_APP in mac-builder target selection test The CI fast-test step runs tests/index.js with PAKE_CREATE_APP=1, which leaks into the spawned vitest process and forces MacBuilder's format to app, dropping the DMG name suffix. The DMG-naming assertions then failed only in CI while passing locally. Stub the env var per-test so the suite is deterministic regardless of the ambient environment. --- tests/unit/mac-builder-targets.test.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/unit/mac-builder-targets.test.ts b/tests/unit/mac-builder-targets.test.ts index 128a29e26e..1b39372d6f 100644 --- a/tests/unit/mac-builder-targets.test.ts +++ b/tests/unit/mac-builder-targets.test.ts @@ -1,5 +1,5 @@ import path from 'path'; -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // tauriConfig.ts reads pake.json at module load, keyed off npmDirectory. // Point it at the repo root so the import chain resolves under vitest. @@ -15,6 +15,17 @@ const makeBuilder = (targets?: string) => new MacBuilder({ name: 'Demo', targets } as PakeAppOptions); describe('MacBuilder target selection', () => { + // The CI fast-test step runs with PAKE_CREATE_APP=1, which forces the macOS + // build format to `app` and strips the DMG name suffix. Clear it so these + // DMG-naming assertions stay deterministic regardless of the ambient env. + beforeEach(() => { + vi.stubEnv('PAKE_CREATE_APP', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('builds an app bundle when --targets app is requested', () => { // The app format ships a bare `.app`, so the file name carries no // version/arch suffix. This proves `--targets app` is honoured rather From 1f088659d4b6c6eabfc38516b15ba4de48ff0004 Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 27 Jun 2026 08:45:11 +0800 Subject: [PATCH 118/120] release: 3.13.0 npm-only publish via the npm-publish workflow_dispatch path; no V tag or GitHub release this round. Ships the fullscreen window-state fix (#1259), native WebView zoom for ChatGPT (#1264, #1160), and per-distro Linux bundle target selection (#1262). --- dist/cli.js | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dist/cli.js b/dist/cli.js index d954510e8f..33dac95be6 100644 --- a/dist/cli.js +++ b/dist/cli.js @@ -20,7 +20,7 @@ import * as psl from 'psl'; import { InvalidArgumentError, program as program$1, Option } from 'commander'; var name = "pake-cli"; -var version = "3.12.1"; +var version = "3.13.0"; var description = "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。"; var engines = { node: ">=18.0.0" diff --git a/package.json b/package.json index d0ac38b0fc..40e38d8682 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pake-cli", - "version": "3.12.1", + "version": "3.13.0", "description": "🤱🏻 Turn any webpage into a desktop app with one command. 🤱🏻 一键打包网页生成轻量桌面应用。", "engines": { "node": ">=18.0.0" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8e172a320..d3a702ea60 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2564,7 +2564,7 @@ dependencies = [ [[package]] name = "pake" -version = "3.12.1" +version = "3.13.0" dependencies = [ "objc2", "objc2-app-kit", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index dec2c28766..e99af7612b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pake" -version = "3.12.1" +version = "3.13.0" description = "🤱🏻 Turn any webpage into a desktop app with Rust." authors = ["Tw93"] license = "GPL-3.0-or-later" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2d051714ac..184e93b690 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "productName": "Weekly", "identifier": "com.pake.weekly", - "version": "3.12.1", + "version": "3.13.0", "app": { "withGlobalTauri": true, "trayIcon": { From 5bb3133450cfd32199bba33301254f2e3ab1e98d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:45:58 +0000 Subject: [PATCH 119/120] chore: update contributors [skip ci] --- CONTRIBUTORS.svg | 175 +++++++++++++++++++++++++---------------------- 1 file changed, 93 insertions(+), 82 deletions(-) diff --git a/CONTRIBUTORS.svg b/CONTRIBUTORS.svg index 6431933a90..888224d6ea 100644 --- a/CONTRIBUTORS.svg +++ b/CONTRIBUTORS.svg @@ -1,4 +1,4 @@ - + @@ -333,13 +333,13 @@ - + - - - princemaple + + + 2nthony @@ -431,6 +431,17 @@ + + + + + + + + ekishion + + + @@ -441,7 +452,7 @@ geekvest - + @@ -452,7 +463,7 @@ lakca - + @@ -463,7 +474,7 @@ liudonghua123 - + @@ -474,7 +485,7 @@ liusishan - + @@ -485,7 +496,7 @@ piaoyidage - + @@ -496,7 +507,7 @@ enihsyou - + @@ -507,180 +518,180 @@ hetz - - - - - - - - - pgoslatara - - - + - - - Milo123459 + + + ACGNnsj - + - - - Jason6987 + + + kidylee - + - - - JohannLai + + + Bortlesboat - + - - - droid-Q + + + nekomeowww - + - - - ImgBotApp + + + kuishou68 - + - - - Fechin + + + turkyden - + - - - fvn-elmy + + + kud - + - - - kud + + + fvn-elmy - + - - - turkyden + + + Fechin - + - - - kuishou68 + + + ImgBotApp - + - - - nekomeowww + + + droid-Q - + - - - Bortlesboat + + + JohannLai - + - - - kidylee + + + Jason6987 - + - - - ACGNnsj + + + Milo123459 - + - - - 2nthony + + + pgoslatara + + + + + + + + + + + princemaple \ No newline at end of file From ccfd6b9a43a4b33de653004815012ad2b65444aa Mon Sep 17 00:00:00 2001 From: Tw93 Date: Sat, 27 Jun 2026 11:35:19 +0800 Subject: [PATCH 120/120] docs: condense support footer to a single line Merge the star/share and issue/PR bullets so the line no longer wraps on the GitHub repo page, where the About sidebar narrows the column. --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index a762120a72..1b214ea66e 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,7 @@ Pake's development can not be without these Hackers. They contributed a lot of c ## Support - The most direct way to support me is getting [Mole for Mac](https://mole.fit), my paid Mac cleanup app. -- If Pake helped you, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux) with friends or give it a star. -- Got ideas or bugs? Open an issue or PR, feel free to contribute your best AI model. +- If Pake helped you, give it a star, [share it](https://twitter.com/intent/tweet?url=https://github.com/tw93/Pake&text=Pake%20-%20Turn%20any%20webpage%20into%20a%20desktop%20app%20with%20one%20command.%20Nearly%2020x%20smaller%20than%20Electron%20packages,%20supports%20macOS%20Windows%20Linux), or open an issue or PR. - I have two cats, TangYuan and Coke. If you think Pake delights your life, you can feed them canned food 🥩.