diff --git a/.github/chainguard/tag-release.sts.yaml b/.github/chainguard/tag-release.sts.yaml new file mode 100644 index 0000000..42b81c7 --- /dev/null +++ b/.github/chainguard/tag-release.sts.yaml @@ -0,0 +1,8 @@ +# octo-sts 信任策略:release.yml 以 OIDC 身份向 https://octo-sts.dev 换取 +# 本仓库 contents:write 的短期 token(见 release.yml 的 Federate token 步骤)。 +# 只放行 main / dev 分支上的工作流运行。 +issuer: https://token.actions.githubusercontent.com +subject_pattern: repo:IvanHanloth/Boss-Key:ref:refs/heads/(main|dev) + +permissions: + contents: write diff --git a/.github/inno-script/Boss-Key.iss b/.github/inno-script/Boss-Key.iss index b67c316..1b4c5e7 100644 --- a/.github/inno-script/Boss-Key.iss +++ b/.github/inno-script/Boss-Key.iss @@ -6,11 +6,12 @@ ; 依赖 package.ps1 先组装好便携文件夹 dist\Boss-Key,安装包的文件与许可协议都取自那里。 ; 需要 Inno Setup 7+:简繁中文语言包自 7.0 起才随官方安装包分发(见 scripts/install-inno.ps1)。 +; 不设默认值:写死的版本号迟早会过期,装出一个版本号对不上的包比编译失败更难发现。 #ifndef MyAppVersion - #define MyAppVersion "3.0.0" + #error "缺少 MyAppVersion:请用 scripts/package.ps1 -Installer 编译,由它从 Cargo.toml 取版本号传入" #endif #ifndef MyAppVersion4 - #define MyAppVersion4 "3.0.0.0" + #error "缺少 MyAppVersion4:请用 scripts/package.ps1 -Installer 编译" #endif #define MyAppName "Boss Key" @@ -19,6 +20,8 @@ #define CoreExe "Boss Key.exe" #define ConfigExe "config.exe" #define SourceDir "..\..\dist\Boss-Key" +; 文件名须与 crates/common/src/paths.rs 的 INSTALLED_MARKER 一致 +#define InstalledMarker "installed.marker" [Setup] AppId={{BA8E9784-B92D-48EE-B447-99709232260B} @@ -34,6 +37,11 @@ DefaultGroupName={#MyAppName} AllowNoIcons=yes ; 复用便携版里的那份,避免和仓库根 LICENSE 各自漂移 LicenseFile={#SourceDir}\LICENSE.txt +; 默认普通权限安装({autopf} 此时为 %LocalAppData%\Programs):不必为装个隐藏窗口的小工具 +; 弹 UAC。仍允许在启动对话框改选「为所有用户安装」装进 Program Files。 +; 两种模式下数据都在 %APPDATA%\BossKey,与安装目录无关,见 crates/common/src/paths.rs。 +; 升级时 Inno 沿用上次的安装模式(UsePreviousPrivileges 默认开),旧的按机器安装原地升级。 +PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog OutputDir=..\..\dist\installer OutputBaseFilename=Boss-Key-{#MyAppVersion}-Setup @@ -52,9 +60,9 @@ Name: "chinesetraditional"; MessagesFile: "compiler:Languages\ChineseTraditional Name: "english"; MessagesFile: "compiler:Default.isl" [CustomMessages] -chinesesimplified.KeepConfigPrompt=是否保留配置文件(config.json)?%n%n选择“是”将保留你的设置,重新安装后可继续使用;%n选择“否”将删除包括配置文件在内的整个安装目录。 -chinesetraditional.KeepConfigPrompt=是否保留設定檔(config.json)?%n%n選擇「是」將保留你的設定,重新安裝後可繼續使用;%n選擇「否」將刪除包括設定檔在內的整個安裝目錄。 -english.KeepConfigPrompt=Do you want to keep your settings file (config.json)?%n%nChoose "Yes" to keep your settings for a future reinstall;%nchoose "No" to delete the entire installation folder, including the settings file. +chinesesimplified.KeepConfigPrompt=是否保留配置文件(config.json)?%n%n选择“是”将保留你的设置,重新安装后可继续使用;%n选择“否”将删除包括配置文件在内的全部数据。 +chinesetraditional.KeepConfigPrompt=是否保留設定檔(config.json)?%n%n選擇「是」將保留你的設定,重新安裝後可繼續使用;%n選擇「否」將刪除包括設定檔在內的全部資料。 +english.KeepConfigPrompt=Do you want to keep your settings file (config.json)?%n%nChoose "Yes" to keep your settings for a future reinstall;%nchoose "No" to delete all data, including the settings file. [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked @@ -63,6 +71,9 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{ Source: "{#SourceDir}\{#CoreExe}"; DestDir: "{app}"; Flags: ignoreversion Source: "{#SourceDir}\{#ConfigExe}"; DestDir: "{app}"; Flags: ignoreversion Source: "static\icon.ico"; DestDir: "{app}"; Flags: ignoreversion +; 安装版标记:程序据它把数据存到 %APPDATA%\BossKey 而非安装目录(见 crates/common/src/paths.rs)。 +; 不放在便携文件夹 dist\Boss-Key 里,故直接从脚本目录取——便携版有了它就不便携了。 +Source: "{#InstalledMarker}"; DestDir: "{app}"; Flags: ignoreversion [Icons] Name: "{group}\{#MyAppName}"; Filename: "{app}\{#CoreExe}" @@ -79,6 +90,8 @@ const AutostartTaskName = 'BossKeyAutostart'; RunSubkey = 'Software\Microsoft\Windows\CurrentVersion\Run'; RunValueName = 'Boss Key Application'; + // 配置界面(Tauri)的 WebView2 用户数据目录名,等于 tauri.conf.json 里的 identifier。 + WebViewDataDirName = 'cn.hanloth.bosskey.config'; // 强制结束核心与配置进程。核心是无窗口常驻进程,CloseApplications 关不掉它; // 且映像名 "Boss Key.exe" 含空格,taskkill 的 /IM 值必须加引号,否则参数被拆断而失败。 @@ -111,13 +124,42 @@ begin Result := ''; end; +// 清理一个数据目录里的运行时产物(日志、恢复文件、缓存、写入残留),配置文件除外。 +// config.json.tmp 是原子保存的中间文件,写到一半崩溃会留下; +// .BossKey-write-probe-* 是可写性探测的探针文件,进程被强杀时会留下。 +// 二者都不该留在磁盘上,且留着会让空目录删不掉。 +procedure RemoveRuntimeFiles(Dir: string); +begin + DelTree(Dir + '\logs', True, True, True); + DeleteFile(Dir + '\recovery.json'); + DeleteFile(Dir + '\verhub_cache.json'); + DeleteFile(Dir + '\config.json.tmp'); + DelTree(Dir + '\.BossKey-write-probe-*', False, True, False); +end; + +// 清理配置界面的 WebView2 用户数据目录(%LOCALAPPDATA%\\EBWebView)。 +// 它不在数据目录里,位置由 Tauri 按 identifier 决定,不删会残留几十 MB 的浏览器缓存。 +// 里面只有缓存与本地存储,用户设置在 config.json 中,故不受「是否保留配置」影响。 +procedure RemoveWebViewData; +begin + DelTree(ExpandConstant('{localappdata}\' + WebViewDataDirName), True, True, True); +end; + // 卸载时清理: // - usUninstall(删文件前):摘掉自启看门狗并结束进程,确保核心不会被重新拉起、文件不被占用。 -// - usPostUninstall(删文件后):清理运行时产物(日志、恢复文件),并询问是否保留配置文件; -// 保留则只留下 config.json,不保留则连同整个安装目录一起删除。静默卸载不弹窗,默认保留配置。 +// - usPostUninstall(删文件后):清理运行时产物(日志、恢复文件、缓存、WebView2 用户数据), +// 并询问是否保留配置文件;保留则只留下 config.json,不保留则连同整个目录一起删除。 +// 静默卸载不弹窗,默认保留配置。 +// +// 安装版的数据在 %APPDATA%\BossKey(见 crates/common/src/paths.rs)。安装目录里也扫一遍: +// 早期版本把数据放在那里,迁移时删不掉的原文件会留下。 +// +// 提权卸载(按机器安装)时 {userappdata} / {localappdata} 指向执行卸载的账户:与当初安装的是 +// 同一账户(UAC 提权自己)时正确,由另一个管理员账户授权时则指向错误的用户目录,那里找不到 +// 文件、什么也不会删。宁可留下也不去遍历所有用户目录误删别人的数据。 procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); var - AppDir: string; + AppDir, UserDir: string; KeepConfig: Boolean; begin if CurUninstallStep = usUninstall then @@ -130,15 +172,24 @@ begin if CurUninstallStep <> usPostUninstall then Exit; AppDir := ExpandConstant('{app}'); - DelTree(AppDir + '\logs', True, True, True); - DeleteFile(AppDir + '\recovery.json'); - if FileExists(AppDir + '\config.json') then + UserDir := ExpandConstant('{userappdata}\BossKey'); + RemoveRuntimeFiles(AppDir); + RemoveRuntimeFiles(UserDir); + RemoveWebViewData; + + if FileExists(AppDir + '\config.json') or FileExists(UserDir + '\config.json') then begin KeepConfig := UninstallSilent or (MsgBox(CustomMessage('KeepConfigPrompt'), mbConfirmation, MB_YESNO) = IDYES); if not KeepConfig then + begin DelTree(AppDir, True, True, True); - end - else - RemoveDir(AppDir); + DelTree(UserDir, True, True, True); + Exit; + end; + end; + + // 只在目录已空时收尾,留着配置的目录会被跳过。 + RemoveDir(AppDir); + RemoveDir(UserDir); end; diff --git a/.github/inno-script/installed.marker b/.github/inno-script/installed.marker new file mode 100644 index 0000000..464777a --- /dev/null +++ b/.github/inno-script/installed.marker @@ -0,0 +1,16 @@ +This file marks an installed copy of Boss Key. + +Because of it, the program stores its settings in %APPDATA%\BossKey instead of +this folder: an installation may live under C:\Program Files, which normal +privileges cannot write to. + +Do not delete it. The uninstaller removes it for you. + +--- + +此文件标记这是 Boss Key 的安装版。 + +程序据此把设置存到 %APPDATA%\BossKey,而不是本目录:安装目录可能位于 +C:\Program Files,普通权限写不进去。 + +请勿删除,卸载时会自动移除。 diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index fa2b2c1..ad696f5 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -8,7 +8,7 @@ name: Deploy Docs # 3. 手动触发(workflow_dispatch)—— 不受路径限制,可随时重新部署。 on: push: - branches: ["main", "dev"] + branches: ["main"] paths: - "docs/**" - "scripts/i18n-check.ps1" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ace456..a265f85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,99 +1,150 @@ name: Release -# 构建并发布一个已随合并进入 main 的 tag。 +# 一键发版:写版本号 → 经 GitHub API 创建带 Verified 签名的提交 → 打 tag → 构建 → 草稿 Release。 # -# 触发方式: -# 1. 推送到 main —— tag.yml 事先把版本号提交与 v* tag 落在 dev,等 dev → main 的 -# PR 合并,tag 随之进入 main 的历史,这里检测到就构建; -# 2. 手动触发(workflow_dispatch),指定 tag —— 用于重跑或补发。 +# 推送凭据来自 octo-sts(https://octo-sts.dev):工作流以 OIDC 身份向其换取一个 +# 只限本仓库、仅 contents:write 的短期 token,job 结束时自动吊销,仓库不保存 +# 任何长期凭据。信任策略见 .github/chainguard/tag-release.sts.yaml。 # -# 为什么不监听 push: tags:tag 是 tag.yml 用 GITHUB_TOKEN 推到 dev 的,那次推送 -# 不会触发任何工作流;而 PR 合并本身**不产生 tag 推送事件**(tag 是独立的 ref, -# 合并只是让它指向的提交变得可从 main 追溯)。所以只能从 main 的 push 事件里检测。 +# 版本号提交与 tag 直接落在触发分支上(请从 main 触发)。Octo STS App 已加入 +# 分支保护的 bypass 名单,无需发版 PR。提交经 GraphQL createCommitOnBranch 创建, +# 由 GitHub 服务端签名(Verified 徽章);tag 没有服务端签名机制,是普通附注 tag。 +# +# 重跑 / 补发:填入已发过的版本号即可 —— tag 已存在时跳过提交与打 tag, +# 直接检出该 tag 重新构建。 on: - push: - branches: ["main"] workflow_dispatch: inputs: - tag: - description: "要构建发布的 tag,例如 v3.0.1" + version: + description: "要发布的版本号,例如 3.0.1 或 3.1.0-rc.1(可带前导 v);对应 tag 已存在时仅重新构建" required: true type: string +permissions: + contents: write # 创建 Release + id-token: write # octo-sts 换 token 与 attest-build-provenance 都依赖 OIDC token + attestations: write # 写入构建来源证明 + +concurrency: + group: release + 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 }} + release: + runs-on: windows-latest steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 # 需要完整历史与全部 tag 才能算可达性 - - name: Find newly merged release tag - id: find + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + + # 先写版本号再判断新发版还是重建:借用 version.ps1 的归一化 + # (去前导 v、折叠四段号),避免在工作流里重复实现一遍 + - name: Apply version to files + id: bump + shell: pwsh + run: ./scripts/version.ps1 apply "${{ inputs.version }}" + + - name: Determine release mode + id: mode + shell: pwsh env: - INPUT_TAG: ${{ inputs.tag }} - BEFORE: ${{ github.event.before }} + VERSION: ${{ steps.bump.outputs.version }} 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: ${{ needs.detect.outputs.tag }} - - steps: - # 构建 tag 指向的那个提交,而不是 main 的 merge commit: - # 前者才是版本号提交本身,与 version.ps1 check 的校验对象一致。 - - name: Checkout repository - uses: actions/checkout@v4 + $tag = "v$env:VERSION" + git ls-remote --tags --exit-code origin "refs/tags/$tag" *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host "tag $tag 已存在,本次仅重新构建" + $rebuild = 'true' + } else { + Write-Host "tag $tag 不存在,本次为新发版" + $rebuild = 'false' + } + @("tag=$tag", "rebuild=$rebuild") | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + # ls-remote 在 tag 不存在时返回 2,而 pwsh 步骤以最后一条原生命令的 + # 退出码收尾,这里显式归零 + exit 0 + + # 以 OIDC 身份向 octo-sts 换取本仓库 contents:write 的短期 token, + # job 结束时由该 action 的 post 步骤吊销 + - name: Federate token via octo-sts + if: steps.mode.outputs.rebuild != 'true' + id: sts + uses: octo-sts/action@main with: - ref: ${{ needs.detect.outputs.tag }} + scope: ${{ github.repository }} + identity: tag-release + + # 经 GraphQL createCommitOnBranch 提交版本号变更:GitHub 会在服务端为 + # API 创建的提交签名(Verified 徽章),这是不保管签名私钥的前提下唯一的 + # 签名途径。expectedHeadOid 保证分支头未被并发移动,否则失败而不是覆盖。 + - name: Create verified commit and tag + if: steps.mode.outputs.rebuild != 'true' + shell: pwsh + env: + STS_TOKEN: ${{ steps.sts.outputs.token }} + TAG: ${{ steps.mode.outputs.tag }} + BRANCH: ${{ github.ref_name }} + HEAD_SHA: ${{ github.sha }} + run: | + $headers = @{ Authorization = "Bearer $env:STS_TOKEN"; 'Content-Type' = 'application/json' } + + # 版本号只落在这两处,其余地方构建时取自 Cargo.toml(见 scripts/version.ps1) + $files = @( + 'Cargo.toml', + 'Cargo.lock' + ) + $additions = foreach ($f in $files) { + @{ path = $f; contents = [Convert]::ToBase64String([IO.File]::ReadAllBytes($f)) } + } + + $body = @{ + query = 'mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }' + variables = @{ + input = @{ + branch = @{ repositoryNameWithOwner = $env:GITHUB_REPOSITORY; branchName = $env:BRANCH } + expectedHeadOid = $env:HEAD_SHA + message = @{ headline = "chore(release): $env:TAG" } + fileChanges = @{ additions = @($additions) } + } + } + } | ConvertTo-Json -Depth 12 -Compress + + $resp = Invoke-RestMethod -Uri 'https://api.github.com/graphql' -Method Post -Headers $headers -Body $body + if ($resp.errors) { throw "创建提交失败:$($resp.errors[0].message)" } + $oid = $resp.data.createCommitOnBranch.commit.oid + Write-Host "版本号已提交至 $env:BRANCH:$oid" + + # 带 message 的附注 tag + $tagBody = @{ tag = $env:TAG; message = "Boss Key $env:TAG"; object = $oid; type = 'commit' } | ConvertTo-Json -Compress + $tagObj = Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/git/tags" -Method Post -Headers $headers -Body $tagBody + + $refBody = @{ ref = "refs/tags/$env:TAG"; sha = $tagObj.sha } | ConvertTo-Json -Compress + Invoke-RestMethod -Uri "https://api.github.com/repos/$env:GITHUB_REPOSITORY/git/refs" -Method Post -Headers $headers -Body $refBody | Out-Null + Write-Host "tag $env:TAG 已创建" + + # 构建对象统一为 tag 指向的提交:新发版时它刚经 API 落在远端(本地工作区 + # 内容与之相同,但没有这个提交对象),重建时它就是当年的版本号提交 + - name: Checkout release tag + shell: pwsh + env: + TAG: ${{ steps.mode.outputs.tag }} + run: | + git fetch origin --no-tags "refs/tags/${env:TAG}:refs/tags/${env:TAG}" + if ($LASTEXITCODE -ne 0) { throw "拉取 tag $env:TAG 失败" } + git checkout -f "refs/tags/$env:TAG" + if ($LASTEXITCODE -ne 0) { throw "检出 tag $env:TAG 失败" } # tag 与代码里的版本号必须一致,否则装出来的包版本号是错的 - name: Verify version matches tag id: version shell: pwsh - run: ./scripts/version.ps1 check "$env:TAG" + run: ./scripts/version.ps1 check "${{ steps.mode.outputs.tag }}" - name: Set up Node.js uses: actions/setup-node@v4 @@ -102,12 +153,6 @@ jobs: cache: npm cache-dependency-path: apps/config/ui/package-lock.json - - name: Set up Rust - uses: dtolnay/rust-toolchain@stable - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - - name: Frontend install, test & build run: | npm --prefix apps/config/ui ci @@ -128,6 +173,8 @@ jobs: # 不会把 exe 散落到用户的下载目录里。 - name: Zip portable build shell: pwsh + env: + TAG: ${{ steps.mode.outputs.tag }} run: Compress-Archive -Path "dist/Boss-Key" -DestinationPath "Boss-Key-$env:TAG-portable.zip" # 构建来源证明(Sigstore):证明产物由本仓库的本 workflow 从该 commit 构建。 @@ -137,7 +184,7 @@ jobs: with: subject-path: | dist/installer/Boss-Key-*-Setup.exe - Boss-Key-${{ env.TAG }}-portable.zip + Boss-Key-${{ steps.mode.outputs.tag }}-portable.zip # ---- 代码签名(可选,反杀软误报最有效的单项措施)---- # 取得 OV/EV 证书后:PFX 以 base64 存入 secrets.CODE_SIGN_PFX, @@ -162,6 +209,7 @@ jobs: shell: pwsh env: GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.mode.outputs.tag }} run: | $notes = (gh api "repos/$env:GITHUB_REPOSITORY/releases/generate-notes" -f tag_name="$env:TAG" --jq '.body') -join "`n" $notice = @' @@ -174,12 +222,12 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: - name: Boss Key ${{ env.TAG }} - tag_name: ${{ env.TAG }} + name: Boss Key ${{ steps.mode.outputs.tag }} + tag_name: ${{ steps.mode.outputs.tag }} body_path: release-notes.md draft: true prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }} make_latest: ${{ steps.version.outputs.is_prerelease != 'true' }} files: | - Boss-Key-${{ env.TAG }}-portable.zip + Boss-Key-${{ steps.mode.outputs.tag }}-portable.zip dist/installer/Boss-Key-*-Setup.exe diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml deleted file mode 100644 index e72d502..0000000 --- a/.github/workflows/tag.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: Bump version and tag - -# 手动触发的发版准备。**请从 dev 分支触发**(bot 已在 dev 的分支保护里放行)。 -# -# 流程: -# 1. 把版本号写进 Cargo.toml / tauri.conf.json / package.json / Cargo.lock; -# 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: - inputs: - version: - 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 # 推版本号提交与 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 - - - name: Rust cache - uses: Swatinem/rust-cache@v2 - - - name: Apply version to files - id: version - shell: pwsh - run: ./scripts/version.ps1 apply "${{ inputs.version }}" - - - name: Commit and tag - id: bump - shell: pwsh - env: - VERSION: ${{ steps.version.outputs.version }} - BRANCH: ${{ github.ref_name }} - run: | - $tag = "v$env:VERSION" - - # tag 已存在说明这个版本发过了,直接失败而不是覆盖历史 - git ls-remote --tags --exit-code origin "refs/tags/$tag" *> $null - if ($LASTEXITCODE -eq 0) { - Write-Host "::error::tag $tag 已存在于远端,请换一个新版本号" - exit 1 - } - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add Cargo.toml Cargo.lock apps/config/src-tauri/tauri.conf.json apps/config/ui/package.json - git commit -m "chore(release): $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 - - # 发版 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 fbabbd2..303e49d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4219,9 +4219,9 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "verhub-sdk" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d327c305b7a92625c9bd30009a1ab80091c401c268b52c4d4576978aecb165" +checksum = "2a3a64a0d678e12dd4f6e80aa815230c0f6e74bb506babfc04d71cdc561154b3" dependencies = [ "log", "os_info", diff --git a/Cargo.toml b/Cargo.toml index 1a04cbf..3199279 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" -verhub-sdk = { version = "0.2.6", default-features = false, features = ["native-tls"] } +verhub-sdk = { version = "0.2.7", default-features = false, features = ["native-tls"] } [profile.release] opt-level = "z" diff --git a/README.en.md b/README.en.md index 04f54e9..2d84d9e 100644 --- a/README.en.md +++ b/README.en.md @@ -103,6 +103,22 @@ The settings window and the core's tray menu and notifications are available in For the full feature list and usage guide, see the Boss-Key [guide](https://boss-key.ivan-hanloth.cn/en/guide/). +## Where the data lives, and how to remove it + +The **portable edition** keeps its settings, logs, recovery file and cache **inside the program folder**, so copying the folder takes your whole setup with it. If that folder is not writable (it sits somewhere like `C:\Program Files`, or on read-only media), the program stores them in `%APPDATA%\BossKey` instead and says so in the settings window. + +The **installer edition** always uses `%APPDATA%\BossKey`: the installation folder may be `C:\Program Files`, which normal privileges cannot write to. The program tells the two apart by the `installed.marker` file the installer drops. + +Either way, the browser component used by the settings window keeps its own data in `%LOCALAPPDATA%\cn.hanloth.bosskey.config`, which deleting the program folder does not remove. The package ships a `cleanup.ps1`; open PowerShell in the program folder and run: + +```powershell +powershell -ExecutionPolicy Bypass -File cleanup.ps1 +``` + +It lists what it is about to delete and waits for your confirmation, then removes `%LOCALAPPDATA%\cn.hanloth.bosskey.config`, any `%APPDATA%\BossKey`, and what autostart leaves behind: the scheduled task `BossKeyAutostart` and the registry entry `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Boss Key Application`. The program folder itself is left alone — delete it yourself once the script is done. + +The installer edition does not need this: the uninstaller already does the same, and asks whether to keep your settings file. + ## Development and contributing For details on development and contributing, see the Boss-Key [development docs](https://boss-key.ivan-hanloth.cn/en/dev/). diff --git a/README.md b/README.md index 303d479..2a1851b 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,22 @@ v2.1.0版本加入了鼠标相关操作隐藏绑定,可以选择鼠标中键 完整功能介绍及使用指南,请参阅 Boss-Key [使用文档](https://boss-key.ivan-hanloth.cn/guide) +## 数据存放位置与清理 + +**便携版**把配置、日志、恢复文件与缓存放在**程序文件夹里**,拷走整个文件夹就带走了全部设置。若该文件夹不可写(放在了 `C:\Program Files` 之类的地方,或只读介质上),程序会改存到 `%APPDATA%\BossKey` 并在界面上说明原因。 + +**安装版**一律存到 `%APPDATA%\BossKey`:安装目录可能在 `C:\Program Files`,普通权限写不进去。程序凭安装包放的 `installed.marker` 分辨自己是哪一种。 + +无论哪种,配置界面用到的浏览器组件另有一份数据在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,删程序文件夹清不掉它。随包附有 `cleanup.ps1`,在程序目录里打开 PowerShell 执行即可: + +```powershell +powershell -ExecutionPolicy Bypass -File cleanup.ps1 +``` + +它会列出将要删除的内容并等你确认,随后清理 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`、可能存在的 `%APPDATA%\BossKey`,以及开机自启留下的计划任务 `BossKeyAutostart` 和注册表项 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Boss Key Application`。程序文件夹本身不会被删,跑完后自行删除即可。 + +安装版无需这一步:卸载程序已经做了同样的事,并会询问是否保留配置文件。 + ## 开发及贡献指南 有关开发和贡献的详细信息,请参阅 Boss-Key [开发文档](https://boss-key.ivan-hanloth.cn/dev) diff --git a/README.zh-TW.md b/README.zh-TW.md index 448793e..b185de0 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -103,6 +103,22 @@ Boss-Key 支援以滑鼠中鍵、側鍵 1、側鍵 2 切換隱藏狀態,並可 完整功能介紹及使用指南,請參閱 Boss-Key [使用說明](https://boss-key.ivan-hanloth.cn/zh-tw/guide/)。 +## 資料存放位置與清理 + +**可攜版**把設定、記錄檔、復原檔與快取放在**程式資料夾裡**,複製走整個資料夾就帶走了全部設定。若該資料夾不可寫入(放在了 `C:\Program Files` 之類的地方,或唯讀媒體上),程式會改存到 `%APPDATA%\BossKey` 並在介面上說明原因。 + +**安裝版**一律存到 `%APPDATA%\BossKey`:安裝資料夾可能在 `C:\Program Files`,一般權限寫不進去。程式憑安裝程式放的 `installed.marker` 分辨自己是哪一種。 + +無論哪種,設定介面用到的瀏覽器元件另有一份資料在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,刪程式資料夾清不掉它。隨附有 `cleanup.ps1`,在程式資料夾裡開啟 PowerShell 執行即可: + +```powershell +powershell -ExecutionPolicy Bypass -File cleanup.ps1 +``` + +它會列出將要刪除的內容並等你確認,隨後清理 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`、可能存在的 `%APPDATA%\BossKey`,以及開機自動啟動留下的排程工作 `BossKeyAutostart` 與登錄項目 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\Boss Key Application`。程式資料夾本身不會被刪,跑完後自行刪除即可。 + +安裝版不需要這一步:解除安裝程式已經做了同樣的事,並會詢問是否保留設定檔。 + ## 開發及貢獻指南 有關開發和貢獻的詳細資訊,請參閱 Boss-Key [開發文件](https://boss-key.ivan-hanloth.cn/zh-tw/dev/)。 diff --git a/apps/config/src-tauri/src/lib.rs b/apps/config/src-tauri/src/lib.rs index 4fbb4f7..05668cb 100644 --- a/apps/config/src-tauri/src/lib.rs +++ b/apps/config/src-tauri/src/lib.rs @@ -12,15 +12,23 @@ mod verhub; const CORE_EXE: &str = "Boss Key.exe"; +/// 程序自身所在目录:只用来找同目录下的可执行文件(核心、pssuspend)。 +/// 数据文件一律走 [`bosskey_common::paths`]——安装版存到用户目录,便携版才在这里。 fn exe_dir() -> PathBuf { - std::env::current_exe() - .ok() - .and_then(|p| p.parent().map(|d| d.to_path_buf())) - .unwrap_or_else(|| PathBuf::from(".")) + bosskey_common::paths::exe_dir() +} + +/// 数据目录(配置、日志、恢复文件、缓存);与核心得出的结果一致。 +fn data_dir() -> PathBuf { + bosskey_common::paths::data_dir() } fn config_path() -> PathBuf { - exe_dir().join("config.json") + bosskey_common::paths::config_path() +} + +fn log_dir() -> PathBuf { + data_dir().join(bosskey_core::logging::LOG_DIR_NAME) } /// 定位同目录下的核心程序,不存在时报错。 @@ -61,6 +69,15 @@ async fn blocking(f: impl FnOnce() -> T + Send + 'static) -> .expect("阻塞任务执行失败") } +/// 数据目录的位置与由来,供界面在便携版回退时提示用户。 +#[derive(Serialize)] +struct DataLocation { + dir: String, + program_dir: String, + /// `installed` / `portable` / `portable_fallback`。 + kind: &'static str, +} + #[derive(Serialize)] struct AppInfo { name: &'static str, @@ -389,17 +406,33 @@ fn startup_action() -> Option { .find(|a| a == bosskey_common::ARG_RESTORE || a == bosskey_common::ARG_ABOUT) } -/// 打开日志目录(`/logs`);目录不存在时先创建,再用资源管理器打开。 +/// 打开日志目录(`<数据目录>/logs`);目录不存在时先创建,再用资源管理器打开。 #[tauri::command] async fn open_log_dir() -> Result<(), String> { blocking(|| { - let dir = exe_dir().join(bosskey_core::logging::LOG_DIR_NAME); + let dir = log_dir(); std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; bosskey_core::shell::open(&dir.to_string_lossy()) }) .await } +/// 数据目录及其由来。界面据 `kind` 判断是否要提示便携版写不进程序目录。 +#[tauri::command] +fn data_location() -> DataLocation { + use bosskey_common::paths::DataDirKind; + let located = bosskey_common::paths::locate(); + DataLocation { + dir: located.dir.display().to_string(), + program_dir: located.program_dir.display().to_string(), + kind: match located.kind { + DataDirKind::Installed => "installed", + DataDirKind::Portable => "portable", + DataDirKind::PortableFallback => "portable_fallback", + }, + } +} + #[tauri::command] fn app_info() -> AppInfo { AppInfo { @@ -423,11 +456,11 @@ async fn open_external(url: String) -> Result<(), 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")) + verhub::project_links(&data_dir().join("verhub_cache.json")) .await .map_err(|e| e.to_string()) } @@ -448,27 +481,42 @@ async fn verhub_announcements(limit: u32) -> Result, S .map_err(|e| e.to_string()) } -/// `contact` 可空——留了才好回复用户。 +/// 反馈提交选项,供前端决定是否显示「转换为 Issue」。 +#[tauri::command] +async fn verhub_feedback_options() -> Result { + verhub::feedback_options().await.map_err(|e| e.to_string()) +} + #[tauri::command] async fn verhub_submit_feedback( content: String, rating: Option, contact: String, + forward_to_github: bool, ) -> Result<(), String> { if content.trim().is_empty() { return Err(i18n::t(Msg::ErrFeedbackEmpty).to_string()); } + let contact = verhub::normalize_contact(&contact); + // 转换成 Issue 后要靠 GitHub 账号跟进,缺了服务端也不受理。 + if forward_to_github && contact.is_none() { + return Err(i18n::t(Msg::ErrFeedbackContactRequired).to_string()); + } 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()) + verhub::submit_feedback( + content, + rating.map(|r| r.clamp(1, 5)), + contact, + forward_to_github, + custom_data, + ) + .await + .map_err(|e| e.to_string()) } -/// 上报一段日志 #[tauri::command] async fn verhub_upload_log(content: String) -> Result<(), String> { let device_info = serde_json::json!({ @@ -480,26 +528,11 @@ async fn verhub_upload_log(content: String) -> Result<(), String> { .map_err(|e| e.to_string()) } -/// 最新日志文件的末尾若干行。 +/// 核心最近一次运行的日志:从该次运行的 `[START]` 起至今,压到上报预算以内。 #[tauri::command] -async fn recent_log_tail(lines: usize) -> String { - blocking(move || { - let dir = exe_dir().join(bosskey_core::logging::LOG_DIR_NAME); - let latest = std::fs::read_dir(&dir) - .ok() - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().is_file()) - .max_by_key(|e| e.metadata().and_then(|m| m.modified()).ok()); - let Some(entry) = latest else { - return String::new(); - }; - let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); - let tail: Vec<&str> = content.lines().rev().take(lines).collect(); - tail.into_iter().rev().collect::>().join("\n") - }) - .await +async fn current_session_log() -> String { + blocking(move || bosskey_core::logging::latest_session(&log_dir(), verhub::LOG_EXCERPT_MAX)) + .await } /// 系统版本描述,形如 `Microsoft Windows [版本 10.0.26200.1234]`。 @@ -557,13 +590,15 @@ pub fn run() { pssuspend_available, startup_action, app_info, + data_location, open_external, verhub_project_links, verhub_check_update, verhub_announcements, + verhub_feedback_options, verhub_submit_feedback, verhub_upload_log, - recent_log_tail, + current_session_log, ]) // 兜底:前端起不来时,5 秒后强制显示窗口。 .setup(|app| { diff --git a/apps/config/src-tauri/src/verhub.rs b/apps/config/src-tauri/src/verhub.rs index f9998e7..c381a64 100644 --- a/apps/config/src-tauri/src/verhub.rs +++ b/apps/config/src-tauri/src/verhub.rs @@ -22,6 +22,8 @@ pub const PLATFORM: Platform = Platform::Windows; const TIMEOUT: Duration = Duration::from_secs(10); const LOG_CONTENT_MAX: usize = 4096; +/// 上报正文里留给日志摘录的预算,其余部分留给错误描述与详情。 +pub const LOG_EXCERPT_MAX: usize = LOG_CONTENT_MAX * 3 / 5; type Result = verhub_sdk::Result; @@ -163,15 +165,45 @@ pub async fn announcements(limit: u32) -> Result> { .collect()) } +/// 反馈提交选项:服务端决定本项目能否把反馈转换为 GitHub Issue。 +#[derive(Debug, Clone, Default, Serialize)] +pub struct FeedbackOptions { + /// 是否开放「转换为 Issue」。为假时前端不显示该选项。 + pub github_forward_available: bool, + /// 选择转换时联系方式是否必填;转换不可用时恒为假。 + pub contact_required_for_forward: bool, +} + +pub async fn feedback_options() -> Result { + let resp = client()?.public().get_feedback_options().await?; + Ok(FeedbackOptions { + github_forward_available: resp.github_forward_available, + contact_required_for_forward: resp.contact_required_for_forward, + }) +} + +/// 规整联系方式:只有空白等同于未填写。 +pub fn normalize_contact(contact: &str) -> Option { + let trimmed = contact.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + /// 提交客户端反馈。`rating` 为 1..=5;`custom_data` 携带附加信息。 +/// +/// `forward_to_github` 为真时由 Verhub 侧的机器人把这条反馈建成 GitHub Issue: +/// 联系方式必填(SDK 本地即拒绝),且 Issue 创建失败时整条反馈不会被记录。 pub async fn submit_feedback( content: String, rating: Option, + contact: Option, + forward_to_github: bool, custom_data: serde_json::Value, ) -> Result<()> { let input = CreateFeedbackInput { content, rating, + contact, + forward_to_github: forward_to_github.then_some(true), platform: Some(PLATFORM), custom_data: json_object(custom_data), ..Default::default() @@ -192,7 +224,7 @@ pub async fn upload_log(content: &str, device_info: serde_json::Value) -> Result Ok(()) } -/// 项目公开链接(主页 / 仓库 / 文档等)的缓存有效期。链接极少变动,一天刷新一次足够。 +/// 项目公开链接的缓存有效期。 const PROJECT_CACHE_TTL_SECS: i64 = 24 * 60 * 60; /// 项目公开链接。所有字段都可能缺省(Verhub 上未填写);前端须自备回退链接。 @@ -259,7 +291,7 @@ fn unix_now() -> i64 { } /// 项目公开链接:内存缓存 → 磁盘缓存(`cache_path`)→ Verhub API 逐级回退。 -/// API 拉取失败时退回过期缓存(有旧数据总比没有强),完全没有缓存才报错。 +/// API 拉取失败时退回过期缓存,完全没有缓存才报错。 pub async fn project_links(cache_path: &Path) -> Result { let now = unix_now(); if let Some(cached) = cache_get() @@ -324,6 +356,16 @@ mod tests { assert_eq!(truncate_log("崩了"), "崩了"); } + #[test] + fn normalize_contact_treats_blank_as_absent() { + assert_eq!( + normalize_contact(" ivan@o5g.top "), + Some("ivan@o5g.top".into()) + ); + assert_eq!(normalize_contact(""), None); + assert_eq!(normalize_contact(" \t\n "), None); + } + #[test] fn log_level_maps_to_verhub_numbers() { assert_eq!(u8::from(LogLevel::Debug), 0); diff --git a/apps/config/src-tauri/tauri.conf.json b/apps/config/src-tauri/tauri.conf.json index aacf6f5..1d25cc2 100644 --- a/apps/config/src-tauri/tauri.conf.json +++ b/apps/config/src-tauri/tauri.conf.json @@ -1,7 +1,6 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Boss Key", - "version": "3.1.0-rc.1", "identifier": "cn.hanloth.bosskey.config", "build": { "frontendDist": "../dist", diff --git a/apps/config/ui/package-lock.json b/apps/config/ui/package-lock.json index c65391d..6038961 100644 --- a/apps/config/ui/package-lock.json +++ b/apps/config/ui/package-lock.json @@ -1,12 +1,10 @@ { "name": "bosskey-config-ui", - "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bosskey-config-ui", - "version": "3.0.0", "dependencies": { "@tauri-apps/api": "^2" }, diff --git a/apps/config/ui/package.json b/apps/config/ui/package.json index 61ca19b..c28fc8a 100644 --- a/apps/config/ui/package.json +++ b/apps/config/ui/package.json @@ -1,7 +1,6 @@ { "name": "bosskey-config-ui", "private": true, - "version": "3.1.0-rc.1", "type": "module", "scripts": { "dev": "vite", diff --git a/apps/config/ui/src/App.svelte b/apps/config/ui/src/App.svelte index e129fbc..ce3a152 100644 --- a/apps/config/ui/src/App.svelte +++ b/apps/config/ui/src/App.svelte @@ -12,6 +12,7 @@ import UpdateModal from "./components/UpdateModal.svelte"; import AnnouncementModal from "./components/AnnouncementModal.svelte"; import ErrorReportModal from "./components/ErrorReportModal.svelte"; + import DataNoticeModal from "./components/DataNoticeModal.svelte"; import Toast from "./components/Toast.svelte"; import { invoke, onAppEvent, win } from "./lib/ipc.js"; import { @@ -58,12 +59,12 @@ }); onMount(() => { - // 主题:main.js 已在挂载前应用过一次(免得启动屏配色跳变),这里只负责跟随系统变化。 + // main.js 已在挂载前应用过一次,这里只负责跟随系统变化。 const media = matchMedia("(prefers-color-scheme: dark)"); const onSystemTheme = () => applyTheme(loadPreference()); media.addEventListener("change", onSystemTheme); - // 配置到手、界面有内容可看了,才把启动屏淡掉;失败时也得淡掉,否则永远卡在启动屏。 + // 配置到手后才淡掉启动屏;失败时同样要淡掉,否则会一直卡住。 loadAll() .then(() => { autoSaveReady = true; @@ -141,6 +142,7 @@ + diff --git a/apps/config/ui/src/components/AboutPanel.svelte b/apps/config/ui/src/components/AboutPanel.svelte index 9631375..76bc7aa 100644 --- a/apps/config/ui/src/components/AboutPanel.svelte +++ b/apps/config/ui/src/components/AboutPanel.svelte @@ -1,11 +1,8 @@ + +{#if loc} + +
+

+ + {t("dataNotice.heading")} +

+

{t("dataNotice.reason", { dir: loc.program_dir })}

+

{t("dataNotice.stored", { dir: loc.dir })}

+

{t("dataNotice.fixTitle")}

+
    +
  • {t("dataNotice.fixMove")}
  • +
  • {t("dataNotice.fixPermission")}
  • +
  • {t("dataNotice.fixKeep")}
  • +
+
+ + {#snippet footer()} + + {/snippet} +
+{/if} + + diff --git a/apps/config/ui/src/components/ErrorReportModal.svelte b/apps/config/ui/src/components/ErrorReportModal.svelte index 7bc9cba..8f8e118 100644 --- a/apps/config/ui/src/components/ErrorReportModal.svelte +++ b/apps/config/ui/src/components/ErrorReportModal.svelte @@ -3,7 +3,7 @@ import IconTriangleAlert from "~icons/lucide/triangle-alert"; import Modal from "./Modal.svelte"; import { app, toast } from "../lib/state.svelte.js"; - import { recentLogTail, uploadLog } from "../lib/verhub.js"; + import { currentSessionLog, uploadLog } from "../lib/verhub.js"; import { t } from "../lib/i18n.svelte.js"; const report = $derived(app.errorReport); @@ -12,12 +12,11 @@ let sending = $state(false); let sent = $state(false); - // 弹框出现时取本地日志尾部供用户过目。 $effect(() => { if (!report) return; sent = false; logTail = ""; - recentLogTail(60) + currentSessionLog() .then((t) => (logTail = t)) .catch(() => (logTail = "")); }); diff --git a/apps/config/ui/src/components/Markdown.svelte b/apps/config/ui/src/components/Markdown.svelte index 2d865d7..6494375 100644 --- a/apps/config/ui/src/components/Markdown.svelte +++ b/apps/config/ui/src/components/Markdown.svelte @@ -9,7 +9,7 @@ const html = $derived(renderMarkdown(source)); - // 链接一律交给系统浏览器:webview 里真导航走了就回不到配置界面了。 + // 链接一律交给系统浏览器:webview 内真导航后回不到配置界面。 function onClick(e) { const a = e.target.closest?.("a[href]"); if (!a) return; @@ -70,7 +70,6 @@ .md :global(h6) { font-size: 13px; } - /* GitHub 的一二级标题带下边线 */ .md :global(h1), .md :global(h2) { padding-bottom: 5px; diff --git a/apps/config/ui/src/components/OptionsPanel.svelte b/apps/config/ui/src/components/OptionsPanel.svelte index b6cd291..78846d2 100644 --- a/apps/config/ui/src/components/OptionsPanel.svelte +++ b/apps/config/ui/src/components/OptionsPanel.svelte @@ -27,6 +27,16 @@ const s = $derived(app.config.setting); + // 日志输出等级,由低到高;value 与核心 Setting::log_level 的取值一致。 + // 文案键须为字面量,供 scripts/i18n-check.ps1 静态检查。 + const LOG_LEVELS = [ + { value: "debug", label: "options.logLevel.debug" }, + { value: "info", label: "options.logLevel.info" }, + { value: "warn", label: "options.logLevel.warn" }, + { value: "error", label: "options.logLevel.error" }, + ]; + const logDisabled = $derived(s.log_retention_days === 0); + // 自启注册方式的标注;仅在已开启自启时显示。 const autostartMethodText = $derived( !app.autostart @@ -202,6 +212,19 @@ {/snippet} + + {#snippet control()} + + {/snippet} +
diff --git a/apps/config/ui/src/components/UpdateModal.svelte b/apps/config/ui/src/components/UpdateModal.svelte index c9af80a..9b3e9fb 100644 --- a/apps/config/ui/src/components/UpdateModal.svelte +++ b/apps/config/ui/src/components/UpdateModal.svelte @@ -1,7 +1,6 @@