diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..1e8dfe4 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# Boss Key pre-commit:文档与文案的 i18n 一致性检查。 +# +# 只在暂存区涉及 docs/、前端或 crates/ 时才跑对应检查,与本次提交无关的部分直接跳过。 +# 启用方式(每个克隆一次):git config core.hooksPath .githooks +# 确有需要时可跳过:git commit --no-verify + +set -e + +root=$(git rev-parse --show-toplevel) + +if command -v pwsh >/dev/null 2>&1; then + shell=pwsh +elif command -v powershell >/dev/null 2>&1; then + shell=powershell +else + echo "pre-commit:未找到 PowerShell,跳过 i18n 检查" >&2 + exit 0 +fi + +"$shell" -NoProfile -File "$root/scripts/i18n-check.ps1" -Staged diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index c1ed632..0970760 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -42,6 +42,12 @@ jobs: shell: pwsh run: ./scripts/version.ps1 check + # ---- 文案:引用的键是否存在、有无死键、Msg 是否登记进 ALL_MSGS ---- + # catalog 内部的键集 / 占位符由下面的 npm test 覆盖,这里跳过免得跑两遍。 + - name: i18n consistency check + shell: pwsh + run: ./scripts/i18n-check.ps1 -SkipVitest + # ---- 前端:依赖 / 单元测试 / 构建 ---- - name: Frontend install, test & build run: | diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 22c91a1..fa2b2c1 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -10,10 +10,11 @@ on: push: branches: ["main", "dev"] paths: - - "/docs/**" - - "/package.json" - - "/pnpm-lock.yaml" - - "/.github/workflows/deploy-docs.yml" + - "docs/**" + - "scripts/i18n-check.ps1" + - "package.json" + - "pnpm-lock.yaml" + - ".github/workflows/deploy-docs.yml" release: types: [published, edited, deleted] workflow_dispatch: @@ -52,8 +53,17 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # 三语文档配套与站内链接的一致性;跳过 catalog 单测(这里没装前端依赖, + # 那部分由 build-test.yml 的 npm test 覆盖)。 + - name: i18n consistency check + shell: pwsh + run: ./scripts/i18n-check.ps1 -SkipVitest + # 旧版客户端仍通过 Pages 上的 releases.json 检查更新,这里用 curl+jq(runner 自带,免安装) # 重新生成它,替代原来单独的 jekyll-gh-pages 工作流。 + # + # 必须滤掉草稿:Actions 的 token 有 push 权限,/releases 会把未发布的草稿一并返回, + # 不过滤就会把还没发的版本推给老客户端。 - name: Update releases.json env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -62,7 +72,9 @@ jobs: -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github.v3+json" \ "https://api.github.com/repos/${{ github.repository }}/releases" \ - | jq 'map({tag_name, published_at, body, assets: [.assets[] | {name, browser_download_url}]}) | sort_by(.published_at)' \ + | jq 'map(select(.draft == false) + | {tag_name, published_at, body, assets: [.assets[] | {name, browser_download_url}]}) + | sort_by(.published_at)' \ > docs/public/releases.json - name: Build with VitePress diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d489a4a..9ace456 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,42 +1,93 @@ name: Release -# 构建并发布一个已存在的 tag。 +# 构建并发布一个已随合并进入 main 的 tag。 # -# 两种进入方式: -# 1. 直接推送 v* tag(手动打的 tag); -# 2. 由 tag.yml 通过 workflow_call 调用 —— 用默认 GITHUB_TOKEN 推的 tag 不会触发 -# 上面的 push 事件,所以 tag.yml 必须显式调用本工作流,而不能指望 tag 推送。 +# 触发方式: +# 1. 推送到 main —— tag.yml 事先把版本号提交与 v* tag 落在 dev,等 dev → main 的 +# PR 合并,tag 随之进入 main 的历史,这里检测到就构建; +# 2. 手动触发(workflow_dispatch),指定 tag —— 用于重跑或补发。 +# +# 为什么不监听 push: tags:tag 是 tag.yml 用 GITHUB_TOKEN 推到 dev 的,那次推送 +# 不会触发任何工作流;而 PR 合并本身**不产生 tag 推送事件**(tag 是独立的 ref, +# 合并只是让它指向的提交变得可从 main 追溯)。所以只能从 main 的 push 事件里检测。 on: push: - tags: - - "v*" - workflow_call: + branches: ["main"] + workflow_dispatch: inputs: tag: - description: "要发布的 tag,例如 v3.0.1" + description: "要构建发布的 tag,例如 v3.0.1" required: true type: string -concurrency: - group: release-${{ inputs.tag || github.ref_name }} - cancel-in-progress: false - jobs: + # 找出「这次推送新带进 main 的 v* tag」。 + # 不能用「HEAD 上挂着的 tag」:merge commit 才是 HEAD,tag 指向的是它的父提交。 + detect: + runs-on: ubuntu-latest + outputs: + tag: ${{ steps.find.outputs.tag }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # 需要完整历史与全部 tag 才能算可达性 + + - name: Find newly merged release tag + id: find + env: + INPUT_TAG: ${{ inputs.tag }} + BEFORE: ${{ github.event.before }} + run: | + set -euo pipefail + + if [ -n "${INPUT_TAG}" ]; then + echo "手动指定:${INPUT_TAG}" + echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # 推送前 main 上已可追溯的 v* tag。新建分支 / 强推时 before 可能不可用, + # 那就当作「之前一个都没有」,让下面取到最新的一个。 + if [ -n "${BEFORE:-}" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then + git tag --merged "${BEFORE}" --list 'v*' | sort > /tmp/before.txt + else + : > /tmp/before.txt + fi + git tag --merged HEAD --list 'v*' | sort > /tmp/after.txt + + # 一次合并正常只带进一个;真有多个就取版本号最大的 + tag=$(comm -13 /tmp/before.txt /tmp/after.txt | sort -V | tail -n 1) + + if [ -z "$tag" ]; then + echo "本次推送没有新的 v* tag 进入 main,跳过发布" + else + echo "检测到新进入 main 的 tag:$tag" + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + build-and-release: + needs: detect + if: needs.detect.outputs.tag != '' runs-on: windows-latest permissions: contents: write id-token: write # attest-build-provenance 取 OIDC token attestations: write # 写入构建来源证明 + concurrency: + group: release-${{ needs.detect.outputs.tag }} + cancel-in-progress: false env: - TAG: ${{ inputs.tag || github.ref_name }} + TAG: ${{ needs.detect.outputs.tag }} steps: + # 构建 tag 指向的那个提交,而不是 main 的 merge commit: + # 前者才是版本号提交本身,与 version.ps1 check 的校验对象一致。 - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ inputs.tag || github.ref_name }} + ref: ${{ needs.detect.outputs.tag }} # tag 与代码里的版本号必须一致,否则装出来的包版本号是错的 - name: Verify version matches tag @@ -103,12 +154,29 @@ jobs: # /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 ` # "dist/Boss-Key/Boss Key.exe" dist/Boss-Key/config.exe dist/installer/Boss-Key-*-Setup.exe + # 发布说明 = GitHub 自动生成的更新日志 + 结尾的安全提示。 + # 创建 Release 时若同时传 body 和 generate_release_notes,自定义内容只会被 + # 放在自动生成内容**之前**,所以这里先调 generate-notes API 拿到更新日志, + # 再手动把安全提示拼在末尾。 + - name: Compose release notes + shell: pwsh + env: + GH_TOKEN: ${{ github.token }} + run: | + $notes = (gh api "repos/$env:GITHUB_REPOSITORY/releases/generate-notes" -f tag_name="$env:TAG" --jq '.body') -join "`n" + $notice = @' + > [!WARNING] + > 【安全提示】 + > 本软件中运用了大量的系统底层API,并且提供开机自启、热键监控、窗口冻结等功能,容易被杀毒软件识别为木马病毒,但是程序本身完全开源、免费,并不包含病毒、木马行为,请放心使用。请务必从官方渠道下载和使用程序,运行程序前通过文件哈希、文件签名等方式验证文件的真实性,谨防利用软件信任伪装的病毒程序。 + '@ + Set-Content -Path release-notes.md -Value ($notes + "`n`n" + $notice) -Encoding utf8 + - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: name: Boss Key ${{ env.TAG }} tag_name: ${{ env.TAG }} - generate_release_notes: true + body_path: release-notes.md draft: true prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }} make_latest: ${{ steps.version.outputs.is_prerelease != 'true' }} diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml index ba1930d..e72d502 100644 --- a/.github/workflows/tag.yml +++ b/.github/workflows/tag.yml @@ -1,9 +1,18 @@ name: Bump version and tag -# 手动触发的发版入口:填一个版本号,工作流会 +# 手动触发的发版准备。**请从 dev 分支触发**(bot 已在 dev 的分支保护里放行)。 +# +# 流程: # 1. 把版本号写进 Cargo.toml / tauri.conf.json / package.json / Cargo.lock; -# 2. 提交这次改动并推回当前分支,然后打上 v<版本号> 的 tag 并推送; -# 3. 调用 release.yml 构建并发布 GitHub Release。 +# 2. 提交到 dev 并打上 v<版本> tag,两者一起推送; +# 3. 确保有一个 dev → main 的 PR(没有就开一个)。 +# +# 这里**不构建**。用 GITHUB_TOKEN 推的 tag 不会触发任何工作流,正合本流程的意图: +# 等你把 PR 合并进 main、tag 随之进入 main 的历史,release.yml 才开始生产构建。 +# +# ⚠ 合并该 PR 时请用 **merge commit**(不要 squash / rebase): +# tag 指向 dev 上的那个提交,squash 会另造提交,tag 就进不了 main 的历史, +# release.yml 也就检测不到、不会构建。 on: workflow_dispatch: @@ -12,18 +21,34 @@ on: description: "要发布的版本号,例如 3.0.1 或 3.1.0-rc.1(可带前导 v)" required: true type: string + base: + description: "发版 PR 的目标分支" + required: false + default: main + type: string jobs: bump: runs-on: windows-latest permissions: - contents: write - outputs: - tag: ${{ steps.bump.outputs.tag }} + contents: write # 推版本号提交与 tag + pull-requests: write # 开发版 PR steps: - name: Checkout repository uses: actions/checkout@v4 + # 从 main 触发的话,下面的 PR 会变成 main → main + - name: Reject running from the base branch + shell: pwsh + env: + SOURCE: ${{ github.ref_name }} + BASE: ${{ inputs.base }} + run: | + if ($env:SOURCE -eq $env:BASE) { + Write-Host "::error::本工作流应从 dev 触发,当前分支 $env:SOURCE 与目标分支相同" + exit 1 + } + - name: Set up Rust uses: dtolnay/rust-toolchain@stable @@ -35,7 +60,7 @@ jobs: shell: pwsh run: ./scripts/version.ps1 apply "${{ inputs.version }}" - - name: Commit, tag and push + - name: Commit and tag id: bump shell: pwsh env: @@ -56,21 +81,53 @@ jobs: git add Cargo.toml Cargo.lock apps/config/src-tauri/tauri.conf.json apps/config/ui/package.json git commit -m "chore(release): $tag" - git tag $tag + if ($LASTEXITCODE -ne 0) { throw "提交版本号失败(版本号是否已经是 $env:VERSION?)" } + + # 带 message 的附注 tag(git describe 认它)。显式关掉签名: + # 机器人没有密钥,谁要是在本机跑这套命令,全局的 tag.gpgsign 会让裸 git tag 直接失败。 + git -c tag.gpgSign=false tag -a $tag -m "Boss Key $tag" + if ($LASTEXITCODE -ne 0) { throw "创建 tag $tag 失败" } git push origin "HEAD:$env:BRANCH" + if ($LASTEXITCODE -ne 0) { throw "推送 $env:BRANCH 失败(bot 是否已在分支保护里放行?)" } git push origin "refs/tags/$tag" + if ($LASTEXITCODE -ne 0) { throw "推送 tag $tag 失败(仓库是否配了 tag 保护规则?)" } "tag=$tag" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 - # 构建并发布刚刚推上去的 tag - release: - needs: bump - permissions: - contents: write - id-token: write - attestations: write - uses: ./.github/workflows/release.yml - secrets: inherit - with: - tag: ${{ needs.bump.outputs.tag }} + # 发版 PR 通常已经存在(你正准备把 dev 合进 main),没有才开一个。 + # 用 PAT 开的 PR 才会触发 build-test.yml;退回 GITHUB_TOKEN 时 PR 上没有检查记录, + # 但你点 Merge 触发的 push 事件仍会正常跑 build-test.yml。 + - name: Ensure release pull request + shell: pwsh + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT || secrets.GITHUB_TOKEN }} + TAG: ${{ steps.bump.outputs.tag }} + BASE: ${{ inputs.base }} + SOURCE: ${{ github.ref_name }} + run: | + $existing = gh pr list --base $env:BASE --head $env:SOURCE --state open --json url --jq '.[0].url' + if ($existing) { + Write-Host "已有 $env:SOURCE → $env:BASE 的 PR:$existing" + Write-Host "版本号提交与 tag $env:TAG 已经在这个 PR 里了" + exit 0 + } + + $body = @" + 由 ``tag.yml`` 自动创建。 + + - 版本号已写入 Cargo.toml / tauri.conf.json / package.json / Cargo.lock + - tag ``$env:TAG`` 已指向 ``$env:SOURCE`` 上的版本号提交 + + 合并后 tag 随之进入 ``$env:BASE`` 的历史,``release.yml`` 会检测到并开始生产构建。 + + 请用 **merge commit** 合并(不要 squash / rebase),否则 tag ``$env:TAG`` + 进不了 ``$env:BASE`` 的历史,构建不会触发。 + "@ + + # 经文件传正文:中文走命令行参数有被终端编码改写的风险,写成 UTF-8 文件更稳 + [System.IO.File]::WriteAllText("pr-body.md", $body, (New-Object System.Text.UTF8Encoding($false))) + + gh pr create --base $env:BASE --head $env:SOURCE ` + --title "release: $env:TAG" --body-file pr-body.md + if ($LASTEXITCODE -ne 0) { throw "创建发版 PR 失败" } diff --git a/Cargo.lock b/Cargo.lock index 88aa831..fbabbd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,19 +288,18 @@ dependencies = [ [[package]] name = "bosskey-common" -version = "3.0.0" +version = "3.1.0-rc.1" dependencies = [ "regex", "serde", "serde_json", "tempfile", "thiserror 2.0.18", - "ureq", ] [[package]] name = "bosskey-config" -version = "3.0.0" +version = "3.1.0-rc.1" dependencies = [ "bosskey-common", "bosskey-core", @@ -309,11 +308,13 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-single-instance", + "tempfile", + "verhub-sdk", ] [[package]] name = "bosskey-core" -version = "3.0.0" +version = "3.1.0-rc.1" dependencies = [ "bosskey-common", "serde", @@ -491,6 +492,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chrono" version = "0.4.45" @@ -528,29 +535,10 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ - "percent-encoding", "time", "version_check", ] -[[package]] -name = "cookie_store" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" -dependencies = [ - "cookie", - "document-features", - "idna", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "time", - "url", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -576,7 +564,7 @@ dependencies = [ "bitflags 2.13.0", "core-foundation", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -825,15 +813,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - [[package]] name = "dom_query" version = "0.27.0" @@ -1048,6 +1027,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1055,7 +1043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1069,6 +1057,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1564,6 +1558,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1955,12 +1965,6 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - [[package]] name = "lock_api" version = "0.4.14" @@ -2050,6 +2054,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2080,6 +2101,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2111,7 +2144,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 1.3.1", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -2240,6 +2273,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2318,6 +2352,49 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2334,6 +2411,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2713,50 +2805,72 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "futures-util", "http", "http-body", "http-body-util", "hyper", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-util", + "tokio-native-tls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", "web-sys", ] [[package]] -name = "ring" -version = "0.17.14" +name = "reqwest" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", ] [[package]] @@ -2787,21 +2901,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -2812,21 +2911,16 @@ dependencies = [ ] [[package]] -name = "rustls-webpki" -version = "0.103.13" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "rustversion" +name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2837,6 +2931,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2894,6 +2997,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3018,6 +3144,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -3226,12 +3364,6 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - [[package]] name = "swift-rs" version = "1.0.7" @@ -3384,7 +3516,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -3713,6 +3845,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3941,7 +4083,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4026,44 +4168,6 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "cookie_store", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4089,12 +4193,6 @@ dependencies = [ "url", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4113,6 +4211,28 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "verhub-sdk" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d327c305b7a92625c9bd30009a1ab80091c401c268b52c4d4576978aecb165" +dependencies = [ + "log", + "os_info", + "percent-encoding", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -4313,15 +4433,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webview2-com" version = "0.38.2" @@ -4594,15 +4705,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.59.0" diff --git a/Cargo.toml b/Cargo.toml index 437eb12..1a04cbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/common", "crates/core", "apps/config/src-tauri"] [workspace.package] -version = "3.0.0" +version = "3.1.0-rc.1" edition = "2024" authors = ["IvanHanloth"] license = "MIT" @@ -17,7 +17,7 @@ regex = "1.11.1" tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } windows = "0.62.2" tempfile = "3.27.0" -ureq = { version = "3.1.2", default-features = false, features = ["json", "rustls", "gzip"] } +verhub-sdk = { version = "0.2.6", default-features = false, features = ["native-tls"] } [profile.release] opt-level = "z" diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..04f54e9 --- /dev/null +++ b/README.en.md @@ -0,0 +1,116 @@ +

+ +![Boss-Key logo bannar](/docs/public/static/bannar.jpg) + +

+ +

Boss-Key

+ +

+ +Github Release Version +Github Repo License +GitHub Actions Workflow Status +Supported Platform + +

+ +
+

+ Website + + Guide + + Development + + Download +

+
+ +

+ 简体中文 + + English + + 繁體中文 +

+ +
+ Boss coming? Hide your windows with a single key.
+ +Hide multiple windows and processes, customise hotkeys, hide the active window, mute windows, pause video playback, freeze processes, and more. + +Highly configurable to fit how you work, and very light: about 1 MB of memory while resident in the background. + +

+ +## Screenshots + +![Boss-Key settings – window binding page](/docs/public/static/screenshot-1.png) + +![Boss-Key settings – hotkeys and mouse page 1](/docs/public/static/screenshot-2.png) + +![Boss-Key settings – hotkeys and mouse page 2](/docs/public/static/screenshot-3.png) + +![Boss-Key settings – other options page](/docs/public/static/screenshot-4.png) + +## Getting started + +Since v3.0.0 every release ships two kinds of package, both available from the [Releases page](https://github.com/IvanHanloth/Boss-Key/releases): + +- **installer** (recommended) — a fully packaged installer providing one-click install, update and uninstall, for managing Boss-Key more efficiently and safely. +- **portable** — an archive containing the Boss-Key core and settings programs; extract it and run. + +Some releases provide a package for Windows 7; those marked `win7` run on Windows 7. + +For the complete illustrated documentation, see the Boss-Key [guide](https://boss-key.ivan-hanloth.cn/en/guide/). + +### Basics + +The first time you open Boss-Key after installing or updating, the settings window appears automatically, where you can change hotkeys and bind processes and windows. + +Day to day, right-click the tray icon to open the menu, and choose "Settings" to open the settings window. + +The tray menu also offers exiting the program, checking for updates, and toggling startup with Windows. + +Press the hide / show hotkey to hide the bound windows at once. Press the close hotkey to close Boss-Key. + +### Binding windows + +Binding several windows lets you hide them all at once. + +In the upper part of the settings window, the left list holds the windows currently open and the right list holds the ones already bound. + +Select a window to hide on the left and add it to the right; likewise, select a rule you no longer need on the right and remove it. + +If a newly opened window is missing from the list, refresh the left-hand list. + +### Changing hotkeys + +Click the record button and press the combination you want; it is recognised and filled in automatically. Clearing a hotkey disables it. + +### Mouse triggers + +Boss-Key supports hiding via the middle mouse button and side buttons 1 and 2, optionally with a click count and modifier keys. + +You can also enable hiding by moving the pointer quickly into a screen corner (turn on corner restore to allow restoring the same way). + +### Display language + +The settings window and the core's tray menu and notifications are available in **Simplified Chinese, English and Traditional Chinese**. The language follows the system display language by default, and can be set explicitly under Options → Language. + +### More + +For the full feature list and usage guide, see the Boss-Key [guide](https://boss-key.ivan-hanloth.cn/en/guide/). + +## Development and contributing + +For details on development and contributing, see the Boss-Key [development docs](https://boss-key.ivan-hanloth.cn/en/dev/). + +## Credits + +Thanks to HsFreezer for the approach to process freezing. + +## Changelog + +For the full changelog, see the Boss-Key [changelog](https://boss-key.ivan-hanloth.cn/en/changelog/). diff --git a/README.md b/README.md index 93ad361..303d479 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Github Release Version Github Repo License -GitHub Actions Workflow Status +GitHub Actions Workflow Status Supported Platform

@@ -27,6 +27,14 @@ +

+ 简体中文 + + English + + 繁體中文 +

+
老板来了?快用Boss-Key老板键一键隐藏窗口!上班摸鱼必备神器。
@@ -65,7 +73,7 @@ 右键点击托盘图标还有退出程序、检查更新、设置开机自启等功能。 -按下隐藏/显示窗口热键可以一键隐藏所绑定的窗口。按下一键关闭程序热键可以一键关闭Boss-Key程序 +按下隐藏/显示窗口热键可以一键隐藏所绑定的窗口。按下关闭核心热键可以一键关闭Boss-Key程序 ### 绑定窗口 @@ -89,6 +97,10 @@ v2.1.0版本加入了鼠标相关操作隐藏绑定,可以选择鼠标中键 可以勾选快速移动鼠标至四角隐藏窗口(启用允许移动恢复功能以允许通过快速移动鼠标至四角恢复窗口) +### 界面语言 + +配置界面与核心的托盘菜单、通知支持**简体中文、English、繁體中文**。默认跟随系统显示语言,也可在「其他选项 → 语言」中手动指定。 + ### 更多功能 完整功能介绍及使用指南,请参阅 Boss-Key [使用文档](https://boss-key.ivan-hanloth.cn/guide) diff --git a/README.zh-TW.md b/README.zh-TW.md new file mode 100644 index 0000000..448793e --- /dev/null +++ b/README.zh-TW.md @@ -0,0 +1,116 @@ +

+ +![Boss-Key logo bannar](/docs/public/static/bannar.jpg) + +

+ +

Boss-Key

+ +

+ +Github Release Version +Github Repo License +GitHub Actions Workflow Status +Supported Platform + +

+ +
+

+ 專案官網 + + 使用說明 + + 開發文件 + + 下載位址 +

+
+ +

+ 简体中文 + + English + + 繁體中文 +

+ +
+ 老闆來了?快用 Boss-Key 老闆鍵一鍵隱藏視窗!上班摸魚必備神器。
+ +支援多視窗隱藏、多程序隱藏、自訂快速鍵、隱藏使用中視窗、靜音視窗、暫停影片播放、程序凍結等超多功能。 + +超高自訂程度,滿足您的不同隱藏需求。極簡記憶體,背景常駐僅 1M 記憶體佔用。 + +

+ +## 應用程式螢幕擷取畫面 + +![Boss-Key 設定視窗綁定頁](/docs/public/static/screenshot-1.png) + +![Boss-Key 設定滑鼠快速鍵設定頁-1](/docs/public/static/screenshot-2.png) + +![Boss-Key 設定滑鼠快速鍵設定頁-2](/docs/public/static/screenshot-3.png) + +![Boss-Key 設定視窗其他選項頁](/docs/public/static/screenshot-4.png) + +## 使用說明 + +從 v3.0.0 版本開始,每個版本都會提供兩種類型的程式,可以從 [Release 頁面](https://github.com/IvanHanloth/Boss-Key/releases) 下載: + +- installer - 安裝程式(建議),完整封裝的 Boss-Key 程式安裝程式,提供一鍵安裝、更新、解除安裝,可以更有效率且安全地管理 Boss-Key 程式 +- portable - 可攜版,包含 Boss-Key 的核心程式和設定程式的壓縮檔,解壓縮後可以執行 + +部分版本會提供 Windows 7 系統的軟體包,帶有 win7 標示的可以在 Windows 7 系統上執行。 + +完整的圖文使用說明,請參閱 Boss-Key [使用說明](https://boss-key.ivan-hanloth.cn/zh-tw/guide/)。 + +### 基礎使用 + +安裝或更新後首次開啟 Boss-Key,會自動開啟設定頁面,可以在其中進行快速鍵修改、程序及視窗綁定等操作。 + +而一般使用時,可以透過以滑鼠右鍵按一下通知區域圖示開啟選單。按一下選單中的「設定」即可開啟設定頁面。 + +以滑鼠右鍵按一下通知區域圖示還有結束程式、檢查更新、設定開機自動啟動等功能。 + +按下隱藏/顯示視窗快速鍵可以一鍵隱藏所綁定的視窗。按下關閉核心快速鍵可以一鍵關閉 Boss-Key 程式。 + +### 綁定視窗 + +透過綁定視窗,可以同時隱藏多個視窗,摸魚更安全~ + +設定視窗中上方部分,左邊清單是目前存在的視窗,右邊清單是已經綁定的視窗。 + +在左邊清單中選取希望隱藏的視窗,按一下「新增」可以將視窗資訊加入右邊。同理,在右邊清單中選擇不需要綁定的規則,按一下「移除」即可刪除。 + +如果發現新開啟的視窗沒有在清單中顯示,可以按一下「重新整理」按鈕,重新整理左邊的清單。 + +### 修改快速鍵 + +按一下「錄製」按鈕,開啟快速鍵錄製視窗進行錄製;按下的組合鍵將被記錄並自動填入。按一下「清除」可清空該快速鍵。 + +### 滑鼠隱藏 + +Boss-Key 支援以滑鼠中鍵、側鍵 1、側鍵 2 切換隱藏狀態,並可搭配連按次數與輔助按鍵。 + +也可以啟用快速移動滑鼠至四角隱藏視窗(啟用角落復原功能以允許透過快速移動滑鼠至四角復原視窗)。 + +### 介面語言 + +設定介面與核心的通知區域選單、通知支援**簡體中文、English、繁體中文**。預設跟隨系統顯示語言,也可在「其他選項 → 語言」中手動指定。 + +### 更多功能 + +完整功能介紹及使用指南,請參閱 Boss-Key [使用說明](https://boss-key.ivan-hanloth.cn/zh-tw/guide/)。 + +## 開發及貢獻指南 + +有關開發和貢獻的詳細資訊,請參閱 Boss-Key [開發文件](https://boss-key.ivan-hanloth.cn/zh-tw/dev/)。 + +## 鳴謝 + +感謝雪藏 HsFreezer 提供的程序凍結實作思路。 + +## 更新日誌 + +完整的更新日誌請參閱 Boss-Key [更新日誌](https://boss-key.ivan-hanloth.cn/zh-tw/changelog/)。 diff --git a/apps/config/dist/index.html b/apps/config/dist/index.html deleted file mode 100644 index 5aa12da..0000000 --- a/apps/config/dist/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - Boss Key 设置 - - - - - - - -
- -
- -
-
Boss Key 正在启动…
-
- - - diff --git a/apps/config/src-tauri/Cargo.toml b/apps/config/src-tauri/Cargo.toml index 7b5bd84..2962491 100644 --- a/apps/config/src-tauri/Cargo.toml +++ b/apps/config/src-tauri/Cargo.toml @@ -20,3 +20,7 @@ serde_json = { workspace = true } bosskey-common = { path = "../../../crates/common" } bosskey-core = { path = "../../../crates/core" } tauri-plugin-single-instance = "2.4.2" +verhub-sdk = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/apps/config/src-tauri/src/lib.rs b/apps/config/src-tauri/src/lib.rs index 7f4895b..4fbb4f7 100644 --- a/apps/config/src-tauri/src/lib.rs +++ b/apps/config/src-tauri/src/lib.rs @@ -4,10 +4,12 @@ use std::time::Duration; use bosskey_common::Config; use bosskey_common::ipc::{Command, PipeClient, Response}; use bosskey_common::model::WindowInfo; -use bosskey_common::verhub; +use bosskey_core::i18n::{self, Msg}; use serde::Serialize; use tauri::{Emitter, Manager}; +mod verhub; + const CORE_EXE: &str = "Boss Key.exe"; fn exe_dir() -> PathBuf { @@ -27,7 +29,7 @@ fn core_exe_path() -> Result { if exe.exists() { Ok(exe) } else { - Err(format!("未找到核心程序 {CORE_EXE}")) + Err(i18n::tf(Msg::ErrCoreExeMissing, &[("exe", CORE_EXE)])) } } @@ -72,14 +74,17 @@ struct AppInfo { #[tauri::command] fn load_config() -> Result { - Config::load(&config_path()).map_err(|e| e.to_string()) + let config = Config::load(&config_path()).map_err(|e| e.to_string())?; + i18n::set_from_pref(&config.setting.language); + Ok(config) } #[tauri::command] async fn save_config(config: Config) -> Result<(), String> { blocking(move || { + i18n::set_from_pref(&config.setting.language); config.save(&config_path()).map_err(|e| e.to_string())?; - // 通知核心热重载;核心未运行时忽略错误。 + // 通知核心热重载;核心据此同步语言。核心未运行时忽略错误。 let _ = notify_core(&Command::ReloadConfig); Ok(()) }) @@ -119,10 +124,25 @@ async fn show_all_windows() -> Result<(), String> { blocking(|| notify_core(&Command::Show).map(|_| ())).await } -/// 恢复显示指定窗口(窗口恢复工具):直接对选中的句柄 ShowWindow。 +/// 把恢复工具的窗口操作交给核心执行;核心未应答(未运行 / 出错)时返回 false, +/// 由调用方退回直接操作。 +fn try_core_window_op(command: &Command) -> bool { + matches!( + PipeClient::connect_default().fast().send(command), + Ok(Response::Ok) + ) +} + +/// 恢复显示指定窗口(窗口恢复工具)。优先经核心释放并更新记录; +/// 核心不在运行才直接对句柄 ShowWindow。 #[tauri::command] async fn show_windows(hwnds: Vec) { blocking(move || { + if try_core_window_op(&Command::ReleaseWindows { + hwnds: hwnds.clone(), + }) { + return; + } use bosskey_core::platform::WindowManager; let mgr = bosskey_core::platform::manager(); for h in hwnds { @@ -132,10 +152,16 @@ async fn show_windows(hwnds: Vec) { .await } -/// 隐藏指定窗口(窗口恢复工具):直接对选中的句柄隐藏。 +/// 隐藏指定窗口(窗口恢复工具)。优先经核心纳入隐藏记录(不施加静音 / 冻结); +/// 核心不在运行才直接对句柄隐藏。 #[tauri::command] async fn hide_windows(hwnds: Vec) { blocking(move || { + if try_core_window_op(&Command::AdoptWindows { + hwnds: hwnds.clone(), + }) { + return; + } use bosskey_core::platform::WindowManager; let mgr = bosskey_core::platform::manager(); for h in hwnds { @@ -193,8 +219,18 @@ async fn run_freeze( if failed == 0 { Ok(()) } else { - let action = if suspend { "冻结" } else { "解冻" }; - Err(format!("{failed}/{} 个进程{action}失败", targets.len())) + let msg = if suspend { + Msg::ErrFreezePartial + } else { + Msg::ErrResumePartial + }; + Err(i18n::tf( + msg, + &[ + ("failed", &failed.to_string()), + ("total", &targets.len().to_string()), + ], + )) } }) .await @@ -246,6 +282,8 @@ struct CoreStatus { elevated: bool, /// 核心是否正在监听热键与鼠标(由核心回报)。 monitoring: bool, + /// 自动隐藏当前是否启用(托盘菜单也可切换,须回读对齐界面)。 + auto_hide_enabled: bool, } const CORE_OFFLINE: CoreStatus = CoreStatus { @@ -253,6 +291,7 @@ const CORE_OFFLINE: CoreStatus = CoreStatus { hidden: false, elevated: false, monitoring: false, + auto_hide_enabled: false, }; /// 核心状态:单次管道往返 + 快速失败(核心未运行时立即返回,不重试)。 @@ -267,11 +306,13 @@ async fn core_status() -> CoreStatus { hidden, elevated, monitoring, + auto_hide_enabled, }) => CoreStatus { running: true, hidden, elevated, monitoring, + auto_hide_enabled, }, _ => CORE_OFFLINE, } @@ -373,28 +414,38 @@ fn app_info() -> AppInfo { } } -/// 用系统默认浏览器打开外部链接。仅放行 http/https。 +/// 用系统默认浏览器打开外部链接。仅放行 http/https/mailto(与前端 markdown 白名单一致)。 #[tauri::command] async fn open_external(url: String) -> Result<(), String> { - if !url.starts_with("https://") && !url.starts_with("http://") { - return Err("只允许打开 http/https 链接".to_string()); + if !url.starts_with("https://") && !url.starts_with("http://") && !url.starts_with("mailto:") { + return Err(i18n::t(Msg::ErrUrlSchemeNotAllowed).to_string()); } blocking(move || bosskey_core::shell::open(&url)).await } +/// 项目公开链接(主页 / 仓库 / 文档等)。带缓存(内存 + exe 同目录磁盘文件, +/// 有效期一天),过期才请求 Verhub;请求失败退回过期缓存。 +#[tauri::command] +async fn verhub_project_links() -> Result { + verhub::project_links(&exe_dir().join("verhub_cache.json")) + .await + .map_err(|e| e.to_string()) +} + /// 检查更新。`required=true` 即强制更新,界面须阻断使用。 #[tauri::command] async fn verhub_check_update(include_preview: bool) -> Result { - blocking(move || { - verhub::check_update(env!("CARGO_PKG_VERSION"), include_preview).map_err(|e| e.to_string()) - }) - .await + verhub::check_update(env!("CARGO_PKG_VERSION"), include_preview) + .await + .map_err(|e| e.to_string()) } /// 公告列表(本平台可见的,从新到旧)。 #[tauri::command] async fn verhub_announcements(limit: u32) -> Result, String> { - blocking(move || verhub::announcements(limit.clamp(1, 50)).map_err(|e| e.to_string())).await + verhub::announcements(limit.clamp(1, 50)) + .await + .map_err(|e| e.to_string()) } /// `contact` 可空——留了才好回复用户。 @@ -405,40 +456,28 @@ async fn verhub_submit_feedback( contact: String, ) -> Result<(), String> { if content.trim().is_empty() { - return Err("请先填写反馈内容".to_string()); + return Err(i18n::t(Msg::ErrFeedbackEmpty).to_string()); } - blocking(move || { - let feedback = verhub::Feedback { - rating: rating.map(|r| r.clamp(1, 5)), - content, - platform: verhub::PLATFORM, - custom_data: serde_json::json!({ - "app_version": env!("CARGO_PKG_VERSION"), - "os": os_description(), - "contact": contact.trim(), - }), - }; - verhub::submit_feedback(&feedback).map_err(|e| e.to_string()) - }) - .await + let custom_data = serde_json::json!({ + "app_version": env!("CARGO_PKG_VERSION"), + "os": os_description(), + "contact": contact.trim(), + }); + verhub::submit_feedback(content, rating.map(|r| r.clamp(1, 5)), custom_data) + .await + .map_err(|e| e.to_string()) } /// 上报一段日志 #[tauri::command] async fn verhub_upload_log(content: String) -> Result<(), String> { - blocking(move || { - verhub::upload_log( - verhub::LogLevel::Error, - &content, - serde_json::json!({ - "app_version": env!("CARGO_PKG_VERSION"), - "os": os_description(), - - }), - ) + let device_info = serde_json::json!({ + "app_version": env!("CARGO_PKG_VERSION"), + "os": os_description(), + }); + verhub::upload_log(&content, device_info) + .await .map_err(|e| e.to_string()) - }) - .await } /// 最新日志文件的末尾若干行。 @@ -519,6 +558,7 @@ pub fn run() { startup_action, app_info, open_external, + verhub_project_links, verhub_check_update, verhub_announcements, verhub_submit_feedback, diff --git a/apps/config/src-tauri/src/verhub.rs b/apps/config/src-tauri/src/verhub.rs new file mode 100644 index 0000000..f9998e7 --- /dev/null +++ b/apps/config/src-tauri/src/verhub.rs @@ -0,0 +1,370 @@ +//! Verhub 客户端:版本 / 公告 / 反馈 / 日志 / 项目链接,基于官方 verhub-sdk。 +//! +//! 只用公开端点(无需凭据)。HTTP 由 SDK 完成;本模块把 SDK 的响应类型映射成 +//! 前端 IPC 契约所需的可序列化 DTO,字段名保持不变。 + +use std::path::Path; +use std::sync::Mutex; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; +use verhub_sdk::VerhubClient; +use verhub_sdk::models::{ + AnnouncementItem, CheckUpdateInput, CreateFeedbackInput, JsonObject, ListAnnouncementsOptions, + LogLevel, Platform, ProjectItem, UploadLogInput, VersionDownloadLink, VersionItem, +}; + +/// Verhub 基础路径。 +pub const BASE_URL: &str = "https://verhub.hanloth.cn/api/v1"; +pub const PROJECT_KEY: &str = "ivanhanloth-boss-key"; +/// 客户端平台(本程序只发行 Windows 版)。 +pub const PLATFORM: Platform = Platform::Windows; + +const TIMEOUT: Duration = Duration::from_secs(10); +const LOG_CONTENT_MAX: usize = 4096; + +type Result = verhub_sdk::Result; + +/// 构造公开接口客户端;User-Agent 追加 `BossKey/{版本}` 以便服务端识别。 +fn client() -> Result { + VerhubClient::builder(BASE_URL) + .project_key(PROJECT_KEY) + .platform(PLATFORM) + .timeout(TIMEOUT) + .app_identifier(concat!("BossKey/", env!("CARGO_PKG_VERSION"))) + .build() +} + +/// 把 `serde_json::Value` 收敛为 JSON 对象;非对象一律丢弃。 +fn json_object(value: serde_json::Value) -> Option { + match value { + serde_json::Value::Object(map) => Some(map), + _ => None, + } +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct DownloadLink { + pub url: String, + pub name: Option, + pub platform: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Version { + pub id: String, + pub version: String, + pub comparable_version: String, + pub title: Option, + /// 更新说明(Markdown)。 + pub content: Option, + pub download_url: Option, + pub download_links: Vec, + pub forced: bool, + pub is_latest: bool, + pub is_preview: bool, + pub is_milestone: bool, + pub is_deprecated: bool, + pub published_at: i64, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct CheckUpdate { + pub should_update: bool, + /// 强制更新。 + pub required: bool, + pub reason_codes: Vec, + pub current_version: Option, + pub latest_version: Option, + /// 该升到哪个版本(可能是里程碑版本,而非最新版)。 + pub target_version: Option, +} + +#[derive(Debug, Clone, Default, Serialize)] +pub struct Announcement { + pub id: String, + pub title: String, + pub content: String, + pub is_pinned: bool, + pub is_hidden: bool, + pub author: Option, + pub published_at: i64, +} + +fn map_link(link: VersionDownloadLink) -> DownloadLink { + DownloadLink { + url: link.url, + name: link.name, + platform: link.platform, + } +} + +fn map_version(version: VersionItem) -> Version { + Version { + id: version.id, + version: version.version, + comparable_version: version.comparable_version, + title: version.title, + content: version.content, + download_url: version.download_url, + download_links: version.download_links.into_iter().map(map_link).collect(), + forced: version.forced, + is_latest: version.is_latest, + is_preview: version.is_preview, + is_milestone: version.is_milestone, + is_deprecated: version.is_deprecated, + published_at: version.published_at, + } +} + +fn map_announcement(item: AnnouncementItem) -> Announcement { + Announcement { + id: item.id, + title: item.title, + content: item.content, + is_pinned: item.is_pinned, + is_hidden: item.is_hidden, + author: item.author, + published_at: item.published_at, + } +} + +/// 检查更新:把当前版本发给 Verhub,由服务端判断是否需要更新、是否强制。 +pub async fn check_update(current_version: &str, include_preview: bool) -> Result { + let input = CheckUpdateInput { + current_version: Some(current_version.to_string()), + current_comparable_version: Some(current_version.to_string()), + include_preview: Some(include_preview), + }; + let resp = client()?.public().check_update(&input).await?; + Ok(CheckUpdate { + should_update: resp.should_update, + required: resp.required, + reason_codes: resp.reason_codes, + current_version: resp.current_version, + latest_version: Some(map_version(resp.latest_version)), + target_version: resp.target_version.map(map_version), + }) +} + +/// 公告列表(只要本平台 / 全平台的),从新到旧,并滤掉隐藏公告。 +pub async fn announcements(limit: u32) -> Result> { + let options = ListAnnouncementsOptions { + limit: Some(limit), + platform: Some(PLATFORM), + ..Default::default() + }; + let resp = client()?.public().list_announcements(&options).await?; + Ok(resp + .data + .into_iter() + .filter(|a| !a.is_hidden) + .map(map_announcement) + .collect()) +} + +/// 提交客户端反馈。`rating` 为 1..=5;`custom_data` 携带附加信息。 +pub async fn submit_feedback( + content: String, + rating: Option, + custom_data: serde_json::Value, +) -> Result<()> { + let input = CreateFeedbackInput { + content, + rating, + platform: Some(PLATFORM), + custom_data: json_object(custom_data), + ..Default::default() + }; + client()?.public().create_feedback(&input).await?; + Ok(()) +} + +/// 上报一条错误日志;内容超长会被截断到 Verhub 的上限内。 +pub async fn upload_log(content: &str, device_info: serde_json::Value) -> Result<()> { + let input = UploadLogInput { + level: LogLevel::Error.into(), + content: truncate_log(content), + device_info: json_object(device_info), + custom_data: None, + }; + client()?.public().upload_log(&input).await?; + Ok(()) +} + +/// 项目公开链接(主页 / 仓库 / 文档等)的缓存有效期。链接极少变动,一天刷新一次足够。 +const PROJECT_CACHE_TTL_SECS: i64 = 24 * 60 * 60; + +/// 项目公开链接。所有字段都可能缺省(Verhub 上未填写);前端须自备回退链接。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct ProjectLinks { + pub name: Option, + pub website_url: Option, + pub repo_url: Option, + pub docs_url: Option, + pub author: Option, + pub author_homepage_url: Option, + /// 拉取时刻(Unix 秒),用于判断缓存新鲜度。 + pub fetched_at: i64, +} + +/// 进程内缓存,避免每次都读盘。 +static PROJECT_CACHE: Mutex> = Mutex::new(None); + +fn map_project(item: ProjectItem, fetched_at: i64) -> ProjectLinks { + ProjectLinks { + name: Some(item.name), + website_url: item.website_url, + repo_url: item.repo_url, + docs_url: item.docs_url, + author: item.author, + author_homepage_url: item.author_homepage_url, + fetched_at, + } +} + +/// 缓存是否仍在有效期内。`fetched_at` 在未来(时钟回拨)按过期处理。 +fn cache_fresh(links: &ProjectLinks, now: i64) -> bool { + (0..PROJECT_CACHE_TTL_SECS).contains(&(now - links.fetched_at)) +} + +fn cache_get() -> Option { + PROJECT_CACHE.lock().ok()?.clone() +} + +fn cache_put(links: ProjectLinks) { + if let Ok(mut cache) = PROJECT_CACHE.lock() { + *cache = Some(links); + } +} + +/// 读磁盘缓存;文件不存在或损坏一律当作没有缓存。 +fn read_cache_file(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() +} + +/// 写磁盘缓存;尽力而为,写失败只影响下次冷启动的命中率。 +fn write_cache_file(path: &Path, links: &ProjectLinks) { + if let Ok(json) = serde_json::to_string_pretty(links) { + let _ = std::fs::write(path, json); + } +} + +fn unix_now() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// 项目公开链接:内存缓存 → 磁盘缓存(`cache_path`)→ Verhub API 逐级回退。 +/// API 拉取失败时退回过期缓存(有旧数据总比没有强),完全没有缓存才报错。 +pub async fn project_links(cache_path: &Path) -> Result { + let now = unix_now(); + if let Some(cached) = cache_get() + && cache_fresh(&cached, now) + { + return Ok(cached); + } + if let Some(cached) = read_cache_file(cache_path) + && cache_fresh(&cached, now) + { + cache_put(cached.clone()); + return Ok(cached); + } + match client()?.public().get_project().await { + Ok(item) => { + let links = map_project(item, now); + write_cache_file(cache_path, &links); + cache_put(links.clone()); + Ok(links) + } + Err(err) => match cache_get().or_else(|| read_cache_file(cache_path)) { + Some(stale) => Ok(stale), + None => Err(err), + }, + } +} + +/// 截到上限以内,按字符边界切以避免切碎多字节字符。 +fn truncate_log(content: &str) -> String { + if content.len() <= LOG_CONTENT_MAX { + return content.to_string(); + } + const MARK: &str = "…(日志过长,已截断前半部分)\n"; + let budget = LOG_CONTENT_MAX - MARK.len(); + // 保留末尾(出错现场)。 + let start = content.len() - budget; + let start = (start..content.len()) + .find(|i| content.is_char_boundary(*i)) + .unwrap_or(content.len()); + format!("{MARK}{}", &content[start..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_keeps_tail_within_limit() { + let long = "错误".repeat(4000); // 远超 4096 字节 + let out = truncate_log(&long); + assert!( + out.len() <= LOG_CONTENT_MAX, + "截断后仍超上限: {}", + out.len() + ); + assert!(out.contains("已截断")); + assert!(out.ends_with('误')); // 保的是末尾(出错现场) + } + + #[test] + fn truncate_leaves_short_content_alone() { + assert_eq!(truncate_log("崩了"), "崩了"); + } + + #[test] + fn log_level_maps_to_verhub_numbers() { + assert_eq!(u8::from(LogLevel::Debug), 0); + assert_eq!(u8::from(LogLevel::Error), 3); + } + + #[test] + fn cache_fresh_within_ttl_only() { + let links = ProjectLinks { + fetched_at: 1_000_000, + ..Default::default() + }; + assert!(cache_fresh(&links, 1_000_000)); // 刚拉取 + assert!(cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS - 1)); + assert!(!cache_fresh(&links, 1_000_000 + PROJECT_CACHE_TTL_SECS)); // 到期 + assert!(!cache_fresh(&links, 999_999)); // 时钟回拨 + } + + #[test] + fn cache_file_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("verhub_cache.json"); + let links = ProjectLinks { + name: Some("Boss Key".into()), + website_url: Some("https://example.com/".into()), + fetched_at: 42, + ..Default::default() + }; + write_cache_file(&path, &links); + let read = read_cache_file(&path).expect("缓存应可读回"); + assert_eq!(read.name.as_deref(), Some("Boss Key")); + assert_eq!(read.website_url.as_deref(), Some("https://example.com/")); + assert_eq!(read.fetched_at, 42); + } + + #[test] + fn cache_file_tolerates_missing_and_corrupt() { + let dir = tempfile::tempdir().unwrap(); + assert!(read_cache_file(&dir.path().join("absent.json")).is_none()); + let bad = dir.path().join("bad.json"); + std::fs::write(&bad, "not json{{").unwrap(); + assert!(read_cache_file(&bad).is_none()); + } +} diff --git a/apps/config/src-tauri/tauri.conf.json b/apps/config/src-tauri/tauri.conf.json index 845265a..aacf6f5 100644 --- a/apps/config/src-tauri/tauri.conf.json +++ b/apps/config/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Boss Key", - "version": "3.0.0", + "version": "3.1.0-rc.1", "identifier": "cn.hanloth.bosskey.config", "build": { "frontendDist": "../dist", @@ -12,7 +12,7 @@ "windows": [ { "label": "main", - "title": "Boss Key 设置", + "title": "Boss Key", "width": 920, "height": 660, "minWidth": 720, diff --git a/apps/config/ui/index.html b/apps/config/ui/index.html index d6a42db..d22f051 100644 --- a/apps/config/ui/index.html +++ b/apps/config/ui/index.html @@ -4,7 +4,7 @@ - Boss Key 设置 + Boss Key + +
e.preventDefault()}> + {@html html} +
+ + diff --git a/apps/config/ui/src/components/Modal.svelte b/apps/config/ui/src/components/Modal.svelte index b85a690..38957d4 100644 --- a/apps/config/ui/src/components/Modal.svelte +++ b/apps/config/ui/src/components/Modal.svelte @@ -1,4 +1,5 @@
-

托盘通知

- +

{t("notify.card")}

+ {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet}
+ +
+

{t("notify.trayCard")}

+ {#each BADGE_COLORS as color (color.key)} + + {#snippet control()} +
+ + +
+ {/snippet} +
+ {/each} +
{t("notify.trayPriorityNote")}
+ + {#snippet control()}{/snippet} + +
diff --git a/apps/config/ui/src/components/OptionsPanel.svelte b/apps/config/ui/src/components/OptionsPanel.svelte index 33a3f8b..b6cd291 100644 --- a/apps/config/ui/src/components/OptionsPanel.svelte +++ b/apps/config/ui/src/components/OptionsPanel.svelte @@ -3,6 +3,7 @@ import Toggle from "./Toggle.svelte"; import { app, openRestoreTool, restartCore, startCore, setAutostart, refreshPssuspend, toast } from "../lib/state.svelte.js"; import { openExternal } from "../lib/verhub.js"; + import { LANGS, LANG_AUTO, LANG_NAMES, t } from "../lib/i18n.svelte.js"; let elevating = $state(false); @@ -10,7 +11,7 @@ try { await openExternal(url); } catch (err) { - toast("打开链接失败:" + err, true); + toast(t("options.openLinkFailed", { err }), true); } } @@ -26,14 +27,14 @@ const s = $derived(app.config.setting); - // 自启注册方式的中文标注;仅在已开启自启时显示。 + // 自启注册方式的标注;仅在已开启自启时显示。 const autostartMethodText = $derived( !app.autostart ? "" : app.autostartMethod === "task" - ? "计划任务" + ? t("options.methodTask") : app.autostartMethod === "registry" - ? "注册表" + ? t("options.methodRegistry") : "", ); @@ -51,9 +52,9 @@ // 增强冻结的前置条件;任一不满足即置灰。 const enhancedBlocked = $derived.by(() => { const reasons = []; - if (!app.status.running) reasons.push("核心未运行"); - else if (!app.status.elevated) reasons.push("核心需以管理员身份运行"); - if (!app.pssuspend) reasons.push("程序目录缺少 pssuspend64.exe"); + if (!app.status.running) reasons.push(t("options.blockedCoreStopped")); + else if (!app.status.elevated) reasons.push(t("options.blockedNeedAdmin")); + if (!app.pssuspend) reasons.push(t("options.blockedNoPssuspend")); return reasons; }); const enhancedDisabled = $derived(enhancedBlocked.length > 0); @@ -61,20 +62,20 @@
-

常规

- +

{t("options.generalCard")}

+ {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} - + {#snippet control()}{/snippet} {#if search} - {/if} @@ -105,11 +107,11 @@
{#if windows.length === 0} -

(空)

+

{t("common.empty")}

{:else} {@render section(parts.visible, null)} {#if parts.hidden.length} - {@render section(parts.hidden, "后台 / 已隐藏窗口")} + {@render section(parts.hidden, t("windowList.hiddenSection"))} {/if} {/if}
@@ -133,7 +135,7 @@ {#each group.windows as w (w.hwnd + "-" + w.process)} {/each} diff --git a/apps/config/ui/src/components/WindowRuleList.svelte b/apps/config/ui/src/components/WindowRuleList.svelte index 6ab68d4..7cab29c 100644 --- a/apps/config/ui/src/components/WindowRuleList.svelte +++ b/apps/config/ui/src/components/WindowRuleList.svelte @@ -5,15 +5,16 @@ import IconRegex from "~icons/lucide/regex"; import IconAppWindow from "~icons/lucide/app-window"; import ScopeSelect from "./ScopeSelect.svelte"; - import { isRegexRule, traceWindowRule } from "../lib/grouping.js"; + import { NO_TITLE, isRegexRule, traceWindowRule } from "../lib/grouping.js"; + import { t } from "../lib/i18n.svelte.js"; import { app } from "../lib/state.svelte.js"; let { rules = $bindable([]), onadd, onaddregex } = $props(); let selected = $state([]); const STATUS = { - reacquired: { text: "已重启·已追溯", cls: "warn" }, - missing: { text: "未找到 / 已关闭", cls: "danger" }, + reacquired: { key: "windowRules.statusReacquired", cls: "warn" }, + missing: { key: "windowRules.statusMissing", cls: "danger" }, }; function remove() { @@ -25,24 +26,24 @@
- 窗口隐藏 + {t("windowRules.title")} {rules.length}
- - -
-
+
{#if rules.length === 0} -

(空)

+

{t("common.empty")}

{:else} {#each rules as rule, i (i)}
@@ -50,13 +51,13 @@ type="checkbox" bind:group={selected} value={i} - aria-label="选中该规则" + aria-label={t("windowRules.selectRule")} /> {#if isRegexRule(rule)} - 标题正则 + {t("windowRules.titleRegexTag")} @@ -71,12 +72,12 @@ {:else} {/if} - {rule.title} + {rule.title === NO_TITLE ? t("common.noTitleWindow") : rule.title} {rule.process} {#if STATUS[st]} - {STATUS[st].text} + {t(STATUS[st].key)} {:else} - + {/if} {/if}
diff --git a/apps/config/ui/src/lib/grouping.js b/apps/config/ui/src/lib/grouping.js index 6deeea5..799aba1 100644 --- a/apps/config/ui/src/lib/grouping.js +++ b/apps/config/ui/src/lib/grouping.js @@ -1,10 +1,12 @@ // 窗口列表分组与集合运算。 +import { t } from "./i18n.svelte.js"; + /** 按进程名分组并排序,返回 [{ process, path, windows }]。 */ export function groupByProcess(windows) { const groups = new Map(); for (const w of windows) { - const key = w.process || "(未知进程)"; + const key = w.process || t("common.unknownProcess"); if (!groups.has(key)) { groups.set(key, { process: key, path: w.path || "", windows: [] }); } @@ -43,7 +45,12 @@ export function splitByVisibility(windows) { return { visible, hidden }; } -/** 与核心 NO_TITLE 常量一致,用于识别无标题窗口。 */ +/** + * 与核心 `bosskey_common::NO_TITLE` 一致的占位标题。 + * + * 它是跨进程、写进配置文件的哨兵值,不随界面语言变化;展示时用 + * `t("common.noTitleWindow")` 翻译。 + */ export const NO_TITLE = "无标题窗口"; /** 转义正则元字符。 */ @@ -82,7 +89,7 @@ export function processRuleFromWindow(w) { /** 新的「窗口」正则规则(标题正则)。 */ export function newWindowRegexRule(seedTitle) { - const seed = seedTitle && seedTitle !== NO_TITLE ? seedTitle : "关键词"; + const seed = seedTitle && seedTitle !== NO_TITLE ? seedTitle : t("binding.regexSeedKeyword"); return { title: NO_TITLE, hwnd: 0, @@ -100,7 +107,7 @@ export function newProcessRegexRule(seedProcess) { return { process: "", path: "", - regex: containsPattern(seedProcess || "程序名.exe"), + regex: containsPattern(seedProcess || t("binding.regexSeedProcess")), by_name: false, include_untitled: false, include_background: false, diff --git a/apps/config/ui/src/lib/i18n.svelte.js b/apps/config/ui/src/lib/i18n.svelte.js new file mode 100644 index 0000000..4764b3f --- /dev/null +++ b/apps/config/ui/src/lib/i18n.svelte.js @@ -0,0 +1,86 @@ +// 界面语言:catalog 查表 + 语言解析。与核心 crates/common/src/i18n.rs 共用语言标签。 + +import zhCN from "../locales/zh-CN.js"; +import en from "../locales/en.js"; +import zhTW from "../locales/zh-TW.js"; + +/** 配置中表示「跟随系统」的语言偏好值。 */ +export const LANG_AUTO = "auto"; + +/** 语言标签 → catalog;键顺序即配置界面的展示顺序。 */ +const CATALOGS = { + "zh-CN": zhCN, + en, + "zh-TW": zhTW, +}; + +/** 可选语言标签(不含 auto)。 */ +export const LANGS = Object.keys(CATALOGS); + +/** 语言的自称,用于语言选择器;不随界面语言变化。 */ +export const LANG_NAMES = { + "zh-CN": "简体中文", + en: "English", + "zh-TW": "繁體中文", +}; + +/** + * 按 BCP-47 标签解析语言,无法归类时返回 null。 + * 中文按 script/region 子标签区分正体与简体,与核心 `Lang::from_tag` 保持一致。 + */ +export function fromTag(tag) { + if (!tag) return null; + const parts = String(tag).trim().replace(/_/g, "-").toLowerCase().split("-").filter(Boolean); + const [primary, ...rest] = parts; + if (primary === "en") return "en"; + if (primary === "zh") { + return rest.some((p) => ["hant", "tw", "hk", "mo"].includes(p)) ? "zh-TW" : "zh-CN"; + } + return null; +} + +/** 归一化配置里的语言偏好:合法标签归一为规范写法,其余回落到 auto。 */ +export function normalizePref(pref) { + if (String(pref ?? "").trim().toLowerCase() === LANG_AUTO) return LANG_AUTO; + return fromTag(pref) ?? LANG_AUTO; +} + +/** + * 解析实际生效的语言。pref 为具体语言时直接采用;为 auto 或非法值时依据 + * systemTag 推断,推断不出时回落到简体中文。 + */ +export function resolve(pref, systemTag) { + if (String(pref ?? "").trim().toLowerCase() !== LANG_AUTO) { + const lang = fromTag(pref); + if (lang) return lang; + } + return fromTag(systemTag) ?? "zh-CN"; +} + +const current = $state({ lang: "zh-CN" }); + +/** 按配置里的语言偏好设定当前语言;pref 为 auto 时跟随浏览器/系统语言。 */ +export function setLangPref(pref) { + current.lang = resolve(pref, globalThis.navigator?.language); + // 字体回退与断行规则依赖根元素的 lang。 + if (typeof document !== "undefined") { + document.documentElement.lang = current.lang; + } +} + +/** 当前生效语言标签。 */ +export function lang() { + return current.lang; +} + +/** + * 取当前语言下的文案。`params` 用于替换文案里的 `{名字}` 占位符。 + * 缺失的键回落到简体中文,仍缺失时返回键本身,便于开发期发现漏译。 + */ +export function t(key, params) { + const text = CATALOGS[current.lang]?.[key] ?? CATALOGS["zh-CN"][key] ?? key; + if (!params) return text; + return text.replace(/\{(\w+)\}/g, (m, name) => + Object.hasOwn(params, name) ? String(params[name]) : m, + ); +} diff --git a/apps/config/ui/src/lib/i18n.test.js b/apps/config/ui/src/lib/i18n.test.js new file mode 100644 index 0000000..99123ff --- /dev/null +++ b/apps/config/ui/src/lib/i18n.test.js @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import zhCN from "../locales/zh-CN.js"; +import en from "../locales/en.js"; +import zhTW from "../locales/zh-TW.js"; +import { LANGS, LANG_NAMES, fromTag, normalizePref, resolve, t } from "./i18n.svelte.js"; + +describe("fromTag", () => { + it("简体中文的各种写法都归为 zh-CN", () => { + for (const tag of ["zh", "zh-CN", "zh_CN", "zh-Hans", "zh-Hans-CN", "ZH-cn", " zh-SG "]) { + expect(fromTag(tag), tag).toBe("zh-CN"); + } + }); + + it("繁体中文的各种写法都归为 zh-TW", () => { + for (const tag of ["zh-TW", "zh_TW", "zh-Hant", "zh-Hant-TW", "zh-HK", "zh-MO"]) { + expect(fromTag(tag), tag).toBe("zh-TW"); + } + }); + + it("英文的各种写法都归为 en", () => { + for (const tag of ["en", "en-US", "en_GB", "EN"]) { + expect(fromTag(tag), tag).toBe("en"); + } + }); + + it("无对应翻译的语言返回 null", () => { + for (const tag of ["ja", "ko", "fr-FR", "", " ", null, undefined]) { + expect(fromTag(tag), String(tag)).toBe(null); + } + }); +}); + +describe("normalizePref", () => { + it("auto 原样保留,合法标签归一为规范写法", () => { + expect(normalizePref("auto")).toBe("auto"); + expect(normalizePref("AUTO")).toBe("auto"); + expect(normalizePref("zh_tw")).toBe("zh-TW"); + expect(normalizePref("en-US")).toBe("en"); + }); + + it("非法值回落到 auto", () => { + expect(normalizePref("ja-JP")).toBe("auto"); + expect(normalizePref("")).toBe("auto"); + }); +}); + +describe("resolve", () => { + it("显式偏好压过系统语言", () => { + expect(resolve("en", "zh-CN")).toBe("en"); + expect(resolve("zh-TW", "en-US")).toBe("zh-TW"); + }); + + it("auto 跟随系统语言", () => { + expect(resolve("auto", "en-US")).toBe("en"); + expect(resolve("auto", "zh-Hant-TW")).toBe("zh-TW"); + }); + + it("系统语言缺失或无翻译时回落到简体中文", () => { + expect(resolve("auto", undefined)).toBe("zh-CN"); + expect(resolve("auto", "ja-JP")).toBe("zh-CN"); + }); +}); + +// 漏译会静默回落到简体中文,界面变中英混排,故逐键校验三份 catalog 对齐。 +describe("catalog 对齐", () => { + const zhKeys = Object.keys(zhCN); + + it.each([ + ["en", en], + ["zh-TW", zhTW], + ])("%s 与 zh-CN 的键集完全一致", (_name, catalog) => { + expect(Object.keys(catalog).sort()).toEqual(zhKeys.slice().sort()); + }); + + it.each([ + ["zh-CN", zhCN], + ["en", en], + ["zh-TW", zhTW], + ])("%s 没有空文案", (_name, catalog) => { + const empty = Object.entries(catalog) + .filter(([, v]) => typeof v !== "string" || !v.trim()) + .map(([k]) => k); + expect(empty).toEqual([]); + }); + + it("占位符在各语言间一致", () => { + const holders = (s) => (s.match(/\{(\w+)\}/g) ?? []).sort(); + for (const key of zhKeys) { + expect(holders(en[key]), key).toEqual(holders(zhCN[key])); + expect(holders(zhTW[key]), key).toEqual(holders(zhCN[key])); + } + }); + + it("每种可选语言都有自称", () => { + expect(Object.keys(LANG_NAMES).sort()).toEqual(LANGS.slice().sort()); + }); +}); + +describe("t", () => { + it("默认取简体中文", () => { + expect(t("common.close")).toBe("关闭"); + }); + + it("替换占位符", () => { + expect(t("restore.frozen", { n: 3 })).toBe("已冻结 3 个进程"); + }); + + it("参数缺失时占位符原样保留", () => { + expect(t("restore.frozen", {})).toBe("已冻结 {n} 个进程"); + }); + + it("未知键返回键本身,便于开发期发现漏译", () => { + expect(t("nope.not.a.key")).toBe("nope.not.a.key"); + }); +}); diff --git a/apps/config/ui/src/lib/ipc.js b/apps/config/ui/src/lib/ipc.js index db44f07..7cae640 100644 --- a/apps/config/ui/src/lib/ipc.js +++ b/apps/config/ui/src/lib/ipc.js @@ -11,13 +11,26 @@ const mockConfig = { version: "v3.0.0.0", history: [], frozen_pids: [], - hotkey: { hide_hotkey: "Ctrl+Q", close_hotkey: "Win+Esc" }, + hotkey: { + hide_hotkey: "Ctrl+Q", + close_hotkey: "Win+Esc", + hide_only_hotkey: "", + show_only_hotkey: "", + hide_foreground_hotkey: "", + hide_intercept: false, + close_intercept: false, + hide_only_intercept: false, + show_only_intercept: false, + hide_foreground_intercept: false, + }, setting: { mute_after_hide: true, send_before_hide: false, hide_current: true, click_to_hide: true, hide_icon_after_hide: false, + tray_badges: { red: "hidden", green: "auto_hide", yellow: "hide_current", blue: "freeze" }, + tray_show_tooltip: true, freeze_after_hide: false, enhanced_freeze: false, freeze_whole_tree: false, @@ -41,6 +54,7 @@ const mockConfig = { corner_fast_only: true, log_retention_days: 7, autostart_admin: false, + language: "auto", }, notifications: { on_start: true, @@ -84,6 +98,8 @@ const mockWindows = [ /** mock 下的核心监控状态。 */ let mockMonitoring = true; +/** mock 下的自动隐藏开关(随 save_config 更新,供状态轮询回读联动)。 */ +let mockAutoHide = mockConfig.setting.auto_hide_enabled; /** mock 下的开机自启状态(有状态,供预览联动 UI)。 */ let mockAutostart = false; @@ -98,7 +114,16 @@ function mockInvoke(cmd, args) { case "autostart_status": return { enabled: mockAutostart, method: mockAutostart ? "task" : null }; case "core_status": - return { running: true, hidden: false, elevated: false, monitoring: mockMonitoring }; + return { + running: true, + hidden: false, + elevated: false, + monitoring: mockMonitoring, + auto_hide_enabled: mockAutoHide, + }; + case "save_config": + mockAutoHide = !!args?.config?.setting?.auto_hide_enabled; + return null; case "set_hotkeys_enabled": mockMonitoring = !!args?.enabled; return true; @@ -127,6 +152,16 @@ function mockInvoke(cmd, args) { blog: "https://blog.ivan-hanloth.cn/", license: "MIT", }; + case "verhub_project_links": + return { + name: "Boss Key", + website_url: "https://boss-key.ivan-hanloth.cn/", + repo_url: "https://github.com/IvanHanloth/Boss-Key", + docs_url: "https://boss-key.ivan-hanloth.cn/guide/", + author: "Ivan Hanloth", + author_homepage_url: "https://www.ivan-hanloth.cn/", + fetched_at: Math.floor(Date.now() / 1000), + }; case "verhub_check_update": return { should_update: false, @@ -141,7 +176,7 @@ function mockInvoke(cmd, args) { { id: "mock-1", title: "Boss Key 3.0 发布", - content: "全新界面与核心,鼠标按键触发、崩溃恢复、进程冻结。", + content: "全新界面与核心,**鼠标按键触发**、崩溃恢复、进程冻结。详见 [更新日志](https://boss-key.ivan-hanloth.cn/changelog/)。", is_pinned: true, is_hidden: false, author: "Ivan Hanloth", diff --git a/apps/config/ui/src/lib/markdown.js b/apps/config/ui/src/lib/markdown.js new file mode 100644 index 0000000..101eb92 --- /dev/null +++ b/apps/config/ui/src/lib/markdown.js @@ -0,0 +1,213 @@ +// 极简 Markdown 渲染,供公告与更新日志使用。只覆盖 GitHub 风格的常用子集, +// 不引入任何依赖。 +// +// 内容来自 Verhub 远端,因此这里**先转义再拼标签**:输出里出现的标签全部由本 +// 模块生成,源文本里的原始 HTML 只会被当成字面量显示,无需再过一遍消毒。 + +const ESCAPE = { "&": "&", "<": "<", ">": ">", '"': """ }; + +const FENCE = /^ {0,3}(`{3,}|~{3,})/; +const HR = /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/; +const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/; +const QUOTE = /^ {0,3}> ?(.*)$/; +const ITEM = /^([ \t]*)(?:([-*+])|(\d{1,9})[.)])[ \t]+(.*)$/; +const TASK = /^\[([ xX])\][ \t]+/; + +// 行内解析的占位符哨兵。NUL 不会出现在正文里,也不属于 \s,因此裸链接之类的 +// 规则不会越过它去改写已经成型的标签。 +const SENTINEL = String.fromCharCode(0); +const PLACEHOLDER = new RegExp(`${SENTINEL}(\\d+)${SENTINEL}`, "g"); + +const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ESCAPE[c]); + +/** 只放行 http(s) 与 mailto:javascript: / data: 等一律按纯文本处理。 */ +function safeUrl(raw) { + const url = raw.trim(); + return /^(?:https?:\/\/|mailto:)/i.test(url) ? url : null; +} + +const startsBlock = (line) => + FENCE.test(line) || HR.test(line) || HEADING.test(line) || QUOTE.test(line) || ITEM.test(line); + +/** 渲染行内标记,返回 HTML 片段。 */ +function inline(text) { + const stash = []; + // 已成型的标签先寄存成占位符,避免被后续规则二次改写。 + const hold = (html) => `${SENTINEL}${stash.push(html) - 1}${SENTINEL}`; + let s = escapeHtml(text); + + s = s.replace(/(`+)([^`]+?)\1/g, (_, __, code) => hold(`${code}`)); + + // CSP 只允许 self / data: 图源,远端图片加载不出来,统一退化成链接。 + s = s.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (m, alt, dest) => anchor(dest, alt, hold) ?? m); + s = s.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (m, label, dest) => anchor(dest, label, hold) ?? m); + + // 裸链接。前面必须是行首/空白/左括号,因此紧挨占位符的文本不会被重复包裹。 + s = s.replace(/(^|[\s(])(https?:\/\/\S+)/g, (_, pre, raw) => { + const trailing = raw.match(/[.,;:!?)]+$/)?.[0] ?? ""; + const url = raw.slice(0, raw.length - trailing.length); + return pre + hold(``) + url + hold("") + trailing; + }); + + s = s + .replace(/\*\*([^\s*](?:[^*]*[^\s*])?)\*\*/g, "$1") + .replace(/__([^\s_](?:[^_]*[^\s_])?)__/g, "$1") + .replace(/(^|[^*])\*([^\s*](?:[^*]*[^\s*])?)\*/g, "$1$2") + .replace(/(^|[^\w_])_([^\s_](?:[^_]*[^\s_])?)_/g, "$1$2") + .replace(/~~([^~]+)~~/g, "$1"); + + return s.replace(PLACEHOLDER, (_, i) => stash[+i]); +} + +/** 拼一个链接;目标协议不安全时返回 null,交由调用方原样保留。 */ +function anchor(dest, label, hold) { + const url = safeUrl(dest.split(/[ \t]/)[0]); + if (!url) return null; + return hold(``) + (label || url) + hold(""); +} + +/** 收集一段连续的列表行,返回 [html, 下一个未消费的行号]。 */ +function list(lines, start) { + const items = []; + let i = start; + while (i < lines.length) { + const m = lines[i].match(ITEM); + if (m) { + items.push({ + indent: m[1].replace(/\t/g, " ").length, + ordered: !!m[3], + start: m[3] ? Number(m[3]) : 0, + text: [m[4]], + }); + i++; + continue; + } + if (!lines[i].trim()) { + // 列表项之间允许空行,空行之后不再是列表项才算结束。 + if (lines[i + 1] && ITEM.test(lines[i + 1])) { + i++; + continue; + } + break; + } + if (items.length && /^[ \t]/.test(lines[i])) { + items[items.length - 1].text.push(lines[i].trim()); + i++; + continue; + } + break; + } + + let html = ""; + let pos = 0; + while (pos < items.length) { + const [chunk, next] = buildList(items, pos); + html += chunk; + pos = next; + } + return [html, i]; +} + +/** 把扁平的 items 按缩进还原成嵌套列表,返回 [html, 下一个未消费的下标]。 */ +function buildList(items, pos) { + const { indent, ordered, start } = items[pos]; + const tag = ordered ? "ol" : "ul"; + let html = ordered && start > 1 ? `
    ` : `<${tag}>`; + let i = pos; + + while (i < items.length && items[i].indent >= indent) { + if (items[i].indent > indent) { + const [sub, next] = buildList(items, i); + // 子列表挂进上一个
  1. 内部。 + html = html.endsWith("
  2. ") ? `${html.slice(0, -5)}${sub}` : html + sub; + i = next; + continue; + } + if (items[i].ordered !== ordered) break; // 换了列表类型,交给上层另起一个 + html += item(items[i]); + i++; + } + return [`${html}`, i]; +} + +function item(it) { + const task = it.text[0].match(TASK); + if (task) { + const rest = [it.text[0].slice(task[0].length), ...it.text.slice(1)]; + const checked = task[1] === " " ? "" : " checked"; + return `
  3. ${rest.map(inline).join("
    ")}
  4. `; + } + return `
  5. ${it.text.map(inline).join("
    ")}
  6. `; +} + +function blocks(lines) { + const out = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (!line.trim()) { + i++; + continue; + } + + const fence = line.match(FENCE); + if (fence) { + const close = fence[1][0].repeat(3); + const body = []; + i++; + while (i < lines.length && !lines[i].trim().startsWith(close)) body.push(lines[i++]); + i++; // 吃掉收尾栅栏;栅栏缺失时正好越界结束 + out.push(`
    ${escapeHtml(body.join("\n"))}
    `); + continue; + } + + if (HR.test(line)) { + out.push("
    "); + i++; + continue; + } + + const h = line.match(HEADING); + if (h) { + out.push(`${inline(h[2])}`); + i++; + continue; + } + + if (QUOTE.test(line)) { + const body = []; + while (i < lines.length && lines[i].trim()) { + const m = lines[i].match(QUOTE); + body.push(m ? m[1] : lines[i]); // 无 > 前缀的续行也算引用内容 + i++; + } + out.push(`
    ${blocks(body)}
    `); + continue; + } + + if (ITEM.test(line)) { + const [html, next] = list(lines, i); + out.push(html); + i = next; + continue; + } + + // 段落。软换行按 GitHub 评论的习惯渲染成
    ,而不是并成一行。 + const para = []; + while (i < lines.length && lines[i].trim() && !startsBlock(lines[i])) { + para.push(lines[i].trim()); + i++; + } + out.push(`

    ${para.map(inline).join("
    ")}

    `); + } + + return out.join(""); +} + +/** 把 Markdown 源文本渲染成 HTML 字符串。 */ +export function renderMarkdown(source) { + if (!source) return ""; + return blocks(String(source).replace(/\r\n?/g, "\n").split("\n")); +} diff --git a/apps/config/ui/src/lib/markdown.test.js b/apps/config/ui/src/lib/markdown.test.js new file mode 100644 index 0000000..3a6bb41 --- /dev/null +++ b/apps/config/ui/src/lib/markdown.test.js @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { renderMarkdown } from "./markdown.js"; + +describe("renderMarkdown 块级", () => { + it("空输入返回空串", () => { + expect(renderMarkdown("")).toBe(""); + expect(renderMarkdown(null)).toBe(""); + expect(renderMarkdown(undefined)).toBe(""); + }); + + it("标题按级数渲染", () => { + expect(renderMarkdown("# 大标题")).toBe("

    大标题

    "); + expect(renderMarkdown("### 小标题")).toBe("

    小标题

    "); + expect(renderMarkdown("####### 七个井号不是标题")).toBe("

    ####### 七个井号不是标题

    "); + }); + + it("段落内的软换行渲染成
    ,空行分段", () => { + expect(renderMarkdown("第一行\n第二行\n\n另一段")).toBe("

    第一行
    第二行

    另一段

    "); + }); + + it("分割线", () => { + expect(renderMarkdown("---")).toBe("
    "); + expect(renderMarkdown("***")).toBe("
    "); + expect(renderMarkdown("--")).toBe("

    --

    "); + }); + + it("引用块内部继续解析", () => { + expect(renderMarkdown("> **重要**")).toBe("

    重要

    "); + }); + + it("围栏代码块原样保留、不解析行内标记", () => { + expect(renderMarkdown("```js\nconst a = **1**;\n```")).toBe("
    const a = **1**;
    "); + }); + + it("未闭合的围栏吃到结尾", () => { + expect(renderMarkdown("```\na\nb")).toBe("
    a\nb
    "); + }); +}); + +describe("renderMarkdown 列表", () => { + it("无序列表", () => { + expect(renderMarkdown("- 甲\n- 乙")).toBe("
    "); + }); + + it("有序列表,起始序号非 1 时带 start", () => { + expect(renderMarkdown("1. 甲\n2. 乙")).toBe("
    "); + expect(renderMarkdown("3. 丙")).toBe('
    '); + }); + + it("缩进还原成嵌套列表", () => { + expect(renderMarkdown("- 甲\n - 甲一\n- 乙")).toBe( + "
      • 甲一
    ", + ); + }); + + it("任务列表渲染成只读复选框", () => { + expect(renderMarkdown("- [x] 做完了\n- [ ] 还没做")).toBe( + '
    • 做完了
    • ' + + '
    • 还没做
    ', + ); + }); + + it("列表结束后回到段落", () => { + expect(renderMarkdown("- 甲\n\n收尾")).toBe("

    收尾

    "); + }); +}); + +describe("renderMarkdown 行内", () => { + it("粗体、斜体、删除线、行内代码", () => { + expect(renderMarkdown("**粗** *斜* ~~删~~ `码`")).toBe( + "

    ", + ); + }); + + it("行内代码里的标记不再解析", () => { + expect(renderMarkdown("`**不是粗体**`")).toBe("

    **不是粗体**

    "); + }); + + it("蛇形命名里的下划线不当成斜体", () => { + expect(renderMarkdown("some_var_name")).toBe("

    some_var_name

    "); + }); + + it("链接与裸链接", () => { + expect(renderMarkdown("[官网](https://boss-key.ivan-hanloth.cn/)")).toBe( + '

    官网

    ', + ); + expect(renderMarkdown("见 https://example.com 。")).toBe( + '

    https://example.com

    ', + ); + }); + + it("裸链接不吞掉句末标点", () => { + expect(renderMarkdown("见 https://example.com.")).toBe( + '

    https://example.com.

    ', + ); + }); + + it("图片退化成链接(CSP 不允许远端图源)", () => { + expect(renderMarkdown("![截图](https://example.com/a.png)")).toBe( + '

    截图

    ', + ); + }); +}); + +describe("renderMarkdown 安全", () => { + it("原始 HTML 一律转义", () => { + expect(renderMarkdown("")).toBe( + "

    <script>alert(1)</script>

    ", + ); + expect(renderMarkdown('')).toBe( + "

    <img src=x onerror="alert(1)">

    ", + ); + }); + + it("非 http/mailto 协议的链接不生成 ", () => { + expect(renderMarkdown("[点我](javascript:alert(1))")).toBe("

    [点我](javascript:alert(1))

    "); + expect(renderMarkdown("[点我](data:text/html,