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/release.yml b/.github/workflows/release.yml index fb467e6..a265f85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -94,11 +94,10 @@ jobs: run: | $headers = @{ Authorization = "Bearer $env:STS_TOKEN"; 'Content-Type' = 'application/json' } + # 版本号只落在这两处,其余地方构建时取自 Cargo.toml(见 scripts/version.ps1) $files = @( 'Cargo.toml', - 'Cargo.lock', - 'apps/config/src-tauri/tauri.conf.json', - 'apps/config/ui/package.json' + 'Cargo.lock' ) $additions = foreach ($f in $files) { @{ path = $f; contents = [Convert]::ToBase64String([IO.File]::ReadAllBytes($f)) } 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..244d407 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()) } @@ -484,7 +517,7 @@ async fn verhub_upload_log(content: String) -> Result<(), String> { #[tauri::command] async fn recent_log_tail(lines: usize) -> String { blocking(move || { - let dir = exe_dir().join(bosskey_core::logging::LOG_DIR_NAME); + let dir = log_dir(); let latest = std::fs::read_dir(&dir) .ok() .into_iter() @@ -557,6 +590,7 @@ pub fn run() { pssuspend_available, startup_action, app_info, + data_location, open_external, verhub_project_links, verhub_check_update, 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..e31ac53 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 { @@ -141,6 +142,7 @@ + diff --git a/apps/config/ui/src/components/DataNoticeModal.svelte b/apps/config/ui/src/components/DataNoticeModal.svelte new file mode 100644 index 0000000..e749c95 --- /dev/null +++ b/apps/config/ui/src/components/DataNoticeModal.svelte @@ -0,0 +1,61 @@ + + +{#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/lib/state.svelte.js b/apps/config/ui/src/lib/state.svelte.js index 5e272f9..632bcb1 100644 --- a/apps/config/ui/src/lib/state.svelte.js +++ b/apps/config/ui/src/lib/state.svelte.js @@ -47,6 +47,10 @@ export const app = $state({ pendingAnnouncement: null, /** 出错报告 { message, detail };有值即弹出错误框。 */ errorReport: null, + /** 数据目录 { dir, program_dir, kind };kind 为 portable_fallback 时提示权限问题。 */ + dataLocation: null, + /** 便携版回退提示弹窗是否打开。 */ + dataNoticeOpen: false, }); // 按「理由」计数暂停核心监控,最后一个理由撤销后才恢复。 @@ -140,6 +144,11 @@ export async function loadAll() { app.info = info; }), invoke("pssuspend_available").then((v) => (app.pssuspend = !!v)), + invoke("data_location").then((loc) => { + app.dataLocation = loc; + // 便携版本该把设置放在程序目录里,回退了就说明那里写不进去,得让用户知道。 + app.dataNoticeOpen = loc?.kind === "portable_fallback"; + }), ]; // 项目链接拉不到时静默——「关于」页有内置回退链接,不值得打扰用户。 verhub diff --git a/apps/config/ui/src/locales/en.js b/apps/config/ui/src/locales/en.js index 9e04169..5ef527f 100644 --- a/apps/config/ui/src/locales/en.js +++ b/apps/config/ui/src/locales/en.js @@ -205,17 +205,16 @@ export default { "options.hideIcon": "Also hide Boss Key's tray icon", "options.hideIconDesc": "Hide Boss Key's own tray icon along with the windows for more discretion; other programs' tray icons are not affected. Press the hotkey again to restore it.", - "options.sendPause": "Send the pause key before hiding (beta)", - "options.sendPauseDesc": - "Send the media pause key before hiding (pausing any playing video or music); adds roughly 0.2 seconds of delay.", + "options.sendPause": "Send the pause key before hiding", + "options.sendPauseDesc": "Send the media pause key before hiding (pausing any playing video or music).", "options.freezeCard": "Process freezing", - "options.freezeAfterHide": "Freeze processes when hiding (beta)", + "options.freezeAfterHide": "Freeze processes when hiding", "options.freezeAfterHideDesc": - "Suspend the target process once hidden to lower its CPU and memory usage; it is resumed automatically when restored.", + "Suspend the target process once hidden to lower its CPU and memory usage; it is resumed automatically when restored. May add some delay when hiding and restoring.", "options.enhancedFreeze": "Use enhanced freezing", "options.enhancedFreezeDesc": - "Freeze via pssuspend64.exe instead. Requires that file in the program folder and the core running as administrator.", + "Freeze via pssuspend64.exe instead. Requires that file in the program folder and the core running as administrator. May add some delay when hiding and restoring.", "options.enhancedFreezeBlocked": "Currently unavailable: {reasons}.", "options.needFreezeFirst": "Enable “Freeze processes when hiding” first", "options.blockedCoreStopped": "the core is not running", @@ -223,7 +222,7 @@ export default { "options.blockedNoPssuspend": "pssuspend64.exe is missing from the program folder", "options.freezeWholeTree": "Freeze the whole process tree (beta)", "options.freezeWholeTreeDesc": - "Recursively freeze the entire child-process tree of the matched program for a more thorough freeze; applies to both normal and enhanced freezing. May affect background tasks of those child processes", + "Recursively freeze the entire child-process tree of the matched program for a more thorough freeze; applies to both normal and enhanced freezing. May affect background tasks of those child processes and adds more delay when hiding and restoring.", "options.freezeNoteBefore": "Enhanced freezing requires downloading", "options.freezeNoteAfter": "and placing pssuspend64.exe in the program folder, with the core running as administrator.", @@ -333,6 +332,16 @@ export default { "announce.pinned": "Pinned", "announce.gotIt": "Got it", + "dataNotice.title": "Where your settings are stored", + "dataNotice.heading": "The program folder is not writable", + "dataNotice.reason": "This account has no write permission for {dir} — usually because the program sits in a system folder such as Program Files, or on a read-only drive.", + "dataNotice.stored": "Your settings are stored in {dir} instead. Nothing else is affected.", + "dataNotice.fixTitle": "To keep the settings with the program folder, you can:", + "dataNotice.fixMove": "move the whole program folder somewhere writable — the desktop, your documents, or an ordinary folder on another drive;", + "dataNotice.fixPermission": "or right-click the folder → Properties → Security → Edit, and grant your account the “Write” permission (needs administrator approval);", + "dataNotice.fixKeep": "or do nothing — everything works the same, the settings just will not travel with a copy of the program folder.", + "dataNotice.gotIt": "Got it", + "error.title": "Something went wrong", "error.summary": "Expand to review what will be sent (the log may contain window titles and program paths — please check first)", diff --git a/apps/config/ui/src/locales/zh-CN.js b/apps/config/ui/src/locales/zh-CN.js index f0b3cac..0210266 100644 --- a/apps/config/ui/src/locales/zh-CN.js +++ b/apps/config/ui/src/locales/zh-CN.js @@ -198,14 +198,16 @@ export default { "options.hideIcon": "同时隐藏 Boss Key 托盘图标", "options.hideIconDesc": "隐藏窗口时连 Boss Key 自身的托盘图标一起藏起,更隐蔽;不影响其他程序的托盘图标。再次触发热键可恢复。", - "options.sendPause": "隐藏前发送暂停键(Beta)", - "options.sendPauseDesc": "隐藏前先发送媒体暂停键(暂停正在播放的视频 / 音乐),会带来约 0.2 秒延迟。", + "options.sendPause": "隐藏前发送暂停键", + "options.sendPauseDesc": "隐藏前先发送媒体暂停键(暂停正在播放的视频 / 音乐)。", "options.freezeCard": "进程冻结", - "options.freezeAfterHide": "隐藏窗口时冻结进程(Beta)", - "options.freezeAfterHideDesc": "隐藏后挂起目标进程,降低其 CPU / 内存占用;恢复显示时自动解冻。", + "options.freezeAfterHide": "隐藏窗口时冻结进程", + "options.freezeAfterHideDesc": + "隐藏后挂起目标进程,降低其 CPU / 内存占用;恢复显示时自动解冻。可能会带来一定的隐藏 / 恢复延迟。", "options.enhancedFreeze": "使用增强冻结", - "options.enhancedFreezeDesc": "改用 pssuspend64.exe 冻结。需在程序目录放置该文件,且核心以管理员身份运行。", + "options.enhancedFreezeDesc": + "改用 pssuspend64.exe 冻结。需在程序目录放置该文件,且核心以管理员身份运行。可能会带来一定的隐藏 / 恢复延迟。", "options.enhancedFreezeBlocked": "当前不可用:{reasons}。", "options.needFreezeFirst": "需先开启「隐藏窗口时冻结进程」", "options.blockedCoreStopped": "核心未运行", @@ -213,7 +215,7 @@ export default { "options.blockedNoPssuspend": "程序目录缺少 pssuspend64.exe", "options.freezeWholeTree": "冻结完整进程(Beta)", "options.freezeWholeTreeDesc": - "递归冻结命中程序的整棵子进程树,冻结更彻底;对普通与增强冻结均生效。可能影响这些子进程的后台任务", + "递归冻结命中程序的整棵子进程树,冻结更彻底;对普通与增强冻结均生效。可能影响这些子进程的后台任务,并带来更高的隐藏 / 恢复延迟。", "options.freezeNoteBefore": "增强冻结需下载", "options.freezeNoteAfter": "并将 pssuspend64.exe 放入程序目录,且核心以管理员身份运行。", "options.recheck": "重新检测", @@ -317,6 +319,16 @@ export default { "announce.pinned": "置顶", "announce.gotIt": "知道了", + "dataNotice.title": "设置的存放位置", + "dataNotice.heading": "程序所在目录无法写入", + "dataNotice.reason": "当前账户对 {dir} 没有写入权限,多半是程序放在了 Program Files 等系统目录下,或所在磁盘是只读的。", + "dataNotice.stored": "设置已改存到 {dir},程序功能不受影响。", + "dataNotice.fixTitle": "想让设置跟着程序目录走,可以:", + "dataNotice.fixMove": "把整个程序文件夹移到有写入权限的位置,例如桌面、文档或另一个磁盘的普通目录;", + "dataNotice.fixPermission": "或右键该文件夹 → 属性 → 安全 → 编辑,为当前用户勾选「写入」权限(需要管理员确认);", + "dataNotice.fixKeep": "也可以什么都不做——设置留在用户目录同样正常使用,只是复制程序文件夹时不会一起带走。", + "dataNotice.gotIt": "知道了", + "error.title": "出错了", "error.summary": "展开查看将要发送的内容(日志里可能含窗口标题与程序路径,请先检查)", "error.hint": "日志仅在点击上报后发送,不会自动上报。", diff --git a/apps/config/ui/src/locales/zh-TW.js b/apps/config/ui/src/locales/zh-TW.js index ca752e0..ddf4d69 100644 --- a/apps/config/ui/src/locales/zh-TW.js +++ b/apps/config/ui/src/locales/zh-TW.js @@ -197,17 +197,16 @@ export default { "options.hideIcon": "一併隱藏 Boss Key 通知區域圖示", "options.hideIconDesc": "隱藏視窗時連 Boss Key 自身的通知區域圖示一起隱藏,更為隱密;不影響其他程式的通知區域圖示。再次觸發快速鍵即可復原。", - "options.sendPause": "隱藏前傳送暫停鍵(Beta)", - "options.sendPauseDesc": - "隱藏前先傳送媒體暫停鍵(暫停正在播放的影片/音樂),會造成約 0.2 秒延遲。", + "options.sendPause": "隱藏前傳送暫停鍵", + "options.sendPauseDesc": "隱藏前先傳送媒體暫停鍵(暫停正在播放的影片/音樂)。", "options.freezeCard": "程序凍結", - "options.freezeAfterHide": "隱藏視窗時凍結程序(Beta)", + "options.freezeAfterHide": "隱藏視窗時凍結程序", "options.freezeAfterHideDesc": - "隱藏後暫停目標程序,降低其 CPU/記憶體佔用;復原顯示時自動解除凍結。", + "隱藏後暫停目標程序,降低其 CPU/記憶體佔用;復原顯示時自動解除凍結。可能造成一定的隱藏/復原延遲。", "options.enhancedFreeze": "使用增強凍結", "options.enhancedFreezeDesc": - "改用 pssuspend64.exe 凍結。需在程式資料夾放置該檔案,且核心以系統管理員身分執行。", + "改用 pssuspend64.exe 凍結。需在程式資料夾放置該檔案,且核心以系統管理員身分執行。可能造成一定的隱藏/復原延遲。", "options.enhancedFreezeBlocked": "目前無法使用:{reasons}。", "options.needFreezeFirst": "需先開啟「隱藏視窗時凍結程序」", "options.blockedCoreStopped": "核心未執行", @@ -215,7 +214,7 @@ export default { "options.blockedNoPssuspend": "程式資料夾缺少 pssuspend64.exe", "options.freezeWholeTree": "凍結完整程序(Beta)", "options.freezeWholeTreeDesc": - "遞迴凍結命中程式的整棵子程序樹,凍結更徹底;對一般與增強凍結均生效。可能影響這些子程序的背景工作", + "遞迴凍結命中程式的整棵子程序樹,凍結更徹底;對一般與增強凍結均生效。可能影響這些子程序的背景工作,並造成更高的隱藏/復原延遲。", "options.freezeNoteBefore": "增強凍結需下載", "options.freezeNoteAfter": "並將 pssuspend64.exe 放入程式資料夾,且核心以系統管理員身分執行。", "options.recheck": "重新偵測", @@ -321,6 +320,16 @@ export default { "announce.pinned": "置頂", "announce.gotIt": "知道了", + "dataNotice.title": "設定的存放位置", + "dataNotice.heading": "程式所在資料夾無法寫入", + "dataNotice.reason": "目前帳戶對 {dir} 沒有寫入權限,多半是程式放在了 Program Files 等系統資料夾下,或所在磁碟是唯讀的。", + "dataNotice.stored": "設定已改存到 {dir},程式功能不受影響。", + "dataNotice.fixTitle": "想讓設定跟著程式資料夾走,可以:", + "dataNotice.fixMove": "把整個程式資料夾移到有寫入權限的位置,例如桌面、文件或另一個磁碟的一般資料夾;", + "dataNotice.fixPermission": "或在該資料夾按右鍵 → 內容 → 安全性 → 編輯,為目前使用者勾選「寫入」權限(需要系統管理員確認);", + "dataNotice.fixKeep": "也可以什麼都不做——設定留在使用者資料夾同樣正常使用,只是複製程式資料夾時不會一起帶走。", + "dataNotice.gotIt": "知道了", + "error.title": "發生錯誤", "error.summary": "展開檢視將要傳送的內容(記錄檔裡可能含視窗標題與程式路徑,請先檢查)", "error.hint": "記錄檔僅在點按回報後才傳送,不會自動回報。", diff --git a/crates/common/src/config.rs b/crates/common/src/config.rs index 1ad23f5..a05454a 100644 --- a/crates/common/src/config.rs +++ b/crates/common/src/config.rs @@ -48,12 +48,25 @@ fn default_language() -> String { #[derive(Debug, thiserror::Error)] pub enum ConfigError { - #[error("配置文件读写错误: {0}")] - Io(#[from] std::io::Error), + /// 带上出错的路径:用户据此才能判断是装在了不可写的目录,还是被杀软拦了。 + #[error("配置文件读写错误: {source}(路径: {path})")] + Io { + path: String, + source: std::io::Error, + }, #[error("配置文件 JSON 解析错误: {0}")] Json(#[from] serde_json::Error), } +impl ConfigError { + fn io(path: &Path, source: std::io::Error) -> Self { + Self::Io { + path: path.display().to_string(), + source, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Hotkey { #[serde(default = "default_hide_hotkey")] @@ -447,8 +460,14 @@ pub struct Verhub { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Config { + /// 配置 schema 版本([`APP_CONFIG_VERSION`]),结构变动时才动。 #[serde(default = "default_version")] pub version: String, + /// 上次运行过的**程序**版本([`crate::APP_VERSION`])。 + /// 与之不符即「更新后首次启动」,核心据此自动拉起配置程序。 + /// 缺省置空:老配置与全新配置都会被判为版本已变,各弹一次。 + #[serde(default)] + pub app_version: String, #[serde(default)] pub history: Vec, #[serde(default)] @@ -476,6 +495,7 @@ impl Default for Config { fn default() -> Self { Self { version: default_version(), + app_version: String::new(), history: Vec::new(), frozen_pids: Vec::new(), hotkey: Hotkey::default(), @@ -520,7 +540,7 @@ impl Config { Ok((config, parse_error)) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok((Config::default(), None)), - Err(e) => Err(ConfigError::Io(e)), + Err(e) => Err(ConfigError::io(path, e)), } } @@ -537,17 +557,32 @@ impl Config { self.setting.normalize(); } + /// 写入配置。先写同目录下的临时文件再原子替换:写到一半失败(磁盘满、 + /// 杀软拦截)也不会把原文件截断成半截,用户的规则不会因此丢光。 pub fn save(&self, path: &Path) -> Result<(), ConfigError> { + let json = self.to_json()?; if let Some(parent) = path.parent() && !parent.as_os_str().is_empty() { - std::fs::create_dir_all(parent)?; + std::fs::create_dir_all(parent).map_err(|e| ConfigError::io(parent, e))?; } - std::fs::write(path, self.to_json()?)?; - Ok(()) + let tmp = tmp_path(path); + std::fs::write(&tmp, json).map_err(|e| ConfigError::io(&tmp, e))?; + // Windows 下 rename 走 MOVEFILE_REPLACE_EXISTING,同目录替换是原子的。 + std::fs::rename(&tmp, path).map_err(|e| { + let _ = std::fs::remove_file(&tmp); + ConfigError::io(path, e) + }) } } +/// 原子写入用的临时文件路径(与目标同目录,rename 才是原子的)。 +fn tmp_path(path: &Path) -> std::path::PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(".tmp"); + path.with_file_name(name) +} + #[cfg(test)] mod tests { use super::*; @@ -805,6 +840,31 @@ mod tests { ); } + #[test] + fn app_version_is_recorded_and_defaults_to_empty() { + assert_eq!( + Config::default().app_version, + "", + "默认值不写死当前版本:全新配置也要走一次「首次启动」流程" + ); + let c = Config::from_json(r#"{"setting": {}}"#).unwrap(); + assert_eq!(c.app_version, "", "老配置没有该字段,视为未记录过"); + + let c = Config { + app_version: "3.1.0".to_string(), + ..Config::default() + }; + let back = Config::from_json(&c.to_json().unwrap()).unwrap(); + assert_eq!(back.app_version, "3.1.0", "写回后应保留"); + } + + #[test] + fn schema_version_and_app_version_are_separate_fields() { + let json = Config::default().to_json().unwrap(); + assert!(json.contains("\"version\""), "配置 schema 版本仍要写出"); + assert!(json.contains("\"app_version\""), "程序版本单独记一份"); + } + #[test] fn autostart_admin_round_trips() { assert!(!Setting::default().autostart_admin, "默认关(普通权限)"); diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index d6e44a4..c8d6e76 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -3,6 +3,7 @@ pub mod i18n; pub mod ipc; pub mod matching; pub mod model; +pub mod paths; pub use config::{ Config, ConfigError, Hotkey, MouseButton, MouseSetting, Notifications, Setting, Verhub, @@ -13,7 +14,11 @@ pub use matching::{WindowResolution, match_process_rule, regex_is_valid, resolve pub use model::{ProcessRule, WindowInfo, WindowRule}; pub const APP_NAME: &str = "Boss Key"; +/// 配置 schema 版本:配置结构变动时才动,与程序版本无关。 pub const APP_CONFIG_VERSION: &str = "v3.0.0.0"; +/// 程序版本(workspace 版本号,唯一真源是根 `Cargo.toml`)。 +/// 核心据它判断「更新后首次启动」,见 [`Config::app_version`]。 +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const NO_TITLE: &str = "无标题窗口"; /// 命令行参数:让配置程序启动后直达「窗口恢复工具」(核心托盘菜单使用)。 diff --git a/crates/common/src/paths.rs b/crates/common/src/paths.rs new file mode 100644 index 0000000..39e9cc1 --- /dev/null +++ b/crates/common/src/paths.rs @@ -0,0 +1,170 @@ +//! 数据文件(配置、日志、恢复文件、缓存)的目录定位。 +//! +//! 安装版与便携版分开对待: +//! +//! - **安装版**用 `%APPDATA%\BossKey`。安装包可以装进 `Program Files`,那里普通权限 +//! 进程不可写,配置程序每次保存都会得到 `os error 5`。 +//! - **便携版**用 exe 同目录,拷走整个文件夹就带走了全部设置。 +//! 目录写不进去时退回 `%APPDATA%\BossKey`,程序照常能用,界面据此提示这是权限问题。 +//! +//! 靠程序目录里有没有安装痕迹来分辨:安装包会放一份 [`INSTALLED_MARKER`], +//! 卸载程序 `unins*.exe` 也在同一目录,便携版压缩包里两者都没有。 +//! +//! 判断依据是文件而非进程权限,核心与配置程序因此必然得出同一结果——核心可能以 +//! 管理员身份运行、配置程序不会,若各按自己能否写入来选目录,两边会各读一份配置。 + +use std::path::{Path, PathBuf}; + +/// 配置文件名。 +pub const CONFIG_FILE_NAME: &str = "config.json"; +/// 数据目录在 `%APPDATA%` 下的名字。 +pub const USER_DIR_NAME: &str = "BossKey"; +/// 安装版标记文件,由安装包放进程序目录,卸载时随之移除。 +pub const INSTALLED_MARKER: &str = "installed.marker"; + +/// 数据目录为何是它。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DataDirKind { + /// 安装版,数据在用户目录。 + Installed, + /// 便携版,数据在程序目录。 + Portable, + /// 便携版,但程序目录写不进去,退回用户目录。 + PortableFallback, +} + +/// 数据目录的定位结果。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DataDir { + /// 实际使用的目录。 + pub dir: PathBuf, + pub kind: DataDirKind, + /// 程序目录。[`DataDirKind::PortableFallback`] 时即写不进去的那个。 + pub program_dir: PathBuf, +} + +/// 当前 exe 所在目录;取不到时退回当前工作目录。 +pub 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(".")) +} + +/// 用户目录 `%APPDATA%\BossKey`;取不到 `%APPDATA%` 时退回 exe 同目录。 +pub fn user_data_dir() -> PathBuf { + match std::env::var_os("APPDATA") { + Some(appdata) if !appdata.is_empty() => PathBuf::from(appdata).join(USER_DIR_NAME), + _ => exe_dir(), + } +} + +/// 程序目录里有没有安装痕迹。 +/// +/// 认两样东西:安装包放的 [`INSTALLED_MARKER`],以及卸载程序 `unins*.exe`。 +/// 后者是兜底——标记文件被误删时仍认得出是安装版,不至于把数据写回 `Program Files`。 +/// 卸载程序的序号会随重复安装递增(`unins000` / `unins001`…),故按前缀匹配。 +pub fn is_installed(program_dir: &Path) -> bool { + if program_dir.join(INSTALLED_MARKER).exists() { + return true; + } + let Ok(entries) = std::fs::read_dir(program_dir) else { + return false; + }; + entries.flatten().any(|entry| { + let name = entry.file_name().to_string_lossy().to_lowercase(); + name.starts_with("unins") && name.ends_with(".exe") + }) +} + +/// 当前进程能否在 `dir` 下建文件。探针文件用后即删。 +pub fn dir_writable(dir: &Path) -> bool { + let probe = dir.join(format!(".BossKey-write-probe-{}", std::process::id())); + match std::fs::File::create(&probe) { + Ok(file) => { + drop(file); + let _ = std::fs::remove_file(&probe); + true + } + Err(_) => false, + } +} + +/// 把程序目录里的旧配置搬到用户目录:先复制,再尽力删掉原文件。 +/// +/// 目标已有配置就不动它——那是当前在用的一份,旧文件不得覆盖,也不去删。 +/// 删不掉(`Program Files` 下没有写权限、文件被占用)就留在原处:安装版只认用户目录, +/// 旧文件不会再被读到。 +fn migrate_config(program_dir: &Path, user_dir: &Path) { + let old = program_dir.join(CONFIG_FILE_NAME); + let new = user_dir.join(CONFIG_FILE_NAME); + if !old.exists() || new.exists() { + return; + } + if std::fs::copy(&old, &new).is_ok() { + let _ = std::fs::remove_file(&old); + } +} + +/// 定位数据目录。 +/// +/// `installed` 与 `portable_writable` 由调用方探测(生产走 [`is_installed`] 与 +/// [`dir_writable`]),便于测试注入。 +pub fn resolve_data_dir( + program_dir: &Path, + user_dir: &Path, + installed: bool, + portable_writable: bool, +) -> DataDir { + let located = |dir: PathBuf, kind: DataDirKind| DataDir { + dir, + kind, + program_dir: program_dir.to_path_buf(), + }; + if !installed && portable_writable { + return located(program_dir.to_path_buf(), DataDirKind::Portable); + } + let kind = if installed { + DataDirKind::Installed + } else { + DataDirKind::PortableFallback + }; + if std::fs::create_dir_all(user_dir).is_err() { + // 用户目录也建不出来时无处可去。保存多半仍会失败,但错误里带得出路径。 + return located(program_dir.to_path_buf(), kind); + } + migrate_config(program_dir, user_dir); + located(user_dir.to_path_buf(), kind) +} + +/// 本次运行使用的数据目录。核心与配置程序共用,两边必须得出同一结果。 +pub fn locate() -> DataDir { + let program_dir = exe_dir(); + let installed = is_installed(&program_dir); + // 安装版结果一样是用户目录,没必要再往程序目录里试写一次。 + let portable_writable = !installed && dir_writable(&program_dir); + resolve_data_dir(&program_dir, &user_data_dir(), installed, portable_writable) +} + +/// 本次运行使用的数据目录路径。 +pub fn data_dir() -> PathBuf { + locate().dir +} + +/// 配置文件路径。 +pub fn config_path() -> PathBuf { + data_dir().join(CONFIG_FILE_NAME) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn user_data_dir_sits_under_appdata() { + let dir = user_data_dir(); + if let Some(appdata) = std::env::var_os("APPDATA") { + assert_eq!(dir, PathBuf::from(appdata).join(USER_DIR_NAME)); + } + } +} diff --git a/crates/common/tests/config_file_io.rs b/crates/common/tests/config_file_io.rs index d004aac..004b311 100644 --- a/crates/common/tests/config_file_io.rs +++ b/crates/common/tests/config_file_io.rs @@ -71,6 +71,55 @@ fn load_reporting_is_quiet_for_healthy_and_missing_files() { assert_eq!(parse_error, None, "正常文件不应报告解析失败"); } +#[test] +fn save_leaves_no_temp_file_behind() { + let dir = tempfile::tempdir().unwrap(); + Config::default() + .save(&dir.path().join("config.json")) + .unwrap(); + + let names: Vec = std::fs::read_dir(dir.path()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["config.json".to_string()], "临时文件须已改名"); +} + +#[test] +fn a_failed_save_keeps_the_previous_file_intact() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.json"); + + let mut cfg = Config::default(); + cfg.hotkey.hide_hotkey = "Ctrl+Shift+B".to_string(); + cfg.save(&path).unwrap(); + + // 占住临时文件名(这里用目录),逼真地模拟写入中途失败(磁盘满、杀软拦截)。 + std::fs::create_dir(dir.path().join("config.json.tmp")).unwrap(); + let err = Config::default().save(&path).unwrap_err(); + + let kept = Config::load(&path).unwrap(); + assert_eq!( + kept.hotkey.hide_hotkey, "Ctrl+Shift+B", + "写入失败不得把原配置截断,用户的规则不能因此丢光: {err}" + ); +} + +#[test] +fn io_errors_name_the_path_they_failed_on() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("locked").join("config.json"); + // 父路径是个文件,创建目录必然失败。 + std::fs::write(dir.path().join("locked"), "occupied").unwrap(); + + let err = Config::default().save(&path).unwrap_err().to_string(); + assert!( + err.contains("locked"), + "报错须带上实际路径,否则用户无从判断问题出在哪个目录: {err}" + ); +} + #[test] fn save_creates_missing_parent_directories() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/common/tests/data_dir_resolution.rs b/crates/common/tests/data_dir_resolution.rs new file mode 100644 index 0000000..3c732d0 --- /dev/null +++ b/crates/common/tests/data_dir_resolution.rs @@ -0,0 +1,202 @@ +//! 数据目录定位:安装版用 `%APPDATA%\BossKey`,便携版用程序目录,写不进去才回退。 + +use std::path::Path; + +use bosskey_common::paths::{self, CONFIG_FILE_NAME, DataDirKind, INSTALLED_MARKER}; + +fn write(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(path, content).unwrap(); +} + +#[test] +fn a_portable_copy_keeps_its_data_next_to_the_exe() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + + let located = paths::resolve_data_dir(program.path(), &user_dir, false, true); + + assert_eq!(located.dir, program.path(), "拷走整个文件夹就带走了设置"); + assert_eq!(located.kind, DataDirKind::Portable); + assert!(!user_dir.exists(), "不该无谓地在用户目录下留空文件夹"); +} + +#[test] +fn an_installed_copy_uses_the_user_dir() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + + // 装在 Program Files 下的核心提权后写得进程序目录,但仍须用用户目录: + // 普通权限的配置程序写不进去,两边不能各读一份。 + let located = paths::resolve_data_dir(program.path(), &user_dir, true, true); + + assert_eq!(located.dir, user_dir); + assert_eq!(located.kind, DataDirKind::Installed); + assert!(user_dir.is_dir(), "目录须就地创建,否则首次保存仍会失败"); +} + +#[test] +fn an_unwritable_portable_copy_falls_back_and_says_so() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + + let located = paths::resolve_data_dir(program.path(), &user_dir, false, false); + + assert_eq!(located.dir, user_dir, "写不进去也得让程序能用"); + assert_eq!( + located.kind, + DataDirKind::PortableFallback, + "界面要据此提示用户这是权限问题,不能与正常的便携版混为一谈" + ); + assert_eq!( + located.program_dir, + program.path(), + "提示里要点名是哪个目录" + ); +} + +#[test] +fn the_marker_file_identifies_an_installed_copy() { + let program = tempfile::tempdir().unwrap(); + assert!( + !paths::is_installed(program.path()), + "压缩包解压出来的是便携版" + ); + + write(&program.path().join(INSTALLED_MARKER), "installed\n"); + assert!(paths::is_installed(program.path())); +} + +#[test] +fn the_uninstaller_identifies_an_installed_copy_without_the_marker() { + let program = tempfile::tempdir().unwrap(); + // 序号随重复安装递增,不能只认 unins000。 + write(&program.path().join("unins001.exe"), ""); + + assert!( + paths::is_installed(program.path()), + "标记文件被误删也不能把数据写回 Program Files" + ); +} + +#[test] +fn a_lookalike_file_is_not_mistaken_for_an_uninstaller() { + let program = tempfile::tempdir().unwrap(); + write(&program.path().join("uninstall-notes.txt"), ""); + write(&program.path().join("unins000.exe.bak"), ""); + + assert!( + !paths::is_installed(program.path()), + "便携版被误判成安装版的话,设置就不跟着文件夹走了" + ); +} + +#[test] +fn an_installed_copy_takes_over_a_config_left_in_the_program_dir() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + let old = program.path().join(CONFIG_FILE_NAME); + write(&old, r#"{"hotkey": {"hide_hotkey": "Ctrl+Shift+B"}}"#); + + paths::resolve_data_dir(program.path(), &user_dir, true, false); + + let migrated = std::fs::read_to_string(user_dir.join(CONFIG_FILE_NAME)).unwrap(); + assert!( + migrated.contains("Ctrl+Shift+B"), + "换位置不能让用户的设置凭空消失: {migrated}" + ); + assert!(!old.exists(), "搬走后旧位置不留副本"); +} + +#[test] +fn an_undeletable_original_is_left_where_it_is() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + let old = program.path().join(CONFIG_FILE_NAME); + write(&old, r#"{"version": "old"}"#); + // 打开着的文件在 Windows 上删不掉,等价于 Program Files 下没有写权限的情形。 + let hold = std::fs::File::open(&old).unwrap(); + + let located = paths::resolve_data_dir(program.path(), &user_dir, true, false); + + drop(hold); + assert_eq!(located.dir, user_dir, "删不掉旧文件不影响迁移结果"); + let migrated = std::fs::read_to_string(user_dir.join(CONFIG_FILE_NAME)).unwrap(); + assert!(migrated.contains("old"), "内容仍须搬过去"); +} + +#[test] +fn a_config_already_in_the_user_dir_is_not_overwritten() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + write(&user_dir.join(CONFIG_FILE_NAME), r#"{"version": "user"}"#); + let old = program.path().join(CONFIG_FILE_NAME); + write(&old, r#"{"version": "program"}"#); + + paths::resolve_data_dir(program.path(), &user_dir, true, false); + + let kept = std::fs::read_to_string(user_dir.join(CONFIG_FILE_NAME)).unwrap(); + assert!( + kept.contains("user"), + "迁移只补空缺:正在用的那份配置不得被旧位置的文件盖掉" + ); + assert!(old.exists(), "没搬走的文件不得删除,那可能是用户还要的东西"); +} + +#[test] +fn migration_is_idempotent() { + let program = tempfile::tempdir().unwrap(); + let user = tempfile::tempdir().unwrap(); + let user_dir = user.path().join("BossKey"); + write( + &program.path().join(CONFIG_FILE_NAME), + r#"{"version": "old"}"#, + ); + + paths::resolve_data_dir(program.path(), &user_dir, true, false); + write(&user_dir.join(CONFIG_FILE_NAME), r#"{"version": "new"}"#); + paths::resolve_data_dir(program.path(), &user_dir, true, false); + + let kept = std::fs::read_to_string(user_dir.join(CONFIG_FILE_NAME)).unwrap(); + assert!(kept.contains("new"), "二次定位不得用旧文件盖掉新配置"); +} + +#[test] +fn resolving_into_the_same_dir_keeps_the_config() { + // %APPDATA% 取不到时用户目录退回程序目录,此时迁移的源与目标是同一个文件。 + let program = tempfile::tempdir().unwrap(); + write( + &program.path().join(CONFIG_FILE_NAME), + r#"{"version": "same"}"#, + ); + + let located = paths::resolve_data_dir(program.path(), program.path(), true, false); + + assert_eq!(located.dir, program.path()); + let kept = std::fs::read_to_string(program.path().join(CONFIG_FILE_NAME)).unwrap(); + assert!(kept.contains("same"), "源与目标相同时不得把配置搬没了"); +} + +#[test] +fn writability_probe_reports_truth_and_leaves_nothing_behind() { + let dir = tempfile::tempdir().unwrap(); + assert!(paths::dir_writable(dir.path())); + assert_eq!( + std::fs::read_dir(dir.path()).unwrap().count(), + 0, + "探针文件必须用后即删" + ); + + assert!( + !paths::dir_writable(&dir.path().join("not_there")), + "目录不存在即不可写" + ); +} diff --git a/crates/core/build.rs b/crates/core/build.rs index 0517705..ee7c21c 100644 --- a/crates/core/build.rs +++ b/crates/core/build.rs @@ -1,14 +1,33 @@ //! 构建脚本:为核心 exe 嵌入 Windows 资源(应用清单、版本信息、进程图标)。 +//! +//! 版本号只认 Cargo.toml:文件版本信息由 tauri-winres 取自 `CARGO_PKG_VERSION`, +//! 清单里的 `assemblyIdentity version="{VERSION}"` 由本脚本按同一来源填入。 +//! +//! manifest.xml 只能用 ASCII,且 `assemblyIdentity` 必须是 `assembly` 的第一个子元素—— +//! 中文注释在嵌入时会被按非 UTF-8 编码写坏,两者都会让 exe 以「并行配置不正确」拒绝启动。 + +/// 清单的 `assemblyIdentity` 只接受纯数字四段号:`3.1.0-rc.1` → `3.1.0.0`。 +/// 与安装包的 `MyAppVersion4` 同一套规则(见 scripts/version.ps1)。 +fn manifest_version(version: &str) -> String { + let numeric = version.split('-').next().unwrap_or(version); + format!("{numeric}.0") +} fn main() { println!("cargo:rerun-if-changed=manifest.xml"); println!("cargo:rerun-if-changed=icon.ico"); + // 声明了 rerun-if-changed 就得自己盯住版本号:否则改完 Cargo.toml 本脚本不会重跑, + // 清单里会留着上一个版本号。 + println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } - let manifest = std::fs::read_to_string("manifest.xml").expect("读取 manifest.xml 失败"); + let version = std::env::var("CARGO_PKG_VERSION").expect("cargo 未提供 CARGO_PKG_VERSION"); + let manifest = std::fs::read_to_string("manifest.xml") + .expect("读取 manifest.xml 失败") + .replace("{VERSION}", &manifest_version(&version)); let mut res = tauri_winres::WindowsResource::new(); res.set_icon("icon.ico"); diff --git a/crates/core/manifest.xml b/crates/core/manifest.xml index e4f397b..4c2dd46 100644 --- a/crates/core/manifest.xml +++ b/crates/core/manifest.xml @@ -1,6 +1,6 @@ - + Boss Key Core diff --git a/crates/core/src/agent.rs b/crates/core/src/agent.rs index f847229..62ec127 100644 --- a/crates/core/src/agent.rs +++ b/crates/core/src/agent.rs @@ -1059,23 +1059,44 @@ fn load_config_logging_fallback(path: &Path) -> Config { } } +/// 启动时是否该拉起配置程序:首次启动(尚无配置文件),或程序版本与上次运行的不一致。 +/// +/// `recorded` 为空表示上个版本还没记过程序版本,一律当作版本已变,弹一次即归位。 +fn should_open_settings(config_missing: bool, recorded: &str, current: &str) -> bool { + config_missing || recorded != current +} + pub fn run(options: AgentOptions) { - // 是否首次启动(尚无配置文件)/ 更新后首次启动(配置里记录的版本与当前不一致)。 + // 是否首次启动(尚无配置文件)/ 更新后首次启动(配置里记录的程序版本与当前不一致)。 // load() 在文件缺失时也返回默认值,故须先按文件是否存在判断「首次」。 let config_missing = !options.config_path.exists(); let mut config = load_config_logging_fallback(&options.config_path); i18n::set_from_pref(&config.setting.language); - let version_changed = !config_missing && config.version != bosskey_common::APP_CONFIG_VERSION; - - // 仅正常运行时(非冒烟测试)在这两种情况下默认拉起配置程序。 - if options.auto_quit_ms.is_none() && (config_missing || version_changed) { - logging::info(if config_missing { - "首次启动,拉起配置程序" + let open_settings = should_open_settings( + config_missing, + &config.app_version, + bosskey_common::APP_VERSION, + ); + + // 仅正常运行时(非冒烟测试)才拉起配置程序。 + if options.auto_quit_ms.is_none() && open_settings { + let reason = if config_missing { + "首次启动,拉起配置程序".to_string() } else { - "更新后首次启动,拉起配置程序" - }); + let was = if config.app_version.is_empty() { + "未记录" + } else { + &config.app_version + }; + format!( + "更新后首次启动({was} → {}),拉起配置程序", + bosskey_common::APP_VERSION + ) + }; + logging::info(&reason); // 记录当前版本并落盘,避免下次启动重复弹出(首次启动时顺带创建配置文件)。 config.version = bosskey_common::APP_CONFIG_VERSION.to_string(); + config.app_version = bosskey_common::APP_VERSION.to_string(); if let Err(e) = config.save(&options.config_path) { log_warn!("写入配置版本失败: {e}"); } @@ -1271,6 +1292,30 @@ pub fn run(options: AgentOptions) { mod tests { use super::*; + #[test] + fn settings_open_on_first_run_and_after_every_version_change() { + assert!( + should_open_settings(true, "", "3.1.0"), + "首次启动(无配置文件)须拉起配置程序" + ); + assert!( + should_open_settings(false, "3.0.0", "3.1.0"), + "程序版本变了须拉起配置程序" + ); + assert!( + should_open_settings(false, "", "3.1.0"), + "更早的版本没记过程序版本,视为版本已变" + ); + assert!( + !should_open_settings(false, "3.1.0", "3.1.0"), + "版本没变就别每次启动都弹窗" + ); + assert!( + should_open_settings(false, "3.1.0", "3.1.0-rc.2"), + "回退到预发布版也是版本变动" + ); + } + #[test] fn hotkey_occupied_is_named_explicitly() { let e = windows::core::Error::from_hresult(ERROR_HOTKEY_ALREADY_REGISTERED.to_hresult()); diff --git a/crates/core/src/effects.rs b/crates/core/src/effects.rs index 0559b63..233d5c1 100644 --- a/crates/core/src/effects.rs +++ b/crates/core/src/effects.rs @@ -9,16 +9,21 @@ use crate::{audio, freeze, input, log_warn, logging}; /// 不得依赖「方法返回即动作已生效」,只能依赖调用顺序与执行顺序一致(FIFO)。 pub trait Effects { fn mute(&self, pid: u32, mute: bool); + /// 冻结整批进程前静置一次,见 [`FREEZE_SETTLE_DELAY`]。 + fn settle_before_freeze(&self); fn suspend(&self, pid: u32, enhanced: bool); fn resume(&self, pid: u32, enhanced: bool); - /// 发送媒体「播放/暂停」键(仅在检测到有音视频正在播放时才发送), - /// 并等待其生效。检测与等待都由实现负责。 + /// 发送媒体「播放/暂停」键,仅在检测到有音视频正在播放时才发送。检测由实现负责。 fn send_pause(&self); } -/// 暂停键发出后等待媒体程序响应的时长。冻结须在这之后(FIFO 保证), -/// 否则被冻结的进程收不到按键。 -const SEND_PAUSE_DELAY: Duration = Duration::from_millis(200); +/// 冻结前的静置时长。 +/// +/// 冻结让进程彻底停止响应消息:隐藏动作若还没在屏幕上画完就冻结,被冻结的窗口 +/// 会留下残影。发出去的媒体暂停键同样需要这段时间被目标程序处理掉,冻结早了就收不到。 +/// +/// 只在冻结前等,静音不受影响——静音走音频会话,与目标进程是否在跑无关。 +const FREEZE_SETTLE_DELAY: Duration = Duration::from_millis(200); pub struct WinEffects { exe_dir: PathBuf, @@ -35,6 +40,10 @@ impl Effects for WinEffects { audio::set_mute(pid, mute); } + fn settle_before_freeze(&self) { + std::thread::sleep(FREEZE_SETTLE_DELAY); + } + fn suspend(&self, pid: u32, enhanced: bool) { if enhanced && freeze::pssuspend_available(&self.exe_dir) { match freeze::suspend_enhanced(&self.exe_dir, pid) { @@ -72,7 +81,6 @@ impl Effects for WinEffects { // 没有音视频在播放时不发键,避免把静止的播放器切成播放。 if audio::is_audio_playing() { input::send_media_pause(); - std::thread::sleep(SEND_PAUSE_DELAY); } } } diff --git a/crates/core/src/effects_worker.rs b/crates/core/src/effects_worker.rs index df2a98a..4afe541 100644 --- a/crates/core/src/effects_worker.rs +++ b/crates/core/src/effects_worker.rs @@ -10,6 +10,7 @@ use crate::{log_error, log_warn}; enum Task { Mute { pid: u32, mute: bool }, + SettleBeforeFreeze, Suspend { pid: u32, enhanced: bool }, Resume { pid: u32, enhanced: bool }, SendPause, @@ -32,6 +33,7 @@ impl EffectsWorker { while let Ok(task) = rx.recv() { match task { Task::Mute { pid, mute } => inner.mute(pid, mute), + Task::SettleBeforeFreeze => inner.settle_before_freeze(), Task::Suspend { pid, enhanced } => inner.suspend(pid, enhanced), Task::Resume { pid, enhanced } => inner.resume(pid, enhanced), Task::SendPause => inner.send_pause(), @@ -89,6 +91,9 @@ impl Effects for AsyncEffects { fn mute(&self, pid: u32, mute: bool) { self.send(Task::Mute { pid, mute }); } + fn settle_before_freeze(&self) { + self.send(Task::SettleBeforeFreeze); + } fn suspend(&self, pid: u32, enhanced: bool) { self.send(Task::Suspend { pid, enhanced }); } @@ -118,6 +123,9 @@ mod tests { .unwrap() .push(format!("mute:{pid}:{mute}")); } + fn settle_before_freeze(&self) { + self.calls.lock().unwrap().push("settle".into()); + } fn suspend(&self, pid: u32, _enhanced: bool) { self.calls.lock().unwrap().push(format!("suspend:{pid}")); } @@ -135,9 +143,10 @@ mod tests { let worker = EffectsWorker::spawn(recorder.clone()); let effects = worker.effects(); - // 暂停键必须先于冻结执行(冻结后的进程收不到按键)。 + // 暂停键必须先于冻结执行(冻结后的进程收不到按键);静置须紧挨在冻结前。 effects.send_pause(); effects.mute(100, true); + effects.settle_before_freeze(); effects.suspend(100, false); effects.resume(100, false); @@ -145,7 +154,13 @@ mod tests { assert_eq!( *recorder.calls.lock().unwrap(), - vec!["pause", "mute:100:true", "suspend:100", "resume:100"], + vec![ + "pause", + "mute:100:true", + "settle", + "suspend:100", + "resume:100" + ], "任务应按入队顺序全部执行完毕(shutdown 排干队列)" ); } diff --git a/crates/core/src/hide.rs b/crates/core/src/hide.rs index fd5eba3..118171e 100644 --- a/crates/core/src/hide.rs +++ b/crates/core/src/hide.rs @@ -382,9 +382,9 @@ impl HideController { } /// 执行计划:同步隐藏窗口(`SW_HIDE`),静音 / 冻结 / 暂停键经 [`Effects`] 施加 - /// (生产实现为异步队列)。 + /// (生产实现为异步队列,故入队顺序即执行顺序)。 pub fn commit_hide(&mut self, plan: HidePlan) { - // 暂停键先入队:冻结后的进程收不到按键。 + // 暂停键排在最前:冻结后的进程收不到按键。 if plan.send_pause { self.effects.send_pause(); } @@ -400,6 +400,11 @@ impl HideController { self.muted.sort_unstable_by_key(|r| r.pid); self.used_enhanced = plan.enhanced; + // 静置排在静音之后、冻结之前:静音不必等,冻结必须等屏幕画完, + // 否则被冻结的窗口会留下残影。整批只等一次。 + if !plan.freeze.is_empty() { + self.effects.settle_before_freeze(); + } for r in &plan.freeze { self.effects.suspend(r.pid, plan.enhanced); self.frozen.push(*r); @@ -953,12 +958,16 @@ mod tests { suspends: RefCell>, resumes: RefCell>, pauses: RefCell, + settles: RefCell, } impl Effects for MockEffects { fn mute(&self, pid: u32, mute: bool) { self.mutes.borrow_mut().push((pid, mute)); } + fn settle_before_freeze(&self) { + *self.settles.borrow_mut() += 1; + } fn suspend(&self, pid: u32, _enhanced: bool) { self.suspends.borrow_mut().push(pid); } @@ -995,6 +1004,11 @@ mod tests { assert_eq!(*controller.effects.mutes.borrow(), vec![(10, true)]); assert_eq!(*controller.effects.suspends.borrow(), vec![10]); assert_eq!(*controller.effects.pauses.borrow(), 1, "应发送一次暂停键"); + assert_eq!( + *controller.effects.settles.borrow(), + 1, + "冻结前须静置一次,等屏幕画完再让进程停摆" + ); let outcome = controller.show(); assert_eq!( @@ -1015,6 +1029,28 @@ mod tests { ); } + #[test] + fn nothing_to_freeze_means_nothing_to_wait_for() { + let mut config = Config::default(); + config.setting.hide_current = false; + config.setting.mute_after_hide = true; + config.setting.freeze_after_hide = false; + config.setting.send_before_hide = true; + config.window_rules = vec![wrule("微信", 10, "WeChat.exe", "C:\\WeChat.exe")]; + + let wm = MockWm::new(vec![win("微信", 10, "WeChat.exe", "C:\\WeChat.exe")], 10); + let mut controller = HideController::new(wm, MockEffects::default()); + + do_hide(&mut controller, &mut config); + + assert_eq!( + *controller.effects.settles.borrow(), + 0, + "没有要冻结的进程就不该空等,静音不必为残影让路" + ); + assert_eq!(*controller.effects.mutes.borrow(), vec![(10, true)]); + } + #[test] fn successive_hides_accumulate_and_restore_together() { let setting = Setting { diff --git a/crates/core/src/main.rs b/crates/core/src/main.rs index 90d99d2..137d01c 100644 --- a/crates/core/src/main.rs +++ b/crates/core/src/main.rs @@ -1,34 +1,49 @@ // release 下以窗口子系统编译(无控制台);debug 保留控制台便于开发。 #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use std::path::PathBuf; use std::time::Duration; +use bosskey_common::paths; use bosskey_core::agent::{self, AgentOptions}; use bosskey_core::logging; use bosskey_core::single_instance::SingleInstance; const MUTEX_NAME: &str = "BossKey_SingleInstance_Mutex"; -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(".")) -} - -fn config_path() -> PathBuf { - exe_dir().join("config.json") -} - fn main() { + // 数据目录只定位一次,配置、日志、恢复文件共用,避免两次定位得出不同结果。 + let located = paths::locate(); + let data_dir = located.dir.clone(); + let config_path = data_dir.join(paths::CONFIG_FILE_NAME); + // 日志与 panic 钩子最先就位。日志保留天数取自配置(0 = 关闭日志)。 - let retention_days = bosskey_common::Config::load(&config_path()) + let retention_days = bosskey_common::Config::load(&config_path) .map(|c| c.setting.log_retention_days) .unwrap_or(bosskey_common::config::DEFAULT_LOG_RETENTION_DAYS); - logging::init(exe_dir().join(logging::LOG_DIR_NAME), retention_days); + logging::init(data_dir.join(logging::LOG_DIR_NAME), retention_days); logging::install_panic_hook(); - logging::info(&format!("核心启动 {}", bosskey_common::APP_CONFIG_VERSION)); + logging::info(&format!( + "核心启动 {}(配置 schema {})", + bosskey_common::APP_VERSION, + bosskey_common::APP_CONFIG_VERSION + )); + // 数据究竟落在哪里是排查读写失败的第一手信息,每次启动都记一笔。 + logging::info(&format!( + "数据目录: {}({})", + data_dir.display(), + match located.kind { + paths::DataDirKind::Installed => "安装版", + paths::DataDirKind::Portable => "便携版", + paths::DataDirKind::PortableFallback => "便携版,程序目录不可写,已回退", + } + )); + if located.kind == paths::DataDirKind::PortableFallback { + logging::warn(&format!( + "程序目录 {} 无写入权限,设置改存到 {}。要让设置随程序目录携带,请把程序移到有写入权限的位置", + located.program_dir.display(), + data_dir.display() + )); + } let args: Vec = std::env::args().collect(); @@ -44,7 +59,7 @@ fn main() { return; } - let mut options = AgentOptions::standard(config_path()); + let mut options = AgentOptions::standard(config_path); if args.iter().any(|a| a == "smoke") { let ms = args .iter() diff --git a/crates/core/tests/agent_ipc.rs b/crates/core/tests/agent_ipc.rs index 5eb1253..1c67154 100644 --- a/crates/core/tests/agent_ipc.rs +++ b/crates/core/tests/agent_ipc.rs @@ -14,10 +14,10 @@ fn agent_answers_ipc_and_quits_cleanly() { let pipe = r"\\.\pipe\bosskey_test_agent_e2e"; let options = AgentOptions { - config_path, pipe_name: pipe.to_string(), enable_tray: false, auto_quit_ms: Some(15_000), + ..AgentOptions::standard(config_path) }; let handle = std::thread::spawn(move || agent::run(options)); diff --git a/crates/core/tests/agent_recovery.rs b/crates/core/tests/agent_recovery.rs index 1b5200d..6b6b729 100644 --- a/crates/core/tests/agent_recovery.rs +++ b/crates/core/tests/agent_recovery.rs @@ -82,10 +82,10 @@ fn agent_restores_hidden_windows_left_by_a_crash() { let pipe = r"\\.\pipe\bosskey_test_agent_recovery"; let options = AgentOptions { - config_path, pipe_name: pipe.to_string(), enable_tray: false, auto_quit_ms: Some(15_000), + ..AgentOptions::standard(config_path) }; let agent_thread = std::thread::spawn(move || agent::run(options)); @@ -143,10 +143,10 @@ fn agent_discards_snapshot_from_a_previous_boot() { let pipe = r"\\.\pipe\bosskey_test_agent_recovery_stale"; let options = AgentOptions { - config_path, pipe_name: pipe.to_string(), enable_tray: false, auto_quit_ms: Some(15_000), + ..AgentOptions::standard(config_path) }; let agent_thread = std::thread::spawn(move || agent::run(options)); diff --git a/crates/core/tests/agent_tool_alignment.rs b/crates/core/tests/agent_tool_alignment.rs index 91113aa..20e98c1 100644 --- a/crates/core/tests/agent_tool_alignment.rs +++ b/crates/core/tests/agent_tool_alignment.rs @@ -66,10 +66,10 @@ fn adopt_and_release_keep_core_records_and_recovery_file_in_sync() { let pipe = r"\\.\pipe\bosskey_test_tool_alignment"; let options = AgentOptions { - config_path, pipe_name: pipe.to_string(), enable_tray: false, auto_quit_ms: Some(15_000), + ..AgentOptions::standard(config_path) }; let agent_thread = std::thread::spawn(move || agent::run(options)); let client = PipeClient::new(pipe); diff --git a/crates/core/tests/agent_window_tracking.rs b/crates/core/tests/agent_window_tracking.rs index 07c18ae..4930db4 100644 --- a/crates/core/tests/agent_window_tracking.rs +++ b/crates/core/tests/agent_window_tracking.rs @@ -61,10 +61,10 @@ fn destroying_a_hidden_window_clears_the_record_in_real_time() { let pipe = r"\\.\pipe\bosskey_test_window_tracking"; let options = AgentOptions { - config_path, pipe_name: pipe.to_string(), enable_tray: false, auto_quit_ms: Some(15_000), + ..AgentOptions::standard(config_path) }; let agent_thread = std::thread::spawn(move || agent::run(options)); let client = PipeClient::new(pipe); diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index a4e44c1..99f9fba 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -23,7 +23,7 @@ Boss Key v3 采用 **核心 + 配置分离** 的**双进程架构**,两者通 │ │ • 枚举/隐藏/显示窗口 │ └────────────┬───────────┘ │ │ │ • Core Audio 静音 │ │ 读写 │ │ │ • NtSuspend 进程冻结 │ ┌────────────▼───────────┐ │ -│ │ • 托盘图标 / 气泡通知 │ │ config.json(与 exe 同目录)│ +│ │ • 托盘图标 / 气泡通知 │ │ config.json(数据目录) │ │ │ • 开机自启(计划任务/注册表) │ 核心收到 reload 后热重载 │ │ │ └──────────────────────────┘ └────────────────────────┘ │ │ ▲ 随登录自启 │ @@ -68,10 +68,11 @@ Boss-Key/ ├── Cargo.toml workspace(含 release profile 调优) ├── crates/ │ ├── common/ 共享库(无平台依赖,可跨平台编译) -│ │ └── src/{model,config,matching,ipc,i18n}.rs +│ │ └── src/{model,config,matching,ipc,i18n,paths}.rs │ │ model WindowInfo / WindowRule / ProcessRule(serde 兼容旧 config.json,PID 大写) -│ │ config Config/Setting/Hotkey(兼容读取旧配置 + 迁移) +│ │ config Config/Setting/Hotkey(兼容读取旧配置 + 迁移;保存走 tmp + rename 原子替换) │ │ matching 窗口匹配逻辑 +│ │ paths 数据目录定位(安装版走 %APPDATA%,便携版就地,见下) │ │ ipc Command/Response 协议 + PipeClient 客户端 │ │ i18n 界面语言标签(Lang)与语言偏好解析,核心与配置程序共用 │ └── core/ 常驻核心(lib + bin) @@ -102,7 +103,7 @@ Boss-Key/ └── apps/config/ 配置界面(Tauri 2 + Svelte 5) ├── src-tauri/ Rust 后端命令 + tauri.conf.json + capabilities │ └── src/verhub.rs Verhub 客户端(版本/公告/反馈/日志/项目链接,基于 verhub-sdk; - │ 项目链接带缓存:内存 + 同目录 verhub_cache.json,有效期一天) + │ 项目链接带缓存:内存 + 数据目录下的 verhub_cache.json,有效期一天) ├── ui/ 前端源码(Vite + Svelte 5) │ └── src/ lib/(纯逻辑 + vitest 测试)+ components/(Svelte 组件) │ + locales/(三语文案 catalog,以 zh-CN.js 为基准) @@ -113,6 +114,41 @@ Boss-Key/ `crates/common` 刻意不依赖 Windows API,因此可以跨平台编译,其纯逻辑(配置解析、匹配、协议)也更易做单元测试。平台相关代码集中在 `crates/core`。 ::: +## 数据目录 + +配置 `config.json`、日志 `logs/`、恢复文件 `recovery.json`、缓存 `verhub_cache.json` 共处一个**数据目录**,由 `crates/common/src/paths.rs` 定位。安装版与便携版分开对待: + +| 情形 | 数据目录 | `DataDirKind` | +| --- | --- | --- | +| 安装版 | `%APPDATA%\BossKey` | `Installed` | +| 便携版,程序目录可写 | 程序目录 | `Portable` | +| 便携版,程序目录写不进去 | `%APPDATA%\BossKey` | `PortableFallback` | + +便携版把数据留在程序目录,拷走整个文件夹就带走了全部设置;安装版则不能这么做——安装包可以装进 `Program Files`,那里普通权限进程不可写,配置程序每次保存都会得到 `os error 5`。 + +### 怎么分辨是哪一种 + +看程序目录里有没有安装痕迹(`paths::is_installed`): + +1. 安装包放的标记文件 `installed.marker`(`[Files]` 里装,卸载时随之移除); +2. 卸载程序 `unins*.exe` —— 兜底,标记文件被误删时仍认得出是安装版,不至于把数据写回 `Program Files`。序号随重复安装递增,故按前缀匹配。 + +::: warning 判据必须是文件,不能是进程权限 +核心可能以管理员身份运行、配置程序不会:核心在 `Program Files` 下写得进去,配置程序写不进去。若两边各按自己能否写入来选目录,就会各读一份配置,用户改了设置却不生效。看文件则两边必然一致。也因此,安装版根本不做可写性探测——结果一样是用户目录。 +::: + +### 回退与迁移 + +便携版探测到程序目录不可写时退回用户目录,`kind` 记为 `PortableFallback`。核心把它写进日志,配置程序通过 `data_location` 命令读到后弹出提示,说明这是权限问题以及怎么改(见 `DataNoticeModal.svelte`)。程序功能不受影响。 + +用到用户目录时,程序目录里的 `config.json` 会搬过来:先复制,再尽力删掉原文件。目标已有配置就不动它——那是当前在用的一份,旧文件不得覆盖,也不去删。删不掉(没有写权限、文件被占用)就留在原处,反正不会再被读到。 + +::: tip 配置界面的浏览器数据另有一处 +Tauri 按 `tauri.conf.json` 里的 identifier 把 WebView2 用户数据放在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,不在数据目录里,也不由 `paths.rs` 管。安装包的卸载程序与便携版随包的 `scripts/cleanup.ps1` 都会清理它。 +::: + +每次启动的实际数据目录与判定结果会写进日志首屏,排查读写失败先看它。 + ## 核心内部:Agent 消息循环 `agent.rs` 是核心的中枢:它创建一个**隐藏的消息窗口**并运行 Windows 消息循环,聚合以下事件源: @@ -130,6 +166,8 @@ Boss-Key/ 当触发隐藏 / 显示时,交由 `HideController` 编排,流程为「意图先行」两段式:`plan_hide` 算出执行计划(剪掉失效记录、补齐 PID)→ 把计划后的快照写入 `recovery.json`(先落盘再动手,隐藏中途崩溃不丢记录)→ `commit_hide` 同步隐藏窗口(`SW_HIDE`),并把静音 / 冻结 / 暂停键交给副作用专职线程(`effects_worker.rs`)按 FIFO 异步执行——消息循环不被慢操作(音频枚举、pssuspend 等待)阻塞,热键与界面保持响应。 +队列内的先后有讲究:暂停键→静音→静置→冻结。冻结让进程彻底停止响应消息,隐藏若还没在屏幕上画完就冻结,被冻结的窗口会留下残影;发出去的暂停键同样要有时间被目标程序处理掉。故冻结前统一静置一次(`FREEZE_SETTLE_DELAY`,整批只等一次,没有要冻结的进程就不等)。静音不排在这道等待之后——它走音频会话,与目标进程是否在跑无关。 + 恢复(显示)时逐条校验记录的有效性:句柄须仍存在且仍属于当初的进程(`IsWindow` + PID 比对),冻结 / 静音记录须匹配进程创建时刻——句柄与 PID 都会被系统回收复用,校验不过的记录跳过并如实计入日志。 ::: info 可测试性设计 @@ -138,7 +176,7 @@ Boss-Key/ ## 稳定性设计(崩溃自愈三层防线) -1. **崩溃日志**:关键事件与 panic 写入 exe 同目录的 `logs/BossKey-YYYY-MM-DD.log`(按天切割,按 `log_retention_days` 保留,0 表示关闭日志;release 构建丢弃 DEBUG 级)。 +1. **崩溃日志**:关键事件与 panic 写入[数据目录](#数据目录)下的 `logs/BossKey-YYYY-MM-DD.log`(按天切割,按 `log_retention_days` 保留,0 表示关闭日志;release 构建丢弃 DEBUG 级)。 2. **崩溃恢复**:隐藏动作执行前先把"将要隐藏 / 冻结 / 静音什么"写入 `recovery.json`(tmp + rename 原子替换),异常退出后重启自动找回;快照带开机时刻与进程创建时刻,跨重启的过期快照直接丢弃,不会对无关窗口 / 进程做恢复动作。 3. **看门狗**:计划任务 `RestartOnFailure`(崩溃后 1 分钟内重启,最多 3 次)。release 构建 `panic = "abort"`,panic 钩子写完日志后以非零码退出,正好触发计划任务重启。 diff --git a/docs/dev/config-reference.md b/docs/dev/config-reference.md index de4fa0c..e45d937 100644 --- a/docs/dev/config-reference.md +++ b/docs/dev/config-reference.md @@ -4,7 +4,7 @@ title: 配置文件字段 # 配置文件字段参考 -Boss Key 的配置保存在与可执行文件**同目录**的 `config.json` 中。**结构与旧版完全兼容**,旧用户配置可直接沿用。首次运行若不存在则使用默认值。字段定义见 `crates/common/src/config.rs`。 +Boss Key 的配置保存在 `config.json` 中,便携版存在程序目录,安装版存在 `%APPDATA%\BossKey`,详见[数据目录](/dev/architecture#数据目录)。位置变动时旧配置会自动迁移过去。**结构与旧版完全兼容**,旧用户配置可直接沿用。首次运行若不存在则使用默认值。字段定义见 `crates/common/src/config.rs`。 ::: tip 一般无需手改 配置由配置界面自动读写并保存,通常无需手动编辑。本页面向需要理解字段含义的开发者。 @@ -14,7 +14,8 @@ Boss Key 的配置保存在与可执行文件**同目录**的 `config.json` 中 | 字段 | 类型 | 说明 | | --- | --- | --- | -| `version` | string | 配置版本 | +| `version` | string | 配置 schema 版本,结构变动时才动 | +| `app_version` | string | 上次运行过的**程序**版本;与当前程序版本不符即「更新后首次启动」,核心据此自动弹出配置界面。缺省置空 | | `history` | number[] | 历史记录(时间戳) | | `frozen_pids` | number[] | 当前被冻结的进程 PID(用于恢复) | | `hotkey` | object | 键盘热键,见下 | @@ -45,7 +46,7 @@ Boss Key 的配置保存在与可执行文件**同目录**的 `config.json` 中 | 字段 | 类型 | 默认 | 对应功能 | | --- | --- | --- | --- | | `mute_after_hide` | bool | `true` | [隐藏后静音](/guide/options#隐藏窗口后静音) | -| `send_before_hide` | bool | `false` | [隐藏前发送暂停键](/guide/options#隐藏前发送暂停键-beta) | +| `send_before_hide` | bool | `false` | [隐藏前发送暂停键](/guide/options#隐藏前发送暂停键) | | `hide_current` | bool | `true` | [同时隐藏当前活动窗口](/guide/options#同时隐藏当前活动窗口) | | `click_to_hide` | bool | `true` | [单击托盘切换隐藏](/guide/options#单击托盘图标切换隐藏) | | `hide_icon_after_hide` | bool | `false` | [同时隐藏 Boss Key 托盘图标](/guide/options#同时隐藏-boss-key-托盘图标) | diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index c5b3e3c..a2319a9 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -55,7 +55,7 @@ cargo build --release ``` ::: warning 版本号一致性 -若你改动了版本号,务必保证 `Cargo.toml`、`tauri.conf.json`、`ui/package.json`、`Cargo.lock` **四处一致**。CI 会用 `scripts/version.ps1 check` 校验。日常功能开发一般**不要**手动改版本号——版本号由发布流程统一管理,详见 [打包与发布](/dev/release)。 +版本号只写在 `Cargo.toml` 的 `[workspace.package] version`,`Cargo.lock` 跟着它走;其余地方构建时自动取用,无需手改。CI 会用 `scripts/version.ps1 check` 校验。日常功能开发一般**不要**手动改版本号——版本号由发布流程统一管理,详见 [打包与发布](/dev/release)。 ::: ## 代码风格 diff --git a/docs/dev/project-management.md b/docs/dev/project-management.md index 1780cc3..697a023 100644 --- a/docs/dev/project-management.md +++ b/docs/dev/project-management.md @@ -51,7 +51,7 @@ feat/* · fix/* · doc/* ──PR──▶ dev ──PR(发版时)──▶ ## 版本与发布管理 -- 版本号的**唯一真源**是 `Cargo.toml` 的 `[workspace.package] version`,其余三处文件必须与之一致。 +- 版本号的**唯一真源**是 `Cargo.toml` 的 `[workspace.package] version`,其余地方在构建时取自它。 - 发布通过 GitHub Actions 手动触发的工作流完成:写入版本号 → 打 tag → 构建并发布 Release。 - 详见 [打包与发布](/dev/release)。 diff --git a/docs/dev/release.md b/docs/dev/release.md index 6981755..1c40b03 100644 --- a/docs/dev/release.md +++ b/docs/dev/release.md @@ -30,30 +30,49 @@ dist/ ├── Boss-Key/ 便携版(拷走即用,发布时整个文件夹压成 zip) │ ├── Boss Key.exe 常驻核心(内嵌 DPI/长路径 manifest + 版本信息 + 图标) │ ├── config.exe 配置界面(前端已内嵌,自包含) +│ ├── cleanup.ps1 残留数据清理脚本(便携版没有卸载程序) │ ├── LICENSE.txt -│ └── README.md +│ ├── README.md 简体中文 +│ ├── README.en.md English +│ └── README.zh-TW.md 繁體中文 └── installer/ 安装包(-Installer 时生成) └── Boss-Key-<版本>-Setup.exe InnoSetup(安装前自动结束运行中的核心) ``` -便携版**无需安装、无外部依赖**(除系统自带的 WebView2)。两个程序通过同目录的 `config.json` 与命名管道协作。 +便携版**无需安装、无外部依赖**(除系统自带的 WebView2)。两个程序通过[数据目录](/dev/architecture#数据目录)下的 `config.json` 与命名管道协作。 + +三语 README 都要带上:便携版没有安装向导,README 是唯一的随包说明,其中「数据存放位置与清理」一节交代了程序在用户目录下留了什么、怎么用 `cleanup.ps1` 清掉。 + +::: danger 便携文件夹里不能出现 installed.marker +程序凭它认出自己是安装版并改用 `%APPDATA%\BossKey`(见[数据目录](/dev/architecture#数据目录))。该文件由 `.iss` 从脚本目录直取,不经过 `dist\Boss-Key`——若混进便携包,便携版就不便携了。 +::: + +安装包默认走**普通权限**安装(`%LocalAppData%\Programs\Boss Key`),用户可在向导首屏改选「为所有用户安装」装进 `Program Files`。两种模式下数据都在 `%APPDATA%\BossKey`,不在安装目录里。 ## 版本号管理 ::: info 版本号唯一真源 -版本号的唯一真源是 `Cargo.toml` 的 `[workspace.package] version`。另外三处必须与之一致:`apps/config/src-tauri/tauri.conf.json`、`apps/config/ui/package.json`、`Cargo.lock`。 +版本号只写在 `Cargo.toml` 的 `[workspace.package] version` 一处,`Cargo.lock` 跟着它走。其余地方**不再各存一份**,一律在构建时取真实版本号: + +| 位置 | 版本号从哪来 | +| --- | --- | +| 两个 exe 的文件版本信息 | `CARGO_PKG_VERSION`(tauri-winres / tauri-build;`tauri.conf.json` 不写 `version` 即回落到 Cargo.toml) | +| 核心清单的 `assemblyIdentity` | `crates/core/build.rs` 按 `CARGO_PKG_VERSION` 填入(换算成纯数字四段号) | +| 安装包的 `MyAppVersion` | `scripts/package.ps1` 从 `Cargo.toml` 读出后传给 Inno;未传则编译报错,不留过期的默认值 | +| 程序内与上报给 Verhub 的版本 | `env!("CARGO_PKG_VERSION")` | +| 配置文件的 `app_version` | 核心启动时写入 `bosskey_common::APP_VERSION` | ::: `scripts/version.ps1` 负责写入与校验: ```powershell -# 把版本号写入四处文件(并同步 Cargo.lock) +# 把版本号写入 Cargo.toml(并同步 Cargo.lock) powershell -File scripts/version.ps1 apply 3.0.1 -# 校验四处与该 tag 一致,不一致则失败 +# 校验 Cargo.toml 与该 tag 一致,不一致则失败 powershell -File scripts/version.ps1 check 3.0.1 -# 不给 tag 时以 Cargo.toml 为基准校验其余文件 +# 不给 tag 时只回显当前版本号 powershell -File scripts/version.ps1 check # 打印当前版本号 @@ -79,7 +98,7 @@ powershell -File scripts/version.ps1 show **触发**:手动(`workflow_dispatch`),输入要发布的版本号。**请从 `main` 触发**(待发布内容合并进 `main` 之后)。 **做什么**: -1. 用 `version.ps1 apply` 把版本号写入四处文件; +1. 用 `version.ps1 apply` 把版本号写入 `Cargo.toml` 并同步 `Cargo.lock`; 2. 以 OIDC 身份向 [octo-sts](https://octo-sts.dev) 换取本仓库 `contents:write` 的短期 token; 3. 经 GraphQL `createCommitOnBranch` 把版本号变更提交到触发分支,并打上 `v<版本>` 附注 tag——API 创建的提交由 GitHub 服务端签名,带 **Verified** 徽章; 4. 检出该 tag → 校验 tag 与代码版本一致 → 前端 / Rust 测试; diff --git a/docs/dev/testing.md b/docs/dev/testing.md index 6c8f5e6..ec4d3a9 100644 --- a/docs/dev/testing.md +++ b/docs/dev/testing.md @@ -34,7 +34,9 @@ cargo test -p bosskey-core -- --test-threads=1 - `PID` 大写字段兼容、正则规则往返序列化; - 连击次数 / 连击窗口的范围钳制; - 协议 `Command` / `Response` 的 round-trip 与 snake_case 标签; -- 语言标签解析与偏好归一化(`zh-Hant` → `zh-TW`、无翻译的语言回落 `auto` 等)。 +- 语言标签解析与偏好归一化(`zh-Hant` → `zh-TW`、无翻译的语言回落 `auto` 等); +- 数据目录定位与迁移(便携版就地 / 安装版走用户目录 / 不可写时回退并报明原因 / 标记文件与卸载程序的识别 / 旧配置搬过去、原文件删不掉、目标已有配置不覆盖); +- 配置写入的原子性(写失败不截断原文件、不留临时文件,错误信息带路径)。 ### `bosskey-core` diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index 3ee898e..2243582 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -23,7 +23,7 @@ Boss Key v3 uses a **two-process architecture** that separates the **core from t │ │ • Enumerate/hide/show │ └────────────┬───────────┘ │ │ │ • Core Audio muting │ │ read/write │ │ │ • NtSuspend freezing │ ┌────────────▼───────────┐ │ -│ │ • Tray icon / balloons │ │ config.json (next to exe) │ +│ │ • Tray icon / balloons │ │ config.json (data folder) │ │ │ • Startup (task/registry) │ │ hot-reloaded on reload │ │ │ └──────────────────────────┘ └────────────────────────┘ │ │ ▲ starts at logon │ @@ -68,10 +68,11 @@ Boss-Key/ ├── Cargo.toml workspace (including release profile tuning) ├── crates/ │ ├── common/ Shared library (no platform dependency; builds cross-platform) -│ │ └── src/{model,config,matching,ipc,i18n}.rs +│ │ └── src/{model,config,matching,ipc,i18n,paths}.rs │ │ model WindowInfo / WindowRule / ProcessRule (serde-compatible with the old config.json; PID uppercase) -│ │ config Config/Setting/Hotkey (reads old configurations + migration) +│ │ config Config/Setting/Hotkey (reads old configurations + migration; saves via tmp + rename) │ │ matching Window matching logic +│ │ paths Data folder resolution (%APPDATA% when installed, in place when portable; see below) │ │ ipc Command/Response protocol + PipeClient │ │ i18n Language tags (Lang) and preference resolution, shared by core and settings │ └── core/ Resident core (lib + bin) @@ -102,7 +103,7 @@ Boss-Key/ └── apps/config/ Settings window (Tauri 2 + Svelte 5) ├── src-tauri/ Rust backend commands + tauri.conf.json + capabilities │ └── src/verhub.rs Verhub client (versions/announcements/feedback/logs/project links, built on verhub-sdk; - │ project links are cached: in memory + verhub_cache.json next to the exe, valid for one day) + │ project links are cached: in memory + verhub_cache.json in the data folder, valid for one day) ├── ui/ Frontend source (Vite + Svelte 5) │ └── src/ lib/ (pure logic + vitest tests) + components/ (Svelte components) │ + locales/ (three-language catalogs; zh-CN.js is the source of truth) @@ -113,6 +114,41 @@ Boss-Key/ `crates/common` deliberately avoids the Windows API so it can be compiled cross-platform, and its pure logic (configuration parsing, matching, protocol) is easier to unit test. Platform-specific code lives in `crates/core`. ::: +## Data folder + +The configuration (`config.json`), logs (`logs/`), the recovery snapshot (`recovery.json`) and the cache (`verhub_cache.json`) all live in a single **data folder**, resolved by `crates/common/src/paths.rs`. Installed and portable copies are treated differently: + +| Case | Data folder | `DataDirKind` | +| --- | --- | --- | +| Installed copy | `%APPDATA%\BossKey` | `Installed` | +| Portable copy, program folder writable | The program folder | `Portable` | +| Portable copy, program folder not writable | `%APPDATA%\BossKey` | `PortableFallback` | + +A portable copy keeps its data in the program folder, so copying that folder takes the whole setup along. An installed copy cannot do the same: the installer may land in `Program Files`, which normal privileges cannot write to, so every save from the settings program would fail with `os error 5`. + +### Telling the two apart + +By looking for traces of an installation in the program folder (`paths::is_installed`): + +1. `installed.marker`, dropped by the installer (shipped via `[Files]`, removed on uninstall); +2. the uninstaller `unins*.exe` — a fallback, so that a deleted marker does not send the data back into `Program Files`. The number increases with repeated installs, hence the prefix match. + +::: warning The test must be a file, never a privilege check +The core may run as administrator while the settings program does not: the core can write inside `Program Files`, the settings program cannot. If each side picked a folder based on what it could write to, the two would read different configs and the user's changes would appear to have no effect. Looking at files makes both sides agree by construction — which is also why an installed copy never probes for writability at all; the answer is the user folder either way. +::: + +### Fallback and migration + +When a portable copy finds the program folder unwritable it falls back to the user folder and records `PortableFallback`. The core writes that to the log; the settings program reads it through the `data_location` command and shows a notice explaining that this is a permissions problem and how to change it (see `DataNoticeModal.svelte`). Nothing else is affected. + +Whenever the user folder is used, a `config.json` in the program folder is moved across: copied first, then the original is deleted on a best-effort basis. An existing config at the destination is left untouched — that is the one currently in use — and the old file is left alone as well. If the original cannot be deleted (no write permission, or the file is in use) it simply stays; it is never read again. + +::: tip The settings window's browser data lives elsewhere +Following the identifier in `tauri.conf.json`, Tauri puts the WebView2 user data in `%LOCALAPPDATA%\cn.hanloth.bosskey.config`. It is not part of the data folder and is not managed by `paths.rs`. Both the installer's uninstaller and the `scripts/cleanup.ps1` shipped with the portable edition remove it. +::: + +The data folder actually in use, and how it was chosen, are written to the log on every start; check that first when diagnosing read/write failures. + ## Inside the core: the agent message loop `agent.rs` is the hub: it creates a **hidden message window**, runs the Windows message loop, and aggregates these event sources: @@ -130,6 +166,8 @@ Window events keep the hidden records maintained in real time: when a hidden win When hiding or showing is triggered, `HideController` orchestrates it with a two-phase, intent-first flow: `plan_hide` computes the execution plan (pruning stale records and backfilling PIDs) → the planned snapshot is written to `recovery.json` (persist first, act second — a crash mid-hide loses no records) → `commit_hide` hides the windows synchronously (`SW_HIDE`) and hands muting / freezing / the pause key to the dedicated side-effect thread (`effects_worker.rs`), executed asynchronously in FIFO order — the message loop is never blocked by slow operations (audio enumeration, waiting on pssuspend), so hotkeys and the UI stay responsive. +The order within the queue matters: pause key → mute → settle → freeze. Freezing stops a process from responding to messages at all, so freezing before the hide has finished painting leaves a ghost of the window on screen; the pause key likewise needs time to be handled by the target program. Hence a single settle before the batch of freezes (`FREEZE_SETTLE_DELAY`, once per batch, skipped when there is nothing to freeze). Muting is deliberately not placed behind that wait — it goes through the audio session and does not care whether the target process is running. + When restoring (showing), every record is validated first: the handle must still exist and still belong to the original process (`IsWindow` + PID comparison), and frozen / muted records must match the process creation time — both handles and PIDs are recycled by the system, and records that fail validation are skipped and reported truthfully in the log. ::: info Designed for testability @@ -138,7 +176,7 @@ When restoring (showing), every record is validated first: the handle must still ## Stability (three layers of crash self-healing) -1. **Crash logs**: key events and panics are written to `logs/BossKey-YYYY-MM-DD.log` next to the exe (rotated daily, retained per `log_retention_days`; 0 disables logging; release builds drop the DEBUG level). +1. **Crash logs**: key events and panics are written to `logs/BossKey-YYYY-MM-DD.log` in the [data folder](#data-folder) (rotated daily, retained per `log_retention_days`; 0 disables logging; release builds drop the DEBUG level). 2. **Crash recovery**: before any hide action executes, what is *about to be* hidden / frozen / muted is written to `recovery.json` (tmp + rename atomic replace); windows are recovered automatically on the next start after an abnormal exit. Snapshots carry the boot time and process creation times, so stale snapshots from a previous boot are discarded instead of acting on unrelated windows / processes. 3. **Watchdog**: the scheduled task's `RestartOnFailure` (restart within a minute of a crash, up to 3 times). Release builds use `panic = "abort"`, and the panic hook exits with a non-zero code once the log is written — exactly what triggers the scheduled-task restart. diff --git a/docs/en/dev/config-reference.md b/docs/en/dev/config-reference.md index 839c1ff..376f056 100644 --- a/docs/en/dev/config-reference.md +++ b/docs/en/dev/config-reference.md @@ -4,7 +4,7 @@ title: Configuration fields # Configuration field reference -Boss Key stores its configuration in `config.json`, in the **same folder** as the executable. The structure is **fully compatible with older versions**, so existing configurations carry over. If the file is missing on first run, defaults are used. The field definitions live in `crates/common/src/config.rs`. +Boss Key stores its configuration in `config.json` — in the program folder for a portable copy, in `%APPDATA%\BossKey` for an installed one; see [Data folder](/en/dev/architecture#data-folder). When the location changes, the old configuration is migrated across automatically. The structure is **fully compatible with older versions**, so existing configurations carry over. If the file is missing on first run, defaults are used. The field definitions live in `crates/common/src/config.rs`. ::: tip You normally do not edit this by hand The settings window reads and writes the configuration automatically. This page is for developers who need to understand the fields. @@ -14,7 +14,8 @@ The settings window reads and writes the configuration automatically. This page | Field | Type | Description | | --- | --- | --- | -| `version` | string | Configuration version | +| `version` | string | Configuration schema version; only changes when the structure does | +| `app_version` | string | The program version last seen; when it differs from the current one this is the first run after an update, and the core opens the settings window automatically. Empty by default | | `history` | number[] | History (timestamps) | | `frozen_pids` | number[] | PIDs of currently frozen processes (used for recovery) | | `hotkey` | object | Keyboard hotkeys; see below | diff --git a/docs/en/dev/contributing.md b/docs/en/dev/contributing.md index 40e8178..38a73bc 100644 --- a/docs/en/dev/contributing.md +++ b/docs/en/dev/contributing.md @@ -55,7 +55,7 @@ cargo build --release ``` ::: warning Version consistency -If you change the version number, keep `Cargo.toml`, `tauri.conf.json`, `ui/package.json` and `Cargo.lock` **consistent across all four**. CI verifies this with `scripts/version.ps1 check`. During ordinary feature work you should generally **not** change the version by hand — the release process manages it; see [Packaging & releasing](/en/dev/release). +The version lives only in `[workspace.package] version` in `Cargo.toml`, with `Cargo.lock` following it; everywhere else picks it up at build time, so there is nothing to edit by hand. CI verifies this with `scripts/version.ps1 check`. During ordinary feature work you should generally **not** change the version by hand — the release process manages it; see [Packaging & releasing](/en/dev/release). ::: ## Code style diff --git a/docs/en/dev/project-management.md b/docs/en/dev/project-management.md index 278ecbc..693fa0e 100644 --- a/docs/en/dev/project-management.md +++ b/docs/en/dev/project-management.md @@ -51,7 +51,7 @@ Try to link **issue – pull request – project** together for unified tracking ## Versioning and releases -- The **single source of truth** for the version is `[workspace.package] version` in `Cargo.toml`; the other three files must match it. +- The **single source of truth** for the version is `[workspace.package] version` in `Cargo.toml`; everywhere else takes it at build time. - Releases are made through a manually triggered GitHub Actions workflow: write the version → tag → build and publish the release. - See [Packaging & releasing](/en/dev/release). diff --git a/docs/en/dev/release.md b/docs/en/dev/release.md index f122def..395da5c 100644 --- a/docs/en/dev/release.md +++ b/docs/en/dev/release.md @@ -30,30 +30,49 @@ dist/ ├── Boss-Key/ Portable edition (copy and run; zipped whole for release) │ ├── Boss Key.exe Resident core (embedded DPI/long-path manifest + version info + icon) │ ├── config.exe Settings window (frontend embedded; self-contained) +│ ├── cleanup.ps1 Leftover-data cleanup script (the portable edition has no uninstaller) │ ├── LICENSE.txt -│ └── README.md +│ ├── README.md Simplified Chinese +│ ├── README.en.md English +│ └── README.zh-TW.md Traditional Chinese └── installer/ Installer (produced with -Installer) └── Boss-Key--Setup.exe Inno Setup (terminates a running core before installing) ``` -The portable edition **needs no installation and has no external dependencies** (beyond the system's WebView2). The two programs cooperate through `config.json` in the same folder and a named pipe. +The portable edition **needs no installation and has no external dependencies** (beyond the system's WebView2). The two programs cooperate through `config.json` in the [data folder](/en/dev/architecture#data-folder) and a named pipe. + +All three READMEs must ship: the portable edition has no installation wizard, so the README is the only documentation in the package, and its "Where the data lives, and how to remove it" section explains what the program leaves in the user folder and how `cleanup.ps1` removes it. + +::: danger installed.marker must never end up in the portable folder +The program uses it to recognise an installed copy and switch to `%APPDATA%\BossKey` (see [Data folder](/en/dev/architecture#data-folder)). The `.iss` takes the file straight from the script folder, bypassing `dist\Boss-Key` — if it slipped into the portable package, the portable edition would stop being portable. +::: + +The installer runs with **normal privileges** by default (`%LocalAppData%\Programs\Boss Key`); on the wizard's first page the user can switch to "Install for all users" and land in `Program Files`. Either way the data goes to `%APPDATA%\BossKey`, not the installation folder. ## Version management ::: info The single source of truth -The single source of truth for the version is `[workspace.package] version` in `Cargo.toml`. Three other places must match it: `apps/config/src-tauri/tauri.conf.json`, `apps/config/ui/package.json` and `Cargo.lock`. +The version is written in exactly one place, `[workspace.package] version` in `Cargo.toml`, with `Cargo.lock` following it. Nowhere else keeps its own copy; every other place takes the real version at build time: + +| Place | Where the version comes from | +| --- | --- | +| The version resources of both exes | `CARGO_PKG_VERSION` (tauri-winres / tauri-build; leaving `version` out of `tauri.conf.json` falls back to Cargo.toml) | +| The core's manifest `assemblyIdentity` | Filled in by `crates/core/build.rs` from `CARGO_PKG_VERSION` (converted to a numeric four-part version) | +| The installer's `MyAppVersion` | `scripts/package.ps1` reads it from `Cargo.toml` and passes it to Inno; compilation fails if it is missing, rather than falling back to a stale default | +| The version shown in the app and reported to Verhub | `env!("CARGO_PKG_VERSION")` | +| `app_version` in the configuration file | Written by the core on start from `bosskey_common::APP_VERSION` | ::: `scripts/version.ps1` writes and verifies it: ```powershell -# Write the version into all four files (and sync Cargo.lock) +# Write the version into Cargo.toml (and sync Cargo.lock) powershell -File scripts/version.ps1 apply 3.0.1 -# Verify all four match this tag; fail if not +# Verify Cargo.toml matches this tag; fail if not powershell -File scripts/version.ps1 check 3.0.1 -# Without a tag, verify the other files against Cargo.toml +# Without a tag, just print the current version powershell -File scripts/version.ps1 check # Print the current version @@ -79,7 +98,7 @@ A new push on the same branch cancels the previous run automatically (`concurren **Trigger**: manual (`workflow_dispatch`), taking the version to release as input. **Run it from `main`** (after the release content has been merged into `main`). **What it does**: -1. Writes the version into the four files with `version.ps1 apply`; +1. Writes the version into `Cargo.toml` and syncs `Cargo.lock` with `version.ps1 apply`; 2. Federates the workflow's OIDC identity through [octo-sts](https://octo-sts.dev) into a short-lived `contents:write` token for this repository; 3. Commits the version change onto the triggering branch via GraphQL `createCommitOnBranch` and creates the `v` annotated tag — commits created through the API are signed by GitHub server-side and carry the **Verified** badge; 4. Checks that tag out → verifies the tag matches the code version → frontend / Rust tests; diff --git a/docs/en/dev/testing.md b/docs/en/dev/testing.md index bc00403..7cc608e 100644 --- a/docs/en/dev/testing.md +++ b/docs/en/dev/testing.md @@ -34,7 +34,9 @@ cargo test -p bosskey-core -- --test-threads=1 - Compatibility of the uppercase `PID` field, and round-trip serialisation of regex rules; - Clamping of click counts and the multi-click interval; - Round-tripping of `Command` / `Response` and their snake_case tags; -- Language tag parsing and preference normalisation (`zh-Hant` → `zh-TW`, untranslated languages falling back to `auto`, and so on). +- Language tag parsing and preference normalisation (`zh-Hant` → `zh-TW`, untranslated languages falling back to `auto`, and so on); +- Data folder resolution and migration (portable copies staying in place / installed copies using the user folder / falling back with a stated reason when not writable / recognising the marker file and the uninstaller / the old configuration being moved across, an original that cannot be deleted, an existing config at the destination not being overwritten); +- Atomicity of configuration writes (a failed write neither truncates the previous file nor leaves a temporary one behind, and the error names the path). ### `bosskey-core` diff --git a/docs/en/guide/faq.md b/docs/en/guide/faq.md index 2c9992d..722475a 100644 --- a/docs/en/guide/faq.md +++ b/docs/en/guide/faq.md @@ -59,6 +59,17 @@ Enhanced freezing needs all three conditions; missing any one greys it out: The settings window states which one is missing. See [Process freezing](/en/guide/freeze). +## "Could not save the configuration" (access denied / os error 5) + +Update to v3.1.0 or later first. Older versions always kept the settings next to the program, and "Install for all users" puts the program in `C:\Program Files`, which normal privileges cannot write to — so every change failed to save. In newer versions the installer edition always stores the settings in `%APPDATA%\BossKey` and migrates the existing `config.json` there — nothing to do by hand. A portable copy still keeps them in the program folder, and switches to `%APPDATA%\BossKey` with an explanatory notice if that folder is not writable. + +If it still fails after updating, it is usually one of these: + +- **Antivirus interference**: add the Boss Key program folder and `%APPDATA%\BossKey` to your antivirus allowlist. Windows Security's "Controlled folder access" blocks writes the same way. +- **The configuration file is read-only**: right-click `config.json` → Properties and clear "Read-only". + +The error message names the path it failed on, which tells you which folder is at fault. + ## Will updating lose my configuration? No. The `config.json` structure is **fully compatible** with older versions, so your bindings, hotkeys and options are preserved. The flat bindings from v2 are migrated to the new rule format automatically. diff --git a/docs/en/guide/freeze.md b/docs/en/guide/freeze.md index f404ceb..a355e12 100644 --- a/docs/en/guide/freeze.md +++ b/docs/en/guide/freeze.md @@ -16,6 +16,8 @@ Freezing stops the target process entirely, so its background work (downloads, i This is the **master switch** for freezing. When on, Boss Key suspends the matching process each time it hides its windows, and resumes it when they are restored. It uses normal freezing by default, which works on the current user's processes **without administrator rights**. +Suspending and resuming take time, so turning this on may add some delay when hiding and restoring. + ## Use enhanced freezing Normal freezing may not be thorough enough for complex programs (multi-process architectures, renderer subprocesses). **Enhanced freezing** suspends processes with Microsoft's official `pssuspend64.exe` tool instead, which is more effective. @@ -33,12 +35,14 @@ Enhanced freezing requires **all** of the following, otherwise the option is gre 3. Find **`pssuspend64.exe`** inside and copy it into Boss Key's **installation root folder**. 4. Return to the settings window and click **Check again** in the "Process freezing" section so Boss Key picks the file up. +Enhanced freezing invokes an external program, so it likewise adds some delay when hiding and restoring. + ## Freeze the whole process tree By default freezing affects only the matched process itself. With **Freeze the whole process tree** on, Boss Key **recursively freezes that process's entire child-process tree** (including differently named child `exe` files, renderer processes, and so on) for a more thorough freeze. ::: warning -This option is still in testing and may cause problems with some programs. Enable it only once you understand the impact. +This option is still in testing, may cause problems with some programs, and adds more delay when hiding and restoring because the whole process tree has to be walked. Enable it only once you understand the impact. ::: ## Prerequisites at a glance diff --git a/docs/en/guide/installation.md b/docs/en/guide/installation.md index 112cdf7..d12432a 100644 --- a/docs/en/guide/installation.md +++ b/docs/en/guide/installation.md @@ -27,26 +27,53 @@ Some releases additionally provide a package marked `win7`. On Windows 7, downlo 2. Run it and follow the wizard. The installer **terminates the running core process automatically** before installing, to avoid file locks. 3. Boss Key starts automatically once installation finishes and opens the settings window. +The installer first asks who it is installing for: + +- **Install for me only** (default, no administrator rights needed): installs into `%LocalAppData%\Programs\Boss Key`. +- **Install for all users** (needs administrator rights): installs into `C:\Program Files\Boss Key`. + +Either way the settings are stored in `%APPDATA%\BossKey`, not in the installation folder — see [Where the data lives](#where-the-data-lives) below. The installer drops an `installed.marker` file in the installation folder so the program knows it is an installed copy; do not delete it. + ## Using the portable edition 1. Download `Boss-Key--portable.zip`. -2. Extract it anywhere (preferably a fixed, writable location). The archive already contains a `Boss-Key` folder — move that whole folder wherever you want it. +2. Extract it anywhere. The archive already contains a `Boss-Key` folder — move that whole folder wherever you want it. 3. Run **`Boss Key.exe`** from that folder. On first run it opens the settings window automatically. The extracted folder looks like this: ``` Boss-Key/ -├── Boss Key.exe Resident core (runs in the background; hides windows / listens for hotkeys) -├── config.exe Settings window (opened on demand; exits when closed) -├── LICENSE.txt License file -└── README.md Readme +├── Boss Key.exe Resident core (runs in the background; hides windows / listens for hotkeys) +├── config.exe Settings window (opened on demand; exits when closed) +├── cleanup.ps1 Leftover-data cleanup script (see "Uninstalling" below) +├── LICENSE.txt License file +├── README.md Readme (Simplified Chinese) +├── README.en.md Readme (English) +└── README.zh-TW.md Readme (Traditional Chinese) ``` ::: warning Both programs must stay in the same folder -`Boss Key.exe` and `config.exe` cooperate through the `config.json` file in the same folder and a named pipe. Do not separate them. +`Boss Key.exe` and `config.exe` cooperate through a shared `config.json` and a named pipe. Do not separate them. ::: +## Where the data lives + +The settings, logs, recovery file and cache share one folder, and which folder that is depends on the edition: + +- **Portable**: right inside the **program folder**. Copy that folder and your whole setup comes along — exactly what a portable copy should do. +- **Installer**: **`%APPDATA%\BossKey`**. The installation folder may be `C:\Program Files`, which normal privileges cannot write to, so settings kept there would fail to save every time. + +The program tells the two apart by the `installed.marker` file the installer places in the installation folder; do not delete it. + +::: warning A portable copy in an unwritable location +If the portable copy's folder is not writable (it sits somewhere like `C:\Program Files`, or on read-only media), the program switches to `%APPDATA%\BossKey` and shows a notice in the settings window explaining that this is a permissions problem and what to do about it. Nothing stops working; the settings just no longer travel with the program folder. +::: + +The browser component used by the settings window keeps its own data in `%LOCALAPPDATA%\cn.hanloth.bosskey.config` in both editions. + +When the location changes (for example after installing over a portable copy), the existing `config.json` is moved across on the first start, so your bindings and hotkeys are preserved. The **Open log folder** button in the settings window's status bar always opens the folder actually in use. + ## About the two executables Boss Key uses a two-process design that separates **core and settings**: @@ -67,5 +94,13 @@ When upgrading from v2, first copy and keep `config.json` and `pssuspend64.exe`. ## Uninstalling -- **Installer**: uninstall through Windows "Apps & features" or the uninstaller in the installation folder. Uninstalling also removes runtime files such as logs, and **asks whether to keep the configuration file**: keep it to leave `config.json` behind (reusable after reinstalling), or decline to delete the entire installation folder. A silent uninstall shows no prompt and keeps the configuration by default. -- **Portable**: exit the core, then delete the whole folder. If you enabled startup with Windows, turn it off from the tray menu first. +- **Installer**: uninstall through Windows "Apps & features" or the uninstaller in the installation folder. Uninstalling also removes runtime files — logs, caches and the settings window's browser data — and **asks whether to keep the configuration file**: keep it to leave `config.json` behind (reusable after reinstalling), or decline to delete it along with `%APPDATA%\BossKey`. A silent uninstall shows no prompt and keeps the configuration by default. +- **Portable**: exit the core, run `cleanup.ps1` from the folder to clear what is left in the user folder, then delete the whole program folder (the settings live inside it and go with it). + +The portable cleanup command (open PowerShell in the program folder): + +```powershell +powershell -ExecutionPolicy Bypass -File cleanup.ps1 +``` + +The script lists what it is about to delete and waits for your confirmation, then removes `%LOCALAPPDATA%\cn.hanloth.bosskey.config`, any `%APPDATA%\BossKey` (present only if the program folder was not writable), 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. diff --git a/docs/en/guide/options.md b/docs/en/guide/options.md index 2aefba7..ddddfb7 100644 --- a/docs/en/guide/options.md +++ b/docs/en/guide/options.md @@ -45,12 +45,11 @@ Once the tray icon is hidden you cannot click it to restore or open the settings Windows itself controls which tray icons are visible: you can choose which icons appear in the taskbar corner by hand. For detailed steps see Microsoft's guide [Customize the taskbar in Windows · System tray](https://support.microsoft.com/en-us/windows/experience/personalization/customize-the-taskbar-in-windows#system-tray), or open the [taskbar settings](ms-settings:taskbar) directly (the `ms-settings:taskbar` link works only on Windows; the browser asks for confirmation first). ::: -### Send the pause key before hiding (beta) +### Send the pause key before hiding When on, Boss Key sends the **media pause key** to the window **before** hiding it, to try to pause any video or music playing inside. - **Off** by default. -- This is experimental and adds roughly **0.2 seconds** of delay to hiding. - It differs from "mute after hiding": muting only silences the audio, whereas the pause key actually stops playback. - Boss Key's **process freezing** has the same pausing effect while also cutting resource usage — see [Process freezing](/en/guide/freeze). diff --git a/docs/en/guide/recovery.md b/docs/en/guide/recovery.md index 2cf1643..03a9a99 100644 --- a/docs/en/guide/recovery.md +++ b/docs/en/guide/recovery.md @@ -26,7 +26,7 @@ The Boss Key core has **three layers of crash self-healing**, so windows stay sa ### Layer 1: crash logs -The core writes key events and panic information to log files in the `logs` folder of the program directory, rotated daily as `BossKey-YYYY-MM-DD.log`, and cleaned up automatically according to the [log retention setting](/en/guide/options) (set it to off to disable logging). **When troubleshooting, read the current day's log first.** +The core writes key events and panic information to log files in the `logs` folder of the data folder, rotated daily as `BossKey-YYYY-MM-DD.log`, and cleaned up automatically according to the [log retention setting](/en/guide/options) (set it to off to disable logging). **When troubleshooting, read the current day's log first.** ### Layer 2: crash recovery @@ -42,9 +42,13 @@ When [startup](/en/guide/autostart) is registered as a scheduled task, it carrie | File | Location | Purpose | | --- | --- | --- | -| `config.json` | Program directory | All your settings and bindings | -| `logs/BossKey-YYYY-MM-DD.log` | Program directory | Crash / event logs (rotated daily, cleaned up per the retention setting) | -| `recovery.json` | Program directory | Snapshot of the hidden state, used for crash recovery (deleted on a normal exit) | +| `config.json` | Data folder | All your settings and bindings | +| `logs/BossKey-YYYY-MM-DD.log` | Data folder | Crash / event logs (rotated daily, cleaned up per the retention setting) | +| `recovery.json` | Data folder | Snapshot of the hidden state, used for crash recovery (deleted on a normal exit) | + +::: info Where the data folder is +Inside the program folder for a portable copy, in `%APPDATA%\BossKey` for an installed one (see [Where the data lives](/en/guide/installation#where-the-data-lives)). A portable copy also switches to `%APPDATA%\BossKey` when its folder is not writable. The **Open log folder** button in the settings window's status bar always opens the folder actually in use, and the log records it on every start. +::: ::: warning Do not delete recovery.json while it is in use Deleting `recovery.json` by hand while windows are hidden loses that snapshot, and with it the crash recovery for this run. diff --git a/docs/guide/faq.md b/docs/guide/faq.md index 876de2d..be2f8da 100644 --- a/docs/guide/faq.md +++ b/docs/guide/faq.md @@ -59,6 +59,17 @@ Boss Key 只能隐藏[自身的托盘图标](/guide/options#同时隐藏-boss-ke 配置界面会提示当前缺少哪一项。详见 [进程冻结](/guide/freeze#使用增强冻结)。 +## 提示"保存配置失败"(拒绝访问 / os error 5)怎么办? + +先升级到 v3.1.0 或更高版本。旧版本把设置固定存在程序所在目录,而选了「为所有用户安装」时程序装在 `C:\Program Files`,普通权限写不进去,于是每次改设置都保存失败。新版本的安装版一律把设置存到 `%APPDATA%\BossKey`,并把已有的 `config.json` 迁过去,无需手动处理。便携版仍存在程序目录里,若那里不可写也会自动改用 `%APPDATA%\BossKey` 并弹出说明。 + +升级后仍然报错,多半是另外两种情况: + +- **被杀软拦截**:把 Boss Key 的程序目录与 `%APPDATA%\BossKey` 加入杀软信任区。Windows 安全中心的"受控文件夹访问"也会以同样的方式拦截写入。 +- **配置文件被设为只读**:在资源管理器中右键 `config.json` → 属性,取消"只读"。 + +报错信息里带有实际路径,据此可判断问题出在哪个目录。 + ## 更新后配置会丢失吗? 不会。`config.json` 结构与旧版**完全兼容**,更新后你的绑定、热键、选项都会保留。V2版的扁平绑定也会自动迁移到新的规则格式。 diff --git a/docs/guide/freeze.md b/docs/guide/freeze.md index 2a000f5..b44c781 100644 --- a/docs/guide/freeze.md +++ b/docs/guide/freeze.md @@ -16,6 +16,8 @@ title: 进程冻结 这是冻结功能的**总开关**。开启后,每次隐藏命中的窗口时,Boss Key 会挂起对应进程;恢复显示时自动解冻,默认使用普通冻结,**无需管理员权限**即可对当前用户的进程生效。 +挂起与解冻本身需要时间,开启后可能会带来一定的隐藏 / 恢复延迟。 + ## 使用增强冻结 普通冻结对某些复杂程序(多进程架构、有渲染子进程等)可能不够彻底。**增强冻结**改用微软官方的 `pssuspend64.exe` 工具来挂起进程,效果更强。 @@ -33,12 +35,14 @@ title: 进程冻结 3. 从中找到 **`pssuspend64.exe`**,复制到 Boss Key 的**程序安装根目录**。 4. 回到配置界面,点击"进程冻结"区域的 **重新检测** 按钮,让 Boss Key 识别到该文件。 +增强冻结需调用外部程序,同样会带来一定的隐藏 / 恢复延迟。 + ## 冻结完整进程 默认情况下冻结只作用于命中的进程本身。开启 **冻结完整进程** 后,Boss Key 会**递归冻结该进程的整棵子进程树**(包括不同名的子 `exe`、渲染进程等),冻结更彻底。 ::: warning -该选项仍在测试阶段,可能会对某些程序造成异常。请在了解影响后再启用。 +该选项仍在测试阶段,可能会对某些程序造成异常,并因需要遍历整棵进程树而带来更高的隐藏 / 恢复延迟。请在了解影响后再启用。 ::: ## 前置条件速查 diff --git a/docs/guide/installation.md b/docs/guide/installation.md index f9aa3f8..27e190f 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -27,26 +27,53 @@ Windows 7 系统及部分精简版系统可能默认不包含 WebView2,导致 2. 双击运行,按照向导完成安装。安装程序在安装前会**自动结束正在运行的核心进程**,避免文件占用。 3. 安装完成后会自动启动,并弹出配置界面。 +安装程序会先问你装给谁: + +- **仅为我安装**(默认,无需管理员权限):装到 `%LocalAppData%\Programs\Boss Key`。 +- **为所有用户安装**(需要管理员权限):装到 `C:\Program Files\Boss Key`。 + +两种模式下设置都存在 `%APPDATA%\BossKey`,不在安装目录里,见下方[数据存放位置](#数据存放位置)。安装包会在安装目录里放一个 `installed.marker` 文件,程序据它认出自己是安装版,请勿删除。 + ## 使用便携版 1. 下载 `Boss-Key-<版本>-portable.zip`。 -2. 解压到任意位置(建议放在一个固定、可写的地方)。压缩包内已包含一层 `Boss-Key` 目录,解压后把它整个挪到你想放的位置即可。 +2. 解压到任意位置。压缩包内已包含一层 `Boss-Key` 目录,解压后把它整个挪到你想放的位置即可。 3. 运行该目录中的 **`Boss Key.exe`**。首次运行会自动拉起配置界面。 解压后目录结构如下: ``` Boss-Key/ -├── Boss Key.exe 常驻核心(后台运行,负责隐藏窗口 / 热键监听) -├── config.exe 配置界面(按需打开,关闭即退出) -├── LICENSE.txt 许可证文件 -└── README.md 使用说明 +├── Boss Key.exe 常驻核心(后台运行,负责隐藏窗口 / 热键监听) +├── config.exe 配置界面(按需打开,关闭即退出) +├── cleanup.ps1 残留数据清理脚本(见下方「卸载」) +├── LICENSE.txt 许可证文件 +├── README.md 使用说明(简体中文) +├── README.en.md 使用说明(English) +└── README.zh-TW.md 使用说明(繁體中文) ``` ::: warning 两个程序需放在同一目录 -`Boss Key.exe` 与 `config.exe` 通过同目录下的 `config.json` 与命名管道协作,请勿将它们分开放置。 +`Boss Key.exe` 与 `config.exe` 通过共用的 `config.json` 与命名管道协作,请勿将它们分开放置。 ::: +## 数据存放位置 + +设置、日志、恢复文件与缓存放在同一个目录里,位置取决于你用的是哪个版本: + +- **便携版**:就在**程序目录**里。拷走整个文件夹就带走了全部设置,这正是便携版该有的样子。 +- **安装版**:在 **`%APPDATA%\BossKey`**。安装目录可能是 `C:\Program Files`,普通权限写不进去,设置存在那里每次保存都会失败。 + +程序凭安装包放在程序目录里的 `installed.marker` 分辨自己是哪一种,请勿删除该文件。 + +::: warning 便携版放在了不可写的位置 +若便携版所在目录写不进去(放在了 `C:\Program Files` 之类的地方,或只读介质上),程序会改用 `%APPDATA%\BossKey` 并在配置界面弹出提示,说明是权限问题以及怎么处理。功能不受影响,只是设置不再跟着程序文件夹走。 +::: + +配置界面用到的浏览器组件另有一份数据在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,两个版本都一样。 + +位置发生变化时(例如把便携版装成了安装版),原先的 `config.json` 会在首次启动时自动搬过去,你的绑定与热键都会保留。配置界面状态栏的**打开日志目录**按钮总是打开当前实际使用的那个目录。 + ## 关于两个可执行文件 Boss Key 采用 **核心 + 配置分离** 的双进程设计: @@ -67,5 +94,13 @@ Boss Key 采用 **核心 + 配置分离** 的双进程设计: ## 卸载 -- **安装版**:通过系统"应用和功能"或安装目录中的卸载程序卸载。卸载时会一并删除日志等运行时产生的文件,并**询问是否保留配置文件**:选择保留则留下 `config.json`(重装后可继续使用),选择不保留则删除整个安装目录。静默卸载不弹窗,默认保留配置。 -- **便携版**:先退出核心程序,再删除整个目录即可。如设置过开机自启,请先在托盘菜单中关闭。 +- **安装版**:通过系统"应用和功能"或安装目录中的卸载程序卸载。卸载时会一并删除日志、缓存、配置界面的浏览器数据等运行时产生的文件,并**询问是否保留配置文件**:选择保留则留下 `config.json`(重装后可继续使用),选择不保留则连同 `%APPDATA%\BossKey` 一起删除。静默卸载不弹窗,默认保留配置。 +- **便携版**:先退出核心程序,再运行目录中的 `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`。程序目录本身不会被删,跑完后自行删除即可。 diff --git a/docs/guide/options.md b/docs/guide/options.md index 94e5298..9eb0cfb 100644 --- a/docs/guide/options.md +++ b/docs/guide/options.md @@ -45,12 +45,11 @@ title: 其他选项 Windows 自带控制托盘图标显隐的功能,可手动设置哪些程序的图标显示在任务栏角落,具体步骤参见微软官方教程 [在 Windows 中自定义任务栏 · 系统托盘](https://support.microsoft.com/zh-cn/windows/experience/personalization/customize-the-taskbar-in-windows#system-tray),或直接打开 [任务栏设置](ms-settings:taskbar)(`ms-settings:taskbar` 链接仅在 Windows 上有效,浏览器会先请求确认)。 ::: -### 隐藏前发送暂停键(Beta) +### 隐藏前发送暂停键 开启后,隐藏窗口**前**会先向窗口发送**媒体暂停键**,尝试暂停其中正在播放的视频 / 音乐。 - 默认**关闭**。 -- 属于实验性功能,启用后会带来约 **0.2 秒**的隐藏延迟。 - 与"隐藏后静音"侧重点不同:静音只是消音,暂停键会真正暂停播放。 - 除了此功能外,Boss Key 提供的**进程冻结**功能,也有相同的暂停效果,同时还能降低资源消耗,详见 [进程冻结](/guide/freeze)。 diff --git a/docs/guide/recovery.md b/docs/guide/recovery.md index 7cfe654..79e164b 100644 --- a/docs/guide/recovery.md +++ b/docs/guide/recovery.md @@ -26,7 +26,7 @@ Boss Key 核心内置了**崩溃自愈三层防线**,即使程序意外崩溃 ### 第一层:崩溃日志 -核心会把关键事件与 panic 信息写入程序目录 `logs` 文件夹下的日志文件,按天切割为 `BossKey-YYYY-MM-DD.log`,并按 [日志保留天数](/guide/options#日志保留天数) 自动清理过期文件(设为 0 则关闭日志)。**排查问题时先看当天的日志。** +核心会把关键事件与 panic 信息写入数据目录 `logs` 文件夹下的日志文件,按天切割为 `BossKey-YYYY-MM-DD.log`,并按 [日志保留天数](/guide/options#日志保留天数) 自动清理过期文件(设为 0 则关闭日志)。**排查问题时先看当天的日志。** ### 第二层:崩溃恢复 @@ -42,9 +42,13 @@ Boss Key 核心内置了**崩溃自愈三层防线**,即使程序意外崩溃 | 文件 | 位置 | 作用 | | --- | --- | --- | -| `config.json` | 程序目录 | 你的全部设置与绑定 | -| `logs/BossKey-YYYY-MM-DD.log` | 程序目录 | 崩溃 / 事件日志(按天切割,按保留天数自动清理) | -| `recovery.json` | 程序目录 | 隐藏状态快照,用于崩溃恢复(正常退出时删除) | +| `config.json` | 数据目录 | 你的全部设置与绑定 | +| `logs/BossKey-YYYY-MM-DD.log` | 数据目录 | 崩溃 / 事件日志(按天切割,按保留天数自动清理) | +| `recovery.json` | 数据目录 | 隐藏状态快照,用于崩溃恢复(正常退出时删除) | + +::: info 数据目录在哪 +便携版就在程序目录里,安装版在 `%APPDATA%\BossKey`(详见[数据存放位置](/guide/installation#数据存放位置))。便携版所在目录不可写时也会改用 `%APPDATA%\BossKey`。配置界面状态栏的**打开日志目录**按钮总是打开当前实际使用的那个目录,日志开头也会记录它。 +::: ::: warning 请勿手动删除运行中的 recovery.json 若在窗口处于隐藏状态时手动删除 `recovery.json`,崩溃恢复将失去这次快照。 diff --git a/docs/zh-tw/dev/architecture.md b/docs/zh-tw/dev/architecture.md index dffd501..c677953 100644 --- a/docs/zh-tw/dev/architecture.md +++ b/docs/zh-tw/dev/architecture.md @@ -23,7 +23,7 @@ Boss Key v3 採用 **核心+設定分離** 的**雙程序架構**,兩者透 │ │ • 列舉/隱藏/顯示視窗 │ └────────────┬───────────┘ │ │ │ • Core Audio 靜音 │ │ 讀寫 │ │ │ • NtSuspend 程序凍結 │ ┌────────────▼───────────┐ │ -│ │ • 通知區域圖示/通知 │ │ config.json(與 exe 同資料夾)│ +│ │ • 通知區域圖示/通知 │ │ config.json(資料目錄) │ │ │ • 開機自動啟動(排程/登錄檔) │ 核心收到 reload 後熱重新載入 │ │ └──────────────────────────┘ └────────────────────────┘ │ │ ▲ 隨登入自動啟動 │ @@ -68,10 +68,11 @@ Boss-Key/ ├── Cargo.toml workspace(含 release profile 調校) ├── crates/ │ ├── common/ 共用程式庫(無平台相依,可跨平台編譯) -│ │ └── src/{model,config,matching,ipc,i18n}.rs +│ │ └── src/{model,config,matching,ipc,i18n,paths}.rs │ │ model WindowInfo / WindowRule / ProcessRule(serde 相容舊 config.json,PID 大寫) -│ │ config Config/Setting/Hotkey(相容讀取舊設定 + 移轉) +│ │ config Config/Setting/Hotkey(相容讀取舊設定 + 移轉;儲存走 tmp + rename 原子取代) │ │ matching 視窗比對邏輯 +│ │ paths 資料目錄定位(安裝版走 %APPDATA%,可攜版就地,見下) │ │ ipc Command/Response 協定 + PipeClient 用戶端 │ │ i18n 介面語言標籤(Lang)與語言偏好解析,核心與設定程式共用 │ └── core/ 常駐核心(lib + bin) @@ -101,7 +102,7 @@ Boss-Key/ └── apps/config/ 設定介面(Tauri 2 + Svelte 5) ├── src-tauri/ Rust 後端命令 + tauri.conf.json + capabilities │ └── src/verhub.rs Verhub 用戶端(版本/公告/回饋/日誌/專案連結,基於 verhub-sdk; - │ 專案連結帶快取:記憶體 + 同目錄 verhub_cache.json,有效期一天) + │ 專案連結帶快取:記憶體 + 資料目錄下的 verhub_cache.json,有效期一天) ├── ui/ 前端原始碼(Vite + Svelte 5) │ └── src/ lib/(純邏輯 + vitest 測試)+ components/(Svelte 元件) │ + locales/(三語文案 catalog,以 zh-CN.js 為基準) @@ -112,6 +113,41 @@ Boss-Key/ `crates/common` 刻意不相依於 Windows API,因此可以跨平台編譯,其純邏輯(設定解析、比對、協定)也更易做單元測試。平台相關程式碼集中在 `crates/core`。 ::: +## 資料目錄 + +設定 `config.json`、記錄檔 `logs/`、復原檔 `recovery.json`、快取 `verhub_cache.json` 共處一個**資料目錄**,由 `crates/common/src/paths.rs` 定位。安裝版與可攜版分開對待: + +| 情形 | 資料目錄 | `DataDirKind` | +| --- | --- | --- | +| 安裝版 | `%APPDATA%\BossKey` | `Installed` | +| 可攜版,程式資料夾可寫入 | 程式資料夾 | `Portable` | +| 可攜版,程式資料夾寫不進去 | `%APPDATA%\BossKey` | `PortableFallback` | + +可攜版把資料留在程式資料夾,複製走整個資料夾就帶走了全部設定;安裝版則不能這麼做——安裝程式可以裝進 `Program Files`,那裡一般權限程序不可寫入,設定程式每次儲存都會得到 `os error 5`。 + +### 怎麼分辨是哪一種 + +看程式資料夾裡有沒有安裝痕跡(`paths::is_installed`): + +1. 安裝程式放的標記檔案 `installed.marker`(`[Files]` 裡裝,解除安裝時隨之移除); +2. 解除安裝程式 `unins*.exe` —— 兜底,標記檔案被誤刪時仍認得出是安裝版,不至於把資料寫回 `Program Files`。序號隨重複安裝遞增,故按前綴比對。 + +::: warning 判據必須是檔案,不能是程序權限 +核心可能以系統管理員身分執行、設定程式不會:核心在 `Program Files` 下寫得進去,設定程式寫不進去。若兩邊各按自己能否寫入來選資料夾,就會各讀一份設定,使用者改了設定卻不生效。看檔案則兩邊必然一致。也因此,安裝版根本不做可寫性探測——結果一樣是使用者資料夾。 +::: + +### 回退與移轉 + +可攜版探測到程式資料夾不可寫入時退回使用者資料夾,`kind` 記為 `PortableFallback`。核心把它寫進記錄檔,設定程式透過 `data_location` 命令讀到後彈出提示,說明這是權限問題以及怎麼改(見 `DataNoticeModal.svelte`)。程式功能不受影響。 + +用到使用者資料夾時,程式資料夾裡的 `config.json` 會搬過來:先複製,再盡力刪掉原檔案。目標已有設定就不動它——那是目前在用的一份,舊檔案不得覆蓋,也不去刪。刪不掉(沒有寫入權限、檔案被占用)就留在原處,反正不會再被讀到。 + +::: tip 設定介面的瀏覽器資料另有一處 +Tauri 按 `tauri.conf.json` 裡的 identifier 把 WebView2 使用者資料放在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,不在資料目錄裡,也不由 `paths.rs` 管。安裝程式的解除安裝程式與可攜版隨附的 `scripts/cleanup.ps1` 都會清理它。 +::: + +每次啟動的實際資料目錄與判定結果會寫進記錄檔開頭,排查讀寫失敗先看它。 + ## 核心內部:Agent 訊息迴圈 `agent.rs` 是核心的中樞:它建立一個**隱藏的訊息視窗**並執行 Windows 訊息迴圈,彙整以下事件來源: @@ -129,6 +165,8 @@ Boss-Key/ 當觸發隱藏/顯示時,交由 `HideController` 編排,流程為「意圖先行」兩段式:`plan_hide` 算出執行計畫(剪掉失效紀錄、補齊 PID)→ 把計畫後的快照寫入 `recovery.json`(先寫入再動手,隱藏中途當機不丟紀錄)→ `commit_hide` 同步隱藏視窗(`SW_HIDE`),並把靜音/凍結/暫停鍵交給副作用專職執行緒(`effects_worker.rs`)按 FIFO 非同步執行——訊息迴圈不被慢操作(音訊列舉、pssuspend 等待)阻塞,快速鍵與介面保持回應。 +佇列內的先後有講究:暫停鍵→靜音→靜置→凍結。凍結讓程序徹底停止回應訊息,隱藏若還沒在螢幕上畫完就凍結,被凍結的視窗會留下殘影;發出去的暫停鍵同樣要有時間被目標程式處理掉。故凍結前統一靜置一次(`FREEZE_SETTLE_DELAY`,整批只等一次,沒有要凍結的程序就不等)。靜音不排在這道等待之後——它走音訊工作階段,與目標程序是否在跑無關。 + 復原(顯示)時逐條校驗紀錄的有效性:控制代碼須仍存在且仍屬於當初的處理程序(`IsWindow` + PID 比對),凍結/靜音紀錄須符合處理程序建立時刻——控制代碼與 PID 都會被系統回收重複使用,校驗不過的紀錄跳過並如實計入日誌。 ::: info 可測試性設計 @@ -137,7 +175,7 @@ Boss-Key/ ## 穩定性設計(當機自癒三層防線) -1. **當機記錄**:關鍵事件與 panic 寫入 exe 同資料夾的 `logs/BossKey-YYYY-MM-DD.log`(按日切割,依 `log_retention_days` 保留,0 表示不記錄;release 建置丟棄 DEBUG 級)。 +1. **當機記錄**:關鍵事件與 panic 寫入[資料目錄](#資料目錄)下的 `logs/BossKey-YYYY-MM-DD.log`(按日切割,依 `log_retention_days` 保留,0 表示不記錄;release 建置丟棄 DEBUG 級)。 2. **當機復原**:隱藏動作執行前先把「將要隱藏/凍結/靜音什麼」寫入 `recovery.json`(tmp + rename 原子替換),異常結束後重新啟動自動找回;快照帶開機時刻與處理程序建立時刻,跨重新開機的過期快照直接丟棄,不會對無關視窗/處理程序做復原動作。 3. **監控程式**:排程工作 `RestartOnFailure`(當機後 1 分鐘內重新啟動,最多 3 次)。release 建置 `panic = "abort"`,panic 掛鉤寫完記錄後以非零碼結束,正好觸發排程工作重新啟動。 diff --git a/docs/zh-tw/dev/config-reference.md b/docs/zh-tw/dev/config-reference.md index ce0312e..d30e030 100644 --- a/docs/zh-tw/dev/config-reference.md +++ b/docs/zh-tw/dev/config-reference.md @@ -4,7 +4,7 @@ title: 設定檔欄位 # 設定檔欄位參考 -Boss Key 的設定儲存在與執行檔**同資料夾**的 `config.json` 中。**結構與舊版完全相容**,舊使用者設定可直接沿用。首次執行若不存在則使用預設值。欄位定義見 `crates/common/src/config.rs`。 +Boss Key 的設定儲存在 `config.json` 中,可攜版存在程式資料夾,安裝版存在 `%APPDATA%\BossKey`,詳見[資料目錄](/zh-tw/dev/architecture#資料目錄)。位置變動時舊設定會自動移轉過去。**結構與舊版完全相容**,舊使用者設定可直接沿用。首次執行若不存在則使用預設值。欄位定義見 `crates/common/src/config.rs`。 ::: tip 一般不需手動修改 設定由設定介面自動讀寫並儲存,通常不需手動編輯。本頁面向需要理解欄位含義的開發者。 @@ -14,7 +14,8 @@ Boss Key 的設定儲存在與執行檔**同資料夾**的 `config.json` 中。* | 欄位 | 型別 | 說明 | | --- | --- | --- | -| `version` | string | 設定版本 | +| `version` | string | 設定 schema 版本,結構變動時才動 | +| `app_version` | string | 上次執行過的**程式**版本;與目前程式版本不符即「更新後首次啟動」,核心據此自動開啟設定介面。預設留空 | | `history` | number[] | 歷史記錄(時間戳記) | | `frozen_pids` | number[] | 目前被凍結的程序 PID(用於復原) | | `hotkey` | object | 鍵盤快速鍵,見下 | diff --git a/docs/zh-tw/dev/contributing.md b/docs/zh-tw/dev/contributing.md index 4bed72e..487c6ab 100644 --- a/docs/zh-tw/dev/contributing.md +++ b/docs/zh-tw/dev/contributing.md @@ -55,7 +55,7 @@ cargo build --release ``` ::: warning 版本號一致性 -若您改動了版本號,務必保證 `Cargo.toml`、`tauri.conf.json`、`ui/package.json`、`Cargo.lock` **四處一致**。CI 會用 `scripts/version.ps1 check` 驗證。日常功能開發一般**不要**手動改版本號——版本號由發布流程統一管理,詳見 [打包與發布](/zh-tw/dev/release)。 +版本號只寫在 `Cargo.toml` 的 `[workspace.package] version`,`Cargo.lock` 跟著它走;其餘地方建置時自動取用,無需手改。CI 會用 `scripts/version.ps1 check` 驗證。日常功能開發一般**不要**手動改版本號——版本號由發布流程統一管理,詳見 [打包與發布](/zh-tw/dev/release)。 ::: ## 程式碼風格 diff --git a/docs/zh-tw/dev/project-management.md b/docs/zh-tw/dev/project-management.md index 2b4d1bd..47937f2 100644 --- a/docs/zh-tw/dev/project-management.md +++ b/docs/zh-tw/dev/project-management.md @@ -51,7 +51,7 @@ feat/* · fix/* · doc/* ──PR──▶ dev ──PR(發版時)──▶ ## 版本與發布管理 -- 版本號的**唯一真實來源**是 `Cargo.toml` 的 `[workspace.package] version`,其餘三處檔案必須與之一致。 +- 版本號的**唯一真實來源**是 `Cargo.toml` 的 `[workspace.package] version`,其餘地方在建置時取自它。 - 發布透過 GitHub Actions 手動觸發的工作流程完成:寫入版本號 → 打 tag → 建置並發布 Release。 - 詳見 [打包與發布](/zh-tw/dev/release)。 diff --git a/docs/zh-tw/dev/release.md b/docs/zh-tw/dev/release.md index efc211b..a94f525 100644 --- a/docs/zh-tw/dev/release.md +++ b/docs/zh-tw/dev/release.md @@ -30,30 +30,49 @@ dist/ ├── Boss-Key/ 可攜版(複製走即可用,發布時整個資料夾壓成 zip) │ ├── Boss Key.exe 常駐核心(內嵌 DPI/長路徑 manifest + 版本資訊 + 圖示) │ ├── config.exe 設定介面(前端已內嵌,自包含) +│ ├── cleanup.ps1 殘留資料清理指令碼(可攜版沒有解除安裝程式) │ ├── LICENSE.txt -│ └── README.md +│ ├── README.md 簡體中文 +│ ├── README.en.md English +│ └── README.zh-TW.md 繁體中文 └── installer/ 安裝包(-Installer 時產生) └── Boss-Key-<版本>-Setup.exe InnoSetup(安裝前自動結束執行中的核心) ``` -可攜版**不需安裝、無外部相依**(除系統內建的 WebView2)。兩個程式透過同資料夾的 `config.json` 與具名管道協作。 +可攜版**不需安裝、無外部相依**(除系統內建的 WebView2)。兩個程式透過[資料目錄](/zh-tw/dev/architecture#資料目錄)下的 `config.json` 與具名管道協作。 + +三語 README 都要帶上:可攜版沒有安裝精靈,README 是唯一的隨附說明,其中「資料存放位置與清理」一節交代了程式在使用者資料夾下留了什麼、怎麼用 `cleanup.ps1` 清掉。 + +::: danger 可攜資料夾裡不能出現 installed.marker +程式憑它認出自己是安裝版並改用 `%APPDATA%\BossKey`(見[資料目錄](/zh-tw/dev/architecture#資料目錄))。該檔案由 `.iss` 從指令碼資料夾直取,不經過 `dist\Boss-Key`——若混進可攜包,可攜版就不可攜了。 +::: + +安裝包預設走**一般權限**安裝(`%LocalAppData%\Programs\Boss Key`),使用者可在精靈首頁改選「為所有使用者安裝」裝進 `Program Files`。兩種模式下資料都在 `%APPDATA%\BossKey`,不在安裝資料夾裡。 ## 版本號管理 ::: info 版本號唯一真實來源 -版本號的唯一真實來源是 `Cargo.toml` 的 `[workspace.package] version`。另外三處必須與之一致:`apps/config/src-tauri/tauri.conf.json`、`apps/config/ui/package.json`、`Cargo.lock`。 +版本號只寫在 `Cargo.toml` 的 `[workspace.package] version` 一處,`Cargo.lock` 跟著它走。其餘地方**不再各存一份**,一律在建置時取真實版本號: + +| 位置 | 版本號從哪來 | +| --- | --- | +| 兩個 exe 的檔案版本資訊 | `CARGO_PKG_VERSION`(tauri-winres/tauri-build;`tauri.conf.json` 不寫 `version` 即回落到 Cargo.toml) | +| 核心資訊清單的 `assemblyIdentity` | `crates/core/build.rs` 按 `CARGO_PKG_VERSION` 填入(換算成純數字四段號) | +| 安裝包的 `MyAppVersion` | `scripts/package.ps1` 從 `Cargo.toml` 讀出後傳給 Inno;未傳則編譯報錯,不留過期的預設值 | +| 程式內與回報給 Verhub 的版本 | `env!("CARGO_PKG_VERSION")` | +| 設定檔的 `app_version` | 核心啟動時寫入 `bosskey_common::APP_VERSION` | ::: `scripts/version.ps1` 負責寫入與驗證: ```powershell -# 把版本號寫入四處檔案(並同步 Cargo.lock) +# 把版本號寫入 Cargo.toml(並同步 Cargo.lock) powershell -File scripts/version.ps1 apply 3.0.1 -# 驗證四處與該 tag 一致,不一致則失敗 +# 驗證 Cargo.toml 與該 tag 一致,不一致則失敗 powershell -File scripts/version.ps1 check 3.0.1 -# 不給 tag 時以 Cargo.toml 為基準驗證其餘檔案 +# 不給 tag 時只回顯目前版本號 powershell -File scripts/version.ps1 check # 印出目前版本號 @@ -79,7 +98,7 @@ powershell -File scripts/version.ps1 show **觸發**:手動(`workflow_dispatch`),輸入要發布的版本號。**請從 `main` 觸發**(待發布內容合併進 `main` 之後)。 **做什麼**: -1. 用 `version.ps1 apply` 把版本號寫入四處檔案; +1. 用 `version.ps1 apply` 把版本號寫入 `Cargo.toml` 並同步 `Cargo.lock`; 2. 以 OIDC 身分向 [octo-sts](https://octo-sts.dev) 換取本儲存庫 `contents:write` 的短期 token; 3. 經 GraphQL `createCommitOnBranch` 把版本號變更提交到觸發分支,並打上 `v<版本>` 附註 tag——API 建立的提交由 GitHub 伺服器端簽章,帶 **Verified** 徽章; 4. 檢出該 tag → 驗證 tag 與程式碼版本一致 → 前端/Rust 測試; diff --git a/docs/zh-tw/dev/testing.md b/docs/zh-tw/dev/testing.md index 4c90c4f..e5a1697 100644 --- a/docs/zh-tw/dev/testing.md +++ b/docs/zh-tw/dev/testing.md @@ -34,7 +34,9 @@ cargo test -p bosskey-core -- --test-threads=1 - `PID` 大寫欄位相容、正規表示式規則來回序列化; - 連按次數/連按時間的範圍鉗制; - 協定 `Command`/`Response` 的 round-trip 與 snake_case 標籤; -- 語言標籤解析與偏好正規化(`zh-Hant` → `zh-TW`、無翻譯的語言回落 `auto` 等)。 +- 語言標籤解析與偏好正規化(`zh-Hant` → `zh-TW`、無翻譯的語言回落 `auto` 等); +- 資料目錄定位與移轉(可攜版就地/安裝版走使用者資料夾/不可寫入時回退並報明原因/標記檔案與解除安裝程式的辨識/舊設定搬過去、原檔案刪不掉、目標已有設定不覆蓋); +- 設定寫入的原子性(寫入失敗不截斷原檔案、不留臨時檔案,錯誤訊息帶路徑)。 ### `bosskey-core` diff --git a/docs/zh-tw/guide/faq.md b/docs/zh-tw/guide/faq.md index a726337..45a3f6e 100644 --- a/docs/zh-tw/guide/faq.md +++ b/docs/zh-tw/guide/faq.md @@ -59,6 +59,17 @@ Boss Key 只能隱藏[自身的通知區域圖示](/zh-tw/guide/options),無 設定介面會提示目前缺少哪一項。詳見 [程序凍結](/zh-tw/guide/freeze)。 +## 提示「儲存設定失敗」(拒絕存取/os error 5)怎麼辦? + +先升級到 v3.1.0 或更高版本。舊版本把設定固定存在程式所在資料夾,而選了「為所有使用者安裝」時程式裝在 `C:\Program Files`,一般權限寫不進去,於是每次改設定都儲存失敗。新版本的安裝版一律把設定存到 `%APPDATA%\BossKey`,並把已有的 `config.json` 移轉過去,不需手動處理。可攜版仍存在程式資料夾裡,若那裡不可寫入也會自動改用 `%APPDATA%\BossKey` 並彈出說明。 + +升級後仍然報錯,多半是另外兩種情況: + +- **被防毒軟體攔截**:把 Boss Key 的程式資料夾與 `%APPDATA%\BossKey` 加入防毒軟體信任區。Windows 安全性中心的「受控資料夾存取權」也會以同樣的方式攔截寫入。 +- **設定檔被設為唯讀**:在檔案總管中右鍵 `config.json` → 內容,取消「唯讀」。 + +錯誤訊息裡帶有實際路徑,據此可判斷問題出在哪個資料夾。 + ## 更新後設定會遺失嗎? 不會。`config.json` 結構與舊版**完全相容**,更新後您的綁定、快速鍵、選項都會保留。v2 版的扁平綁定也會自動移轉到新的規則格式。 diff --git a/docs/zh-tw/guide/freeze.md b/docs/zh-tw/guide/freeze.md index 0c8a30f..1de0b4f 100644 --- a/docs/zh-tw/guide/freeze.md +++ b/docs/zh-tw/guide/freeze.md @@ -16,6 +16,8 @@ title: 程序凍結 這是凍結功能的**總開關**。開啟後,每次隱藏命中的視窗時,Boss Key 會暫停對應程序;復原顯示時自動解除凍結,預設使用一般凍結,**不需系統管理員權限**即可對目前使用者的程序生效。 +暫停與解除凍結本身需要時間,開啟後可能會造成一定的隱藏/復原延遲。 + ## 使用增強凍結 一般凍結對某些複雜程式(多程序架構、有算圖子程序等)可能不夠徹底。**增強凍結**改用微軟官方的 `pssuspend64.exe` 工具來暫停程序,效果更強。 @@ -33,12 +35,14 @@ title: 程序凍結 3. 從中找到 **`pssuspend64.exe`**,複製到 Boss Key 的**程式安裝根資料夾**。 4. 回到設定介面,按一下「程序凍結」區域的 **重新偵測** 按鈕,讓 Boss Key 辨識到該檔案。 +增強凍結需呼叫外部程式,同樣會造成一定的隱藏/復原延遲。 + ## 凍結完整程序 預設情況下凍結只作用於命中的程序本身。開啟 **凍結完整程序** 後,Boss Key 會**遞迴凍結該程序的整棵子程序樹**(包括不同名的子 `exe`、算圖程序等),凍結更徹底。 ::: warning -該選項仍在測試階段,可能會對某些程式造成異常。請在瞭解影響後再啟用。 +該選項仍在測試階段,可能會對某些程式造成異常,並因需要走訪整棵程序樹而造成更高的隱藏/復原延遲。請在瞭解影響後再啟用。 ::: ## 前置條件速查 diff --git a/docs/zh-tw/guide/installation.md b/docs/zh-tw/guide/installation.md index cef933d..2989b22 100644 --- a/docs/zh-tw/guide/installation.md +++ b/docs/zh-tw/guide/installation.md @@ -27,26 +27,53 @@ Windows 7 系統及部分精簡版系統可能預設不含 WebView2,導致設 2. 按兩下執行,依照精靈完成安裝。安裝程式在安裝前會**自動結束正在執行的核心程序**,避免檔案被佔用。 3. 安裝完成後會自動啟動,並開啟設定介面。 +安裝程式會先問您裝給誰: + +- **僅為我安裝**(預設,不需系統管理員權限):裝到 `%LocalAppData%\Programs\Boss Key`。 +- **為所有使用者安裝**(需要系統管理員權限):裝到 `C:\Program Files\Boss Key`。 + +兩種模式下設定都存在 `%APPDATA%\BossKey`,不在安裝資料夾裡,見下方[資料存放位置](#資料存放位置)。安裝程式會在安裝資料夾裡放一個 `installed.marker` 檔案,程式據它認出自己是安裝版,請勿刪除。 + ## 使用可攜版 1. 下載 `Boss-Key-<版本>-portable.zip`。 -2. 解壓縮到任意位置(建議放在固定、可寫入的地方)。壓縮檔內已包含一層 `Boss-Key` 資料夾,解壓縮後把它整個搬到您想放的位置即可。 +2. 解壓縮到任意位置。壓縮檔內已包含一層 `Boss-Key` 資料夾,解壓縮後把它整個搬到您想放的位置即可。 3. 執行該資料夾中的 **`Boss Key.exe`**。首次執行會自動開啟設定介面。 解壓縮後資料夾結構如下: ``` Boss-Key/ -├── Boss Key.exe 常駐核心(背景執行,負責隱藏視窗/快速鍵監聽) -├── config.exe 設定介面(依需求開啟,關閉即結束) -├── LICENSE.txt 授權檔案 -└── README.md 使用說明 +├── Boss Key.exe 常駐核心(背景執行,負責隱藏視窗/快速鍵監聽) +├── config.exe 設定介面(依需求開啟,關閉即結束) +├── cleanup.ps1 殘留資料清理指令碼(見下方「解除安裝」) +├── LICENSE.txt 授權檔案 +├── README.md 使用說明(簡體中文) +├── README.en.md 使用說明(English) +└── README.zh-TW.md 使用說明(繁體中文) ``` ::: warning 兩個程式需放在同一資料夾 -`Boss Key.exe` 與 `config.exe` 透過同資料夾下的 `config.json` 與具名管道協作,請勿將它們分開放置。 +`Boss Key.exe` 與 `config.exe` 透過共用的 `config.json` 與具名管道協作,請勿將它們分開放置。 ::: +## 資料存放位置 + +設定、記錄檔、復原檔與快取放在同一個資料夾裡,位置取決於您用的是哪個版本: + +- **可攜版**:就在**程式資料夾**裡。複製走整個資料夾就帶走了全部設定,這正是可攜版該有的樣子。 +- **安裝版**:在 **`%APPDATA%\BossKey`**。安裝資料夾可能是 `C:\Program Files`,一般權限寫不進去,設定存在那裡每次儲存都會失敗。 + +程式憑安裝程式放在程式資料夾裡的 `installed.marker` 分辨自己是哪一種,請勿刪除該檔案。 + +::: warning 可攜版放在了不可寫入的位置 +若可攜版所在資料夾寫不進去(放在了 `C:\Program Files` 之類的地方,或唯讀媒體上),程式會改用 `%APPDATA%\BossKey` 並在設定介面彈出提示,說明是權限問題以及怎麼處理。功能不受影響,只是設定不再跟著程式資料夾走。 +::: + +設定介面用到的瀏覽器元件另有一份資料在 `%LOCALAPPDATA%\cn.hanloth.bosskey.config`,兩個版本都一樣。 + +位置發生變化時(例如把可攜版裝成了安裝版),原先的 `config.json` 會在首次啟動時自動搬過去,您的綁定與快速鍵都會保留。設定介面狀態列的**開啟記錄檔資料夾**按鈕總是開啟目前實際使用的那個資料夾。 + ## 關於兩個執行檔 Boss Key 採用 **核心+設定分離** 的雙程序設計: @@ -67,5 +94,13 @@ Boss Key 採用 **核心+設定分離** 的雙程序設計: ## 解除安裝 -- **安裝版**:透過系統「應用程式與功能」或安裝資料夾中的解除安裝程式移除。解除安裝時會一併刪除記錄檔等執行階段產生的檔案,並**詢問是否保留設定檔**:選擇保留則留下 `config.json`(重新安裝後可繼續使用),選擇不保留則刪除整個安裝資料夾。無訊息解除安裝不會顯示提示,預設保留設定。 -- **可攜版**:先結束核心程式,再刪除整個資料夾即可。如設定過開機自動啟動,請先在通知區域選單中關閉。 +- **安裝版**:透過系統「應用程式與功能」或安裝資料夾中的解除安裝程式移除。解除安裝時會一併刪除記錄檔、快取、設定介面的瀏覽器資料等執行階段產生的檔案,並**詢問是否保留設定檔**:選擇保留則留下 `config.json`(重新安裝後可繼續使用),選擇不保留則連同 `%APPDATA%\BossKey` 一起刪除。無訊息解除安裝不會顯示提示,預設保留設定。 +- **可攜版**:先結束核心程式,再執行資料夾中的 `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`。程式資料夾本身不會被刪,跑完後自行刪除即可。 diff --git a/docs/zh-tw/guide/options.md b/docs/zh-tw/guide/options.md index 81293f8..017ebae 100644 --- a/docs/zh-tw/guide/options.md +++ b/docs/zh-tw/guide/options.md @@ -45,12 +45,11 @@ title: 其他選項 Windows 內建控制通知區域圖示顯示與否的功能,可手動設定哪些程式的圖示顯示在工作列角落,詳細步驟參見微軟官方教學 [在 Windows 中自訂工作列 · 系統匣](https://support.microsoft.com/zh-tw/windows/experience/personalization/customize-the-taskbar-in-windows#system-tray),或直接開啟 [工作列設定](ms-settings:taskbar)(`ms-settings:taskbar` 連結僅在 Windows 上有效,瀏覽器會先要求確認)。 ::: -### 隱藏前傳送暫停鍵(Beta) +### 隱藏前傳送暫停鍵 開啟後,隱藏視窗**前**會先向視窗傳送**媒體暫停鍵**,嘗試暫停其中正在播放的影片/音樂。 - 預設**關閉**。 -- 屬於實驗性功能,啟用後會造成約 **0.2 秒**的隱藏延遲。 - 與「隱藏後靜音」側重點不同:靜音只是消音,暫停鍵會真正暫停播放。 - 除了此功能外,Boss Key 提供的**程序凍結**功能,也有相同的暫停效果,同時還能降低資源消耗,詳見 [程序凍結](/zh-tw/guide/freeze)。 diff --git a/docs/zh-tw/guide/recovery.md b/docs/zh-tw/guide/recovery.md index 983d322..7ea76af 100644 --- a/docs/zh-tw/guide/recovery.md +++ b/docs/zh-tw/guide/recovery.md @@ -26,7 +26,7 @@ Boss Key 核心內建了**當機自癒三層防線**,即使程式意外當機 ### 第一層:當機記錄 -核心會把關鍵事件與 panic 資訊寫入程式資料夾 `logs` 資料夾下的記錄檔,按日切割為 `BossKey-YYYY-MM-DD.log`,並依 [記錄檔保留天數](/zh-tw/guide/options) 自動清除過期檔案(設為關閉則不記錄)。**排查問題時先看當天的記錄檔。** +核心會把關鍵事件與 panic 資訊寫入資料目錄 `logs` 下的記錄檔,按日切割為 `BossKey-YYYY-MM-DD.log`,並依 [記錄檔保留天數](/zh-tw/guide/options) 自動清除過期檔案(設為關閉則不記錄)。**排查問題時先看當天的記錄檔。** ### 第二層:當機復原 @@ -42,9 +42,13 @@ Boss Key 核心內建了**當機自癒三層防線**,即使程式意外當機 | 檔案 | 位置 | 作用 | | --- | --- | --- | -| `config.json` | 程式資料夾 | 您的全部設定與綁定 | -| `logs/BossKey-YYYY-MM-DD.log` | 程式資料夾 | 當機/事件記錄(按日切割,依保留天數自動清除) | -| `recovery.json` | 程式資料夾 | 隱藏狀態快照,用於當機復原(正常結束時刪除) | +| `config.json` | 資料目錄 | 您的全部設定與綁定 | +| `logs/BossKey-YYYY-MM-DD.log` | 資料目錄 | 當機/事件記錄(按日切割,依保留天數自動清除) | +| `recovery.json` | 資料目錄 | 隱藏狀態快照,用於當機復原(正常結束時刪除) | + +::: info 資料目錄在哪 +可攜版就在程式資料夾裡,安裝版在 `%APPDATA%\BossKey`(詳見[資料存放位置](/zh-tw/guide/installation#資料存放位置))。可攜版所在資料夾不可寫入時也會改用 `%APPDATA%\BossKey`。設定介面狀態列的**開啟記錄檔資料夾**按鈕總是開啟目前實際使用的那個資料夾,記錄檔開頭也會記下它。 +::: ::: warning 請勿手動刪除執行中的 recovery.json 若在視窗處於隱藏狀態時手動刪除 `recovery.json`,當機復原將失去這次快照。 diff --git a/scripts/cleanup.ps1 b/scripts/cleanup.ps1 new file mode 100644 index 0000000..7ab1f60 --- /dev/null +++ b/scripts/cleanup.ps1 @@ -0,0 +1,145 @@ +# Boss Key 残留数据清理脚本 +# +# 便携版的设置、日志、恢复文件就放在程序文件夹里,删掉文件夹即可。但另有两样东西在 +# 用户目录下,删文件夹清不掉:配置界面的浏览器数据,以及程序文件夹不可写时改存到 +# %APPDATA%\BossKey 的那份设置。开机自启还会留下计划任务与注册表项。本脚本负责这些。 +# +# 用法(在本文件所在目录打开 PowerShell): +# powershell -ExecutionPolicy Bypass -File cleanup.ps1 +# powershell -ExecutionPolicy Bypass -File cleanup.ps1 -Force # 不询问,直接清理 +# +# 本脚本不会删除程序本身:程序文件夹请在脚本跑完后自行删除。 +# 安装版无需用它,卸载程序已经做了同样的事。 + +param([switch]$Force) + +$ErrorActionPreference = "Stop" + +# 界面语言跟随系统,与程序保持一致(zh-CN 为基准)。 +$lang = if ($PSUICulture -eq 'zh-CN' -or $PSUICulture -like 'zh-Hans*') { 'zh-CN' } +elseif ($PSUICulture -like 'zh-*') { 'zh-TW' } +else { 'en' } + +$catalog = @{ + 'zh-CN' = @{ + Title = 'Boss Key 残留数据清理' + Nothing = '没有发现残留数据,无需清理。' + Found = '将删除以下内容:' + Confirm = '确认删除?输入 y 继续,其他任意键取消' + Cancelled = '已取消,未做任何改动。' + Killing = '正在结束仍在运行的 Boss Key 进程...' + Removed = '已删除:{0}' + Failed = '删除失败:{0}({1})' + Done = '清理完成。程序文件夹请自行删除。' + DataDir = '用户目录下的数据(程序目录不可写时存到这里)' + WebView = '配置界面的浏览器数据' + Task = '开机自启计划任务' + RegRun = '开机自启注册表项' + } + 'zh-TW' = @{ + Title = 'Boss Key 殘留資料清理' + Nothing = '沒有發現殘留資料,不需清理。' + Found = '將刪除以下內容:' + Confirm = '確認刪除?輸入 y 繼續,其他任意鍵取消' + Cancelled = '已取消,未做任何變更。' + Killing = '正在結束仍在執行的 Boss Key 處理程序...' + Removed = '已刪除:{0}' + Failed = '刪除失敗:{0}({1})' + Done = '清理完成。程式資料夾請自行刪除。' + DataDir = '使用者資料夾下的資料(程式資料夾不可寫入時存到這裡)' + WebView = '設定介面的瀏覽器資料' + Task = '開機自動啟動排程工作' + RegRun = '開機自動啟動登錄項目' + } + 'en' = @{ + Title = 'Boss Key leftover data cleanup' + Nothing = 'No leftover data found; nothing to clean up.' + Found = 'The following will be deleted:' + Confirm = 'Delete these? Type y to continue, anything else to cancel' + Cancelled = 'Cancelled; nothing was changed.' + Killing = 'Stopping running Boss Key processes...' + Removed = 'Deleted: {0}' + Failed = 'Could not delete {0} ({1})' + Done = 'Cleanup finished. Delete the program folder yourself.' + DataDir = 'Data in the user folder (used when the program folder is not writable)' + WebView = "Settings window's browser data" + Task = 'Autostart scheduled task' + RegRun = 'Autostart registry entry' + } +} +$t = $catalog[$lang] + +# 以下四项须与程序保持一致: +# 用户数据目录 crates/common/src/paths.rs(USER_DIR_NAME) +# 浏览器数据目录 apps/config/src-tauri/tauri.conf.json(identifier) +# 自启任务与注册表项 crates/core/src/autostart.rs(TASK_NAME / REG_VALUE_NAME) +$dataDir = Join-Path $env:APPDATA 'BossKey' +$webViewDir = Join-Path $env:LOCALAPPDATA 'cn.hanloth.bosskey.config' +$taskName = 'BossKeyAutostart' +$runSubkey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$runValueName = 'Boss Key Application' + +$targets = @() +if (Test-Path $dataDir) { + $targets += [pscustomobject]@{ Kind = 'Dir'; Label = $t.DataDir; Detail = $dataDir } +} +if (Test-Path $webViewDir) { + $targets += [pscustomobject]@{ Kind = 'Dir'; Label = $t.WebView; Detail = $webViewDir } +} +# schtasks 找不到任务时返回非零码,用它判断存在性;输出丢弃,此处只关心结果。 +& schtasks.exe /Query /TN $taskName *> $null +if ($LASTEXITCODE -eq 0) { + $targets += [pscustomobject]@{ Kind = 'Task'; Label = $t.Task; Detail = $taskName } +} +$runEntry = Get-ItemProperty -Path $runSubkey -Name $runValueName -ErrorAction SilentlyContinue +if ($runEntry) { + $targets += [pscustomobject]@{ Kind = 'Reg'; Label = $t.RegRun; Detail = "$runSubkey\$runValueName" } +} + +Write-Host $t.Title -ForegroundColor Cyan +if ($targets.Count -eq 0) { + Write-Host $t.Nothing -ForegroundColor Green + return +} + +Write-Host "" +Write-Host $t.Found +foreach ($target in $targets) { + Write-Host (" - {0}`n {1}" -f $target.Label, $target.Detail) +} +Write-Host "" + +if (-not $Force) { + $answer = Read-Host $t.Confirm + if ($answer -ne 'y' -and $answer -ne 'Y') { + Write-Host $t.Cancelled -ForegroundColor Yellow + return + } +} + +# 核心是常驻进程,不结束它的话数据目录里的日志正被占用,删不干净; +# 它还会在退出前回写配置,把刚删掉的目录重新建出来。 +Write-Host $t.Killing +foreach ($name in @('Boss Key', 'config')) { + Get-Process -Name $name -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue +} + +foreach ($target in $targets) { + try { + switch ($target.Kind) { + 'Dir' { Remove-Item -LiteralPath $target.Detail -Recurse -Force -ErrorAction Stop } + 'Task' { + & schtasks.exe /Delete /F /TN $target.Detail *> $null + if ($LASTEXITCODE -ne 0) { throw "schtasks exit $LASTEXITCODE" } + } + 'Reg' { Remove-ItemProperty -Path $runSubkey -Name $runValueName -Force -ErrorAction Stop } + } + Write-Host ($t.Removed -f $target.Detail) -ForegroundColor Green + } + catch { + Write-Host ($t.Failed -f $target.Detail, $_.Exception.Message) -ForegroundColor Red + } +} + +Write-Host "" +Write-Host $t.Done -ForegroundColor Green diff --git a/scripts/package.ps1 b/scripts/package.ps1 index 5f4bc93..0cfea16 100644 --- a/scripts/package.ps1 +++ b/scripts/package.ps1 @@ -59,7 +59,13 @@ Copy-Item "target\release\core.exe" (Join-Path $portableDir "Boss Key.exe") Copy-Item "target\release\bosskey-config.exe" (Join-Path $portableDir "config.exe") # LICENSE 带上 .txt 后缀:Windows 上双击才有默认打开方式;安装包的许可协议页也复用这份 Copy-Item "LICENSE" (Join-Path $portableDir "LICENSE.txt") +# 三语 README 全带上:便携版没有安装向导,README 是唯一的随包说明, +# 其中「清理残留数据」一节交代了程序在用户目录下留了什么。 Copy-Item "README.md" (Join-Path $portableDir "README.md") +Copy-Item "README.en.md" (Join-Path $portableDir "README.en.md") +Copy-Item "README.zh-TW.md" (Join-Path $portableDir "README.zh-TW.md") +# 便携版没有卸载程序,用户目录下的数据得靠它清 +Copy-Item "scripts\cleanup.ps1" (Join-Path $portableDir "cleanup.ps1") Write-Host "==> 便携版组装完成:$portableDir" -ForegroundColor Green Get-ChildItem $portableDir | Select-Object Name, @{Name = "Size"; Expression = { "{0:N0} KB" -f ($_.Length / 1KB) } } | Format-Table -AutoSize diff --git a/scripts/version.ps1 b/scripts/version.ps1 index eabcee2..dcbc739 100644 --- a/scripts/version.ps1 +++ b/scripts/version.ps1 @@ -1,14 +1,18 @@ # Boss Key 版本号工具 # -# 版本号的唯一真源是 Cargo.toml 的 [workspace.package] version, -# 另外三处(tauri.conf.json、ui/package.json、Cargo.lock)必须与之一致。 +# 版本号只写在 Cargo.toml 的 [workspace.package] version 一处,Cargo.lock 跟着它走。 +# 其余地方都取真实版本号,不再各存一份,从源头上没有「对不上」的可能: +# - 两个 exe 的文件版本信息:CARGO_PKG_VERSION(tauri-winres / tauri-build) +# - 核心清单的 assemblyIdentity:crates/core/build.rs 按 CARGO_PKG_VERSION 填 +# - 安装包的 MyAppVersion:scripts/package.ps1 从 Cargo.toml 读出后传入 +# - 程序内与上报给 Verhub 的版本:env!("CARGO_PKG_VERSION") # 发版流程(.github/workflows/tag.yml)先 apply 写入并提交, # 构建流程(release.yml)再 check 校验,防止 tag 与代码里的版本号对不上。 # # 用法: -# powershell -File scripts/version.ps1 apply v3.0.1 # 写入四处文件 -# powershell -File scripts/version.ps1 check v3.0.1 # 校验四处与该 tag 一致,不一致则失败 -# powershell -File scripts/version.ps1 check # 不给 tag 时以 Cargo.toml 为基准校验 +# powershell -File scripts/version.ps1 apply v3.0.1 # 写入 Cargo.toml 并同步 Cargo.lock +# powershell -File scripts/version.ps1 check v3.0.1 # 校验与该 tag 一致,不一致则失败 +# powershell -File scripts/version.ps1 check # 不给 tag 时只回显当前版本号 # powershell -File scripts/version.ps1 show # 打印当前版本号 # # 在 GitHub Actions 中运行时,会把 version / version4 / is_prerelease @@ -26,8 +30,6 @@ $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot $cargoToml = Join-Path $root "Cargo.toml" -$tauriConf = Join-Path $root "apps\config\src-tauri\tauri.conf.json" -$uiPackage = Join-Path $root "apps\config\ui\package.json" # 统一用 .NET 读写:Windows PowerShell 5.1 的 Get-Content/Set-Content 按 ANSI 处理, # 会把文件里的中文写成乱码。写回一律 UTF-8 无 BOM —— BOM 会让 cargo / node 解析失败。 @@ -85,21 +87,6 @@ function Set-CargoVersion([string]$Version) { Write-TextFile $cargoToml $updated } -function Set-JsonVersion([string]$Path, [string]$Version) { - $content = Read-TextFile $Path - # 只改顶层第一处 "version": "...",避免动到依赖项里的版本约束 - $updated = [regex]::Replace( - $content, - '(?m)^(\s*"version"\s*:\s*")[^"]+(")', - { param($m) "$($m.Groups[1].Value)$Version$($m.Groups[2].Value)" }, - 1) - Write-TextFile $Path $updated -} - -function Get-JsonVersion([string]$Path) { - return (Read-TextFile $Path | ConvertFrom-Json).version -} - # 把版本号回填到 Cargo.lock(workspace 成员),保持 lock 与 Cargo.toml 同步 function Update-CargoLock { cargo update --workspace --quiet @@ -129,31 +116,25 @@ switch ($Action) { 'apply' { $version = Get-NormalizedVersion $Tag Set-CargoVersion $version - Set-JsonVersion $tauriConf $version - Set-JsonVersion $uiPackage $version Update-CargoLock - Write-Host "已写入 Cargo.toml / tauri.conf.json / package.json / Cargo.lock" + Write-Host "已写入 Cargo.toml,并同步 Cargo.lock" Export-Outputs $version } 'check' { - # 不给 tag 时,以 Cargo.toml 为基准校验其余文件是否跟得上 - $version = if ($Tag) { Get-NormalizedVersion $Tag } else { Get-CurrentVersion } - $actual = @{ - 'Cargo.toml' = Get-CurrentVersion - 'tauri.conf.json' = Get-JsonVersion $tauriConf - 'package.json' = Get-JsonVersion $uiPackage - } - - $bad = $actual.GetEnumerator() | Where-Object { $_.Value -ne $version } - if ($bad) { - foreach ($entry in $bad) { - Write-Host "::error::$($entry.Key) 的版本号是 $($entry.Value),与目标 $version 不符" + # 只需校验 Cargo.toml 与 tag 对得上:其余地方都取自它,无从漂移 + $current = Get-CurrentVersion + if ($Tag) { + $version = Get-NormalizedVersion $Tag + if ($current -ne $version) { + Write-Host "::error::Cargo.toml 的版本号是 $current,与目标 $version 不符" + throw "版本号校验失败:请先运行 scripts/version.ps1 apply $version 并提交" } - throw "版本号校验失败:请先运行 scripts/version.ps1 apply $version 并提交" + Write-Host "版本号校验通过:Cargo.toml 为 $version" + } + else { + $version = $current } - - Write-Host "版本号校验通过:三处文件均为 $version" Export-Outputs $version } }