From 4891a69787d40e2a00b404a553a0181280e9a870 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:56:07 +0800 Subject: [PATCH 01/23] Windows portable: module skeletons and startup order Integration branch for the Windows portable build. Each module is a no-op placeholder that one work path fills in: - portable: data next to the exe, write check (path A) - dialog: native dialogs before Tauri starts (path B; MessageBoxW stub) - single: one running instance, prompt and switch (path B) - winreg: HKCU protocol, notification registration, autostart (path B) - cleanup: --uninstall-cleanup for the system uninstaller (path C) - webview2: refuse to start without the WebView2 runtime (path C) run() calls them in order before the builder; setup() claims the HKCU entries before the notification sink is chosen and starts the switch listener once AppState exists. TEMP: ci.yml also runs on pushes to this branch and portable/**; revert before merging into dev. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 ++- src-tauri/src/cleanup.rs | 7 +++++ src-tauri/src/dialog.rs | 56 +++++++++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 21 +++++++++++++++ src-tauri/src/portable.rs | 23 ++++++++++++++++ src-tauri/src/single.rs | 14 ++++++++++ src-tauri/src/webview2.rs | 7 +++++ src-tauri/src/winreg.rs | 18 +++++++++++++ 8 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/cleanup.rs create mode 100644 src-tauri/src/dialog.rs create mode 100644 src-tauri/src/portable.rs create mode 100644 src-tauri/src/single.rs create mode 100644 src-tauri/src/webview2.rs create mode 100644 src-tauri/src/winreg.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b2a5c1..b8fa761f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,9 @@ name: CI on: push: - branches: [main, dev] + # TEMP (Windows portable work): the integration branch and its per-path + # branches run the full CI on every push. Remove before merging into dev. + branches: [main, dev, feat/windows-portable, "portable/**"] pull_request: branches: [main, dev] diff --git a/src-tauri/src/cleanup.rs b/src-tauri/src/cleanup.rs new file mode 100644 index 00000000..5325f348 --- /dev/null +++ b/src-tauri/src/cleanup.rs @@ -0,0 +1,7 @@ +//! 系统卸载程序调用的无界面清理:`--uninstall-cleanup`(可带 `--delete-data`)。 +//! +//! 和应用内「卸载」走同一套步骤:还原接管过的客户端、删掉指向这个 exe 的注册项、 +//! 按要求删除数据目录。不起窗口,做完就退出,退出码说明结果。 + +/// 命令行里有 `--uninstall-cleanup` 就执行清理并退出进程;没有就什么都不做。 +pub fn dispatch() {} diff --git a/src-tauri/src/dialog.rs b/src-tauri/src/dialog.rs new file mode 100644 index 00000000..5643bfa4 --- /dev/null +++ b/src-tauri/src/dialog.rs @@ -0,0 +1,56 @@ +//! 应用起来之前的系统原生对话框。 +//! +//! 绿色版的文件夹写不进、缺 WebView2、已经有一个实例在运行,这几件事都发生在 +//! Tauri 起来之前,没有网页可用,只能用系统自己的对话框。只有 Windows 上有; +//! 其他平台上什么都不做。 + +/// 弹一个对话框,返回按下的按钮的下标;关掉对话框、或者这个平台不支持时是 `None`。 +/// +/// `buttons` 从左到右排,第一个是默认按钮。 +pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> Option { + #[cfg(windows)] + { + imp::show(title, instruction, content, buttons) + } + #[cfg(not(windows))] + { + let _ = (title, instruction, content, buttons); + None + } +} + +#[cfg(windows)] +mod imp { + use windows_sys::Win32::UI::WindowsAndMessaging::{ + IDCANCEL, IDOK, MB_ICONINFORMATION, MB_OK, MB_OKCANCEL, MessageBoxW, + }; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + /// 骨架:`MessageBoxW`,按钮文字由系统定、最多两个。B 路换成 `TaskDialogIndirect` + pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> Option { + let text = wide(&format!("{instruction}\n\n{content}")); + let caption = wide(title); + let kind = if buttons.len() >= 2 { + MB_OKCANCEL + } else { + MB_OK + }; + // SAFETY: 两个指针都指向以 0 结尾、在调用期间一直活着的 UTF-16 缓冲区 + let r = unsafe { + MessageBoxW( + std::ptr::null_mut(), + text.as_ptr(), + caption.as_ptr(), + kind | MB_ICONINFORMATION, + ) + }; + match r { + IDOK => Some(0), + IDCANCEL if buttons.len() >= 2 => Some(1), + _ => None, + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c43a07a..3e8b20fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod atomic_file; pub mod autostart; pub mod call; pub mod chatgpt; +pub mod cleanup; pub mod clients; /// 系统睡醒、改时钟、换时区时叫醒定在钟点上的几处,见模块头上 pub mod clock; @@ -27,6 +28,7 @@ pub mod dashboard; #[cfg(target_os = "linux")] pub mod desktop_entry; pub mod diagnostics; +pub mod dialog; #[cfg(target_os = "macos")] /// 从 DMG 里取出 `.app`,给更新器用。**只有 macOS 有** —— 它整个是 `hdiutil`, /// 而发布页上那个 DMG 本来就只给那个平台。Windows 上更新器直接装 NSIS 包。 @@ -46,18 +48,22 @@ pub mod memcheck; pub mod menubar; pub mod notices; pub mod plugins; +pub mod portable; pub mod prefs; /// 建只有自己能读的数据目录,见模块头上 pub mod private_dir; pub mod scan; pub mod settings; +pub mod single; pub mod supervisor; pub mod theme; pub mod uninstall; pub mod update; pub mod updater; pub mod upstreams; +pub mod webview2; pub mod window; +pub mod winreg; pub mod wire; pub mod zai; @@ -119,6 +125,16 @@ pub fn run() { // 在单实例插件把这个进程判成「第二个」之前放下激活令牌 #[cfg(target_os = "linux")] window::relaunch_token::stash(); + // 绿色版的数据目录要在一切之前定下来(见 `portable`):下面每一步都要读写它 + portable::prepare(); + // 下面几步都可能弹系统对话框,语言要先定 + i18n::set(i18n::effective(prefs::load(&data_dir()).language)); + // 系统卸载程序调用的无界面清理(见 `cleanup`):做完就退出,不起界面 + cleanup::dispatch(); + // 已经有一个实例在运行(见 `single`):转交、提示,或者等它退出后接着启动 + single::precheck(); + // 缺 WebView2 就开不了窗口(见 `webview2`) + webview2::ensure(); let builder = tauri::Builder::default(); // **单实例要第一个注册**,插件自己的文档如此要求:它得在别的插件把端口、 // socket、注册表项占上之前就判断出「已经有一个在跑」。 @@ -266,6 +282,9 @@ pub fn run() { i18n::set(i18n::effective(saved.language)); // 外观在窗口出现之前就设好,不然会先画一帧系统那一档的颜色 theme::init(&handle, saved.theme); + // 链接、开机自启、通知登记改成指向正在运行的这个 exe(见 `winreg`)。 + // 要在下面判断系统通知能不能用之前 + winreg::claim(&handle); // **找不到 core 也要把窗口开起来。**这里原来是 `?` —— // 而它把「找不到一个文件」变成了「应用打不开」。 let located = locate_core(&handle); @@ -374,6 +393,8 @@ pub fn run() { menubar: menubar_wake, menubar_now: Arc::new(tokio::sync::Notify::new()), }); + // 等另一个位置的程序请求切换(见 `single`) + single::listen(&handle); // **状态变化推给界面,不要让它来问。**「core 起来没、是不是 // 在重启、有没有进安全模式」一天变不了几次,而界面原来是每 diff --git a/src-tauri/src/portable.rs b/src-tauri/src/portable.rs new file mode 100644 index 00000000..54651924 --- /dev/null +++ b/src-tauri/src/portable.rs @@ -0,0 +1,23 @@ +//! 绿色版:Windows 上解压即用的 zip。 +//! +//! 和安装版的区别只在三处:数据放在 exe 旁边的 `data\`(安装版在 +//! `%APPDATA%\ThinkWatch`)、自更新换的是这个文件夹里的文件(不跑安装程序)、 +//! twcore 只认同目录那一份。怎么认出自己是绿色版见 [`is_portable`]。 + +use std::path::PathBuf; + +/// 启动最早的一步:绿色版把数据目录定在 exe 旁边的 `data\`,并确认能写。 +/// +/// 写不进时弹系统对话框,然后退出进程。不是绿色版什么都不做。 +/// **必须在任何线程起来之前调用**:它要改进程的环境变量(`THINKWATCH_HOME`)。 +pub fn prepare() {} + +/// 这一份是不是绿色版。 +pub fn is_portable() -> bool { + false +} + +/// 绿色版的 WebView2 数据目录(`data\webview`)。不是绿色版时是 `None`,窗口用框架的默认位置。 +pub fn webview_data_dir() -> Option { + None +} diff --git a/src-tauri/src/single.rs b/src-tauri/src/single.rs new file mode 100644 index 00000000..798a99be --- /dev/null +++ b/src-tauri/src/single.rs @@ -0,0 +1,14 @@ +//! 同一时间只运行一个实例,谁运行就听谁的。 +//! +//! 安装版和绿色版用的是同一把锁(单实例插件按应用标识起名),所以它们也互斥。 +//! 插件只会把第二个进程的参数转交给第一个、然后让第二个退出;这里补上插件 +//! 不做的那部分:开的是另一个位置的程序时说明情况,并且可以切换过去。 + +/// 程序真正启动之前调用。已经有一个实例在运行时,按情况静默转交、提示后退出, +/// 或者等运行中的那个退出后接着启动。没有在运行的实例、或者不是 Windows,什么都不做。 +pub fn precheck() {} + +/// 运行中的实例这一侧:等「切换」的请求,收到后等进行中的请求结束、停掉 core、退出。 +pub fn listen(app: &tauri::AppHandle) { + let _ = app; +} diff --git a/src-tauri/src/webview2.rs b/src-tauri/src/webview2.rs new file mode 100644 index 00000000..4863aae0 --- /dev/null +++ b/src-tauri/src/webview2.rs @@ -0,0 +1,7 @@ +//! 启动之前确认 WebView2 运行时在。 +//! +//! 没有它就开不了任何窗口。安装程序会替用户装上,绿色版不会;系统里被卸掉的 +//! 情况两边都可能有。缺的时候用系统对话框说明,并给出微软的下载页。 + +/// 缺 WebView2 时弹系统对话框并退出进程。不缺、或者不是 Windows,什么都不做。 +pub fn ensure() {} diff --git a/src-tauri/src/winreg.rs b/src-tauri/src/winreg.rs new file mode 100644 index 00000000..7b9a230a --- /dev/null +++ b/src-tauri/src/winreg.rs @@ -0,0 +1,18 @@ +//! 当前用户名下(HKCU)的几项注册:`thinkwatch://` 链接、系统通知的应用登记、开机自启。 +//! +//! **谁运行就听谁的。**安装版和绿色版同一时间只会有一个在运行,所以每次启动都把 +//! 这几项改成指向正在运行的这个 exe;清理时只删指向自己的那几项。 + +use std::path::Path; + +/// 启动时调用:链接、开机自启(开着的话)、通知登记都改成指向正在运行的这个 exe。 +/// 不是 Windows 什么都不做。 +pub fn claim(app: &tauri::AppHandle) { + let _ = app; +} + +/// 清理:删掉指向 `exe` 的注册项(链接、开机自启、这一份的通知登记),每一项一行结果。 +pub fn release_all(exe: &Path) -> Vec { + let _ = exe; + Vec::new() +} From 4645c8eaf6346df352433dbd812db290fff20238 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:23:20 +0800 Subject: [PATCH 02/23] Windows portable: data beside the exe, self-update from a zip, new asset names The portable copy is a release build on Windows with no uninstall.exe beside it (update::windows_kind). It keeps its data in \data, checked for write access at startup (a native dialog and exit when the folder is not writable), runs only the twcore.exe next to it, and gives WebView2 its own data\webview directory at all three window sites. Self-update asks the updater for the windows--portable key, checks that the zip holds exactly thinkwatch-lite.exe and twcore.exe at its root, waits for requests in flight, stops core, renames both exes to .old, writes the new ones and restarts. Any failure renames them back and brings the gateway back up; the .old files are removed on the next start. The rename/write/rollback step does not need an AppHandle and is tested on every platform, plus on Windows against two running executables. Release assets are renamed to ThinkWatch-Lite---... with darwin/windows/linux, and each Windows job also publishes a portable zip (sha256 + signature) built from the exes inside the installer and checked for shape, PE architecture, versions and Visual C++ runtime imports. latest.json keeps every existing key and adds windows-x86_64-portable and windows-aarch64-portable; manifest.py now pins which file suffix each key may point to. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 169 +++++++-- scripts/manifest.py | 33 +- scripts/release_notes.py | 29 +- scripts/release_notes_test.py | 65 +++- src-tauri/Cargo.lock | 2 + src-tauri/Cargo.toml | 4 + src-tauri/dmg/settings.py | 2 +- src-tauri/src/connection/mod.rs | 11 +- src-tauri/src/gateway.rs | 16 +- src-tauri/src/portable.rs | 627 +++++++++++++++++++++++++++++++- src-tauri/src/update.rs | 95 ++++- src-tauri/src/updater.rs | 85 ++++- src-tauri/src/window.rs | 6 + src/Update.i18n.ts | 1 + src/updateFlow.test.ts | 4 + src/updateFlow.ts | 11 +- 16 files changed, 1068 insertions(+), 92 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa1ac621..ae662435 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,19 @@ # 发一版桌面端。 # # **每一种安装方式只发一个文件**,外加它的 sha256,和一份所有平台共用的 -# latest.json: +# latest.json。名字一律是 `ThinkWatch-Lite-<版本>-<系统>-<架构>…`,系统名用 +# darwin / windows / linux: # -# macOS ThinkWatch-Lite-<版本>-arm64.dmg -# Windows x64 ThinkWatch-Lite-<版本>-x64-setup.exe -# Windows arm64 ThinkWatch-Lite-<版本>-arm64-setup.exe -# Linux x86_64 ThinkWatch-Lite-<版本>-x86_64.AppImage -# Linux aarch64 ThinkWatch-Lite-<版本>-aarch64.AppImage +# macOS ThinkWatch-Lite-<版本>-darwin-arm64.dmg +# Windows x64 ThinkWatch-Lite-<版本>-windows-x64-setup.exe +# Windows x64 绿色版 ThinkWatch-Lite-<版本>-windows-x64-portable.zip +# Windows arm64 ThinkWatch-Lite-<版本>-windows-arm64-setup.exe +# Windows arm64 绿色版 ThinkWatch-Lite-<版本>-windows-arm64-portable.zip +# Linux x86_64 ThinkWatch-Lite-<版本>-linux-x86_64.AppImage +# Linux aarch64 ThinkWatch-Lite-<版本>-linux-aarch64.AppImage +# +# 绿色版的 zip 里只有根目录下的 `thinkwatch-lite.exe` 和 `twcore.exe`,就是安装 +# 程序里装的那两个。 # # Linux 另外挂一份 `install.sh`(`scripts/install.sh`):一行命令读 latest.json、 # 下载本架构的 AppImage、核对 sha256、放进 `~/Applications`。 @@ -15,7 +21,11 @@ # 网页上下载的是它,Homebrew 的 cask / winget 的清单吃的是它,已经装上的 # 应用自己更新下的也是它:latest.json 指向它,签名签的也是它(应用怎么从 # DMG 更新自己,见 `src-tauri/src/dmg.rs`;Windows 上更新器直接跑新的安装 -# 程序;Linux 上 AppImage 原地换掉自己)。 +# 程序,绿色版换掉自己文件夹里的两个 exe(`src-tauri/src/portable.rs`);Linux +# 上 AppImage 原地换掉自己)。 +# +# **已经装着的旧版本不受改名影响**:它们只读 latest.json,按键找地址,键名一个 +# 没改(见 `scripts/manifest.py`)。 # # **所有平台一起发,或者都不发。**各自构建、拆开看过,最后一个 job 才把 # 它们一起挂上去 —— 只发出一部分的话,latest.json 要么缺平台,要么指向 @@ -185,7 +195,7 @@ jobs: "$RUNNER_TEMP/dmgbuild/bin/dmgbuild" -s src-tauri/dmg/settings.py \ -D app="src-tauri/target/aarch64-apple-darwin/release/bundle/macos/ThinkWatch Lite.app" \ -D dir=src-tauri/dmg -D icon=src-tauri/icons/icon.icns \ - "ThinkWatch Lite" "dist/ThinkWatch-Lite-$VERSION-arm64.dmg" + "ThinkWatch Lite" "dist/ThinkWatch-Lite-$VERSION-darwin-arm64.dmg" # 签的是 DMG 本身:应用自己更新时下的就是它,验签验的是下载下来的 # 原始字节。**私钥没传进来这一步就失败** —— 那正是要让它失败的地方, @@ -196,7 +206,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - DMG="dist/ThinkWatch-Lite-$VERSION-arm64.dmg" + DMG="dist/ThinkWatch-Lite-$VERSION-darwin-arm64.dmg" ( cd dist && shasum -a 256 "$(basename "$DMG")" > "$(basename "$DMG").sha256" ) cat "$DMG.sha256" @@ -210,7 +220,7 @@ jobs: set -euo pipefail MNT=$(mktemp -d) hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$MNT" \ - "dist/ThinkWatch-Lite-$VERSION-arm64.dmg" + "dist/ThinkWatch-Lite-$VERSION-darwin-arm64.dmg" APP="$MNT/ThinkWatch Lite.app" codesign --verify --deep --strict "$APP" test -x "$APP/Contents/MacOS/thinkwatch-lite" @@ -231,9 +241,9 @@ jobs: with: name: macos-arm64 path: | - dist/ThinkWatch-Lite-*-arm64.dmg - dist/ThinkWatch-Lite-*-arm64.dmg.sha256 - dist/ThinkWatch-Lite-*-arm64.dmg.sig + dist/ThinkWatch-Lite-*-darwin-arm64.dmg + dist/ThinkWatch-Lite-*-darwin-arm64.dmg.sha256 + dist/ThinkWatch-Lite-*-darwin-arm64.dmg.sig if-no-files-found: error windows: @@ -315,7 +325,7 @@ jobs: # 名字里有空格,而 GitHub 会把资源名里的空格换成点,latest.json # 里的地址就对不上了 New-Item -ItemType Directory -Force dist | Out-Null - $exe = "dist\ThinkWatch-Lite-$env:VERSION-${{ matrix.arch }}-setup.exe" + $exe = "dist\ThinkWatch-Lite-$env:VERSION-windows-${{ matrix.arch }}-setup.exe" Copy-Item $built[0].FullName $exe $x = "$env:RUNNER_TEMP\inside" @@ -341,19 +351,111 @@ jobs: Write-Host "包里的网关:$(& $core.FullName --version)" if ($LASTEXITCODE -ne 0) { throw "包里的网关跑不起来" } - # 签的是安装程序本身:更新器下载的就是它,验签验的是原始字节。 - # **私钥没传进来这一步就失败** —— 理由和 macOS 那边一样。 + # 绿色版:解压即用,不要管理员权限,数据在 exe 旁边的 `data\`(见 + # `src-tauri/src/portable.rs`)。**zip 里就是安装程序里的那两个 exe**,上一步 + # 用 7-Zip 解出来、已经核对过的那一份,放在 zip 的根目录,别的什么都不放 —— + # 应用自更新时只认这个形状(`portable::unpack`)。 + # + # 用 .NET 的 ZipArchive 一个一个加,条目名就是文件名:`Compress-Archive` 之类 + # 按目录打包的写法会把目录名、或者别的文件一起带进去 + - name: The portable package + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Add-Type -AssemblyName System.IO.Compression.FileSystem + $x = "$env:RUNNER_TEMP\inside" + # .NET 按进程的当前目录解析相对路径,那不一定是 PowerShell 的当前位置 + $zip = Join-Path $PWD "dist\ThinkWatch-Lite-$env:VERSION-windows-${{ matrix.arch }}-portable.zip" + $archive = [IO.Compression.ZipFile]::Open($zip, [IO.Compression.ZipArchiveMode]::Create) + try { + foreach ($name in @("thinkwatch-lite.exe", "twcore.exe")) { + $f = Get-ChildItem -Recurse "$x\$name" | Select-Object -First 1 + if (-not $f) { throw "安装程序里没有 $name" } + [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $f.FullName, $name, [IO.Compression.CompressionLevel]::Optimal) + } + } finally { + $archive.Dispose() + } + Get-Item $zip | Format-Table Name, Length + + # **拆开看一眼再发**,看的是用户会下载的那个 zip:恰好两个条目、都在根目录, + # 架构、版本都对,网关跑得起来。 + # + # **不要用户另装 VC 运行库。**绿色版没有安装程序,缺了什么不会有人替它装, + # 所以两个 exe 的导入表里都不许有 Windows 不自带的那几个 DLL。这台 runner 上 + # 装着运行库,exe 照样跑得起来 —— 只有导入表看得出来。dumpbin 取的是这台 + # 机器上那套 MSVC 里的,什么架构的 exe 它都读得了(照 core 的 release.yml) + - name: Look inside the portable package + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + Add-Type -AssemblyName System.IO.Compression.FileSystem + # .NET 按进程的当前目录解析相对路径,那不一定是 PowerShell 的当前位置 + $zip = Join-Path $PWD "dist\ThinkWatch-Lite-$env:VERSION-windows-${{ matrix.arch }}-portable.zip" + $read = [IO.Compression.ZipFile]::OpenRead($zip) + try { $names = @($read.Entries | ForEach-Object { $_.FullName } | Sort-Object) } finally { $read.Dispose() } + if (($names -join ",") -ne "thinkwatch-lite.exe,twcore.exe") { + throw "zip 里应当恰好是 thinkwatch-lite.exe 和 twcore.exe,实际是:$($names -join ', ')" + } + $x = "$env:RUNNER_TEMP\portable" + Expand-Archive -Path $zip -DestinationPath $x -Force + $app = Get-Item "$x\thinkwatch-lite.exe" + $core = Get-Item "$x\twcore.exe" + + $v = $app.VersionInfo.ProductVersion + if (-not $v.StartsWith($env:VERSION)) { throw "exe 里的版本是 $v,而这一版是 $env:VERSION" } + + $want = @{ "x64" = 0x8664; "arm64" = 0xAA64 }["${{ matrix.arch }}"] + foreach ($f in @($app.FullName, $core.FullName)) { + $b = [IO.File]::ReadAllBytes($f) + $pe = [BitConverter]::ToInt32($b, 0x3C) + $m = [BitConverter]::ToUInt16($b, $pe + 4) + if ($m -ne $want) { throw ("{0} 的架构是 0x{1:X4},要的是 0x{2:X4}" -f $f, $m, $want) } + } + + # 网关是 Cargo.toml 钉住的那一版 core(fetch-core.sh 按同一个 tag 取的) + $tag = (Select-String -Path src-tauri\Cargo.toml -Pattern '^tw-api = .*tag = "v([^"]+)"').Matches[0].Groups[1].Value + $said = (& $core.FullName --version | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { throw "zip 里的网关跑不起来" } + if ($said -ne "twcore $tag") { throw "zip 里的网关说自己是「$said」,要的是 twcore $tag" } + Write-Host "zip 里的网关:$said" + + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + # 挑和这台机器同架构的那一份,没有就用 x64 的(arm64 的 Windows 能跑它) + $hostDir = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "Hostarm64" } else { "Hostx64" } + $dumpbin = (@(& $vswhere -latest -products * -find "VC\Tools\MSVC\**\bin\$hostDir\*\dumpbin.exe") + + @(& $vswhere -latest -products * -find "VC\Tools\MSVC\**\bin\Hostx64\x64\dumpbin.exe")) | + Where-Object { $_ } | Select-Object -First 1 + if (-not $dumpbin) { throw "dumpbin.exe not found" } + foreach ($f in @($app.FullName, $core.FullName)) { + $deps = & $dumpbin /nologo /dependents $f | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -match '\.dll$' } + if ($LASTEXITCODE -ne 0 -or -not $deps) { throw "读不出 $f 的导入表" } + Write-Host "$($f | Split-Path -Leaf): $($deps -join ', ')" + $redist = @($deps | Where-Object { $_ -match '^(vcruntime|msvcp|concrt|vccorlib|vcomp)\d' }) + if ($redist.Count -gt 0) { + throw "$f 要 Visual C++ 运行库($($redist -join ', ')):绿色版没有安装程序替它装" + } + } + + # 签的是安装程序和 zip 本身:更新器下载的就是它们(安装版下安装程序、绿色版 + # 下 zip),验签验的是原始字节。**私钥没传进来这一步就失败** —— 理由和 macOS + # 那边一样。 - name: Package env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail - EXE="dist/ThinkWatch-Lite-$VERSION-${{ matrix.arch }}-setup.exe" - # Git Bash 带的是 sha256sum,输出和 shasum 是同一种两列格式 - ( cd dist && sha256sum "$(basename "$EXE")" > "$(basename "$EXE").sha256" ) - cat "$EXE.sha256" - pnpm tauri signer sign "$EXE" + for F in "dist/ThinkWatch-Lite-$VERSION-windows-${{ matrix.arch }}-setup.exe" \ + "dist/ThinkWatch-Lite-$VERSION-windows-${{ matrix.arch }}-portable.zip"; do + # Git Bash 带的是 sha256sum,输出和 shasum 是同一种两列格式 + ( cd dist && sha256sum "$(basename "$F")" > "$(basename "$F").sha256" ) + cat "$F.sha256" + pnpm tauri signer sign "$F" + done - uses: actions/upload-artifact@v4 with: @@ -362,6 +464,9 @@ jobs: dist/ThinkWatch-Lite-*-setup.exe dist/ThinkWatch-Lite-*-setup.exe.sha256 dist/ThinkWatch-Lite-*-setup.exe.sig + dist/ThinkWatch-Lite-*-portable.zip + dist/ThinkWatch-Lite-*-portable.zip.sha256 + dist/ThinkWatch-Lite-*-portable.zip.sig if-no-files-found: error linux: @@ -443,8 +548,8 @@ jobs: IMAGES=("$B"/appimage/*.AppImage) [ "${#IMAGES[@]}" -eq 1 ] || { echo "要一个 AppImage,打出来的是 ${#IMAGES[@]} 个" >&2; exit 1; } mkdir -p dist - cp "${IMAGES[0]}" "dist/ThinkWatch-Lite-$VERSION-${{ matrix.arch }}.AppImage" - echo "APPIMAGE=dist/ThinkWatch-Lite-$VERSION-${{ matrix.arch }}.AppImage" >> "$GITHUB_ENV" + cp "${IMAGES[0]}" "dist/ThinkWatch-Lite-$VERSION-linux-${{ matrix.arch }}.AppImage" + echo "APPIMAGE=dist/ThinkWatch-Lite-$VERSION-linux-${{ matrix.arch }}.AppImage" >> "$GITHUB_ENV" # **拆开看一眼再发。**和另外两个平台同一个理由:缺了网关、网关是别的 # 架构、或者更新器认不出这是哪种包,从文件列表上都看不出来。 @@ -606,11 +711,13 @@ jobs: run: | set -euo pipefail python3 scripts/manifest.py "$VERSION" notes.txt \ - "darwin-aarch64=dist/ThinkWatch-Lite-$VERSION-arm64.dmg" \ - "windows-x86_64=dist/ThinkWatch-Lite-$VERSION-x64-setup.exe" \ - "windows-aarch64=dist/ThinkWatch-Lite-$VERSION-arm64-setup.exe" \ - "linux-x86_64-appimage=dist/ThinkWatch-Lite-$VERSION-x86_64.AppImage" \ - "linux-aarch64-appimage=dist/ThinkWatch-Lite-$VERSION-aarch64.AppImage" + "darwin-aarch64=dist/ThinkWatch-Lite-$VERSION-darwin-arm64.dmg" \ + "windows-x86_64=dist/ThinkWatch-Lite-$VERSION-windows-x64-setup.exe" \ + "windows-aarch64=dist/ThinkWatch-Lite-$VERSION-windows-arm64-setup.exe" \ + "windows-x86_64-portable=dist/ThinkWatch-Lite-$VERSION-windows-x64-portable.zip" \ + "windows-aarch64-portable=dist/ThinkWatch-Lite-$VERSION-windows-arm64-portable.zip" \ + "linux-x86_64-appimage=dist/ThinkWatch-Lite-$VERSION-linux-x86_64.AppImage" \ + "linux-aarch64-appimage=dist/ThinkWatch-Lite-$VERSION-linux-aarch64.AppImage" # **正文只在建 Release 时写一次。**Release 已经在了 —— 重跑这个 job(比如 # 挂文件挂到一半断了),或者有人先手工建好了 —— 就不动它的正文:那可能是 @@ -642,10 +749,12 @@ jobs: # 不知道该下哪个的文件。`install.sh` 挂上去是为了一个固定的地址 # (`releases/latest/download/install.sh`),它读的是同一版的 latest.json files: | - dist/ThinkWatch-Lite-*-arm64.dmg - dist/ThinkWatch-Lite-*-arm64.dmg.sha256 + dist/ThinkWatch-Lite-*-darwin-arm64.dmg + dist/ThinkWatch-Lite-*-darwin-arm64.dmg.sha256 dist/ThinkWatch-Lite-*-setup.exe dist/ThinkWatch-Lite-*-setup.exe.sha256 + dist/ThinkWatch-Lite-*-portable.zip + dist/ThinkWatch-Lite-*-portable.zip.sha256 dist/ThinkWatch-Lite-*.AppImage dist/ThinkWatch-Lite-*.AppImage.sha256 dist/latest.json diff --git a/scripts/manifest.py b/scripts/manifest.py index d28f0911..a69abc6d 100755 --- a/scripts/manifest.py +++ b/scripts/manifest.py @@ -3,7 +3,8 @@ 应用去问「有没有新版本」时读的就是这一份清单,下载的是它指向的那个文件 —— 和网页上给人下载的是同一个:macOS 上是 DMG(应用怎么从 DMG 更新自己, -见 `src-tauri/src/dmg.rs`),Windows 上是 NSIS 安装程序,更新器直接跑它; +见 `src-tauri/src/dmg.rs`),Windows 上安装版是 NSIS 安装程序,更新器直接跑它, +绿色版是 zip,应用自己换掉文件夹里的两个 exe(见 `src-tauri/src/portable.rs`); Linux 上只发 AppImage,它原地换掉自己(见 `src-tauri/src/update.rs`); `scripts/install.sh` 也从这一份里找本架构的 AppImage。 @@ -33,12 +34,22 @@ # 到 `<系统>-<架构>`(插件的 `get_urls`);安装方式来自打包时写进二进制的标记。 # **只给带 `-appimage` 的那个。**AppImage 第一下就找到它;不带后缀的键是所有 # 构建的退路,给了它,一个自己打出来的 deb 或 rpm 也会被递上 AppImage 的字节。 +# +# Windows 的绿色版**自己点名要 `-portable` 那个键**(`portable::updater_target`): +# 它的 exe 就是安装程序里的那一个,打包标记是 NSIS,按标记找会找到安装程序。安装版 +# 照旧找 `windows-<架构>-nsis`、再退到 `windows-<架构>` —— 已经装着的旧版本也是这样 +# 找的,新加的两个键它们看不见,文件改名也不影响它们:地址只从这一份清单里读。 +# +# 每个键指向的文件**以什么结尾**也定死:键和文件对错了(x64 的键指着 arm64 的包、 +# 绿色版的键指着安装程序),装下去就是一个起不来的应用。 PLATFORMS = { - "darwin-aarch64", - "windows-x86_64", - "windows-aarch64", - "linux-x86_64-appimage", - "linux-aarch64-appimage", + "darwin-aarch64": "-darwin-arm64.dmg", + "windows-x86_64": "-windows-x64-setup.exe", + "windows-aarch64": "-windows-arm64-setup.exe", + "windows-x86_64-portable": "-windows-x64-portable.zip", + "windows-aarch64-portable": "-windows-arm64-portable.zip", + "linux-x86_64-appimage": "-linux-x86_64.AppImage", + "linux-aarch64-appimage": "-linux-aarch64.AppImage", } @@ -93,13 +104,13 @@ def main() -> None: sys.exit(f"不认识的平台或写法:{pair}(要的是 <平台>=<文件>,平台是 {sorted(PLATFORMS)} 之一)") if platform in platforms: sys.exit(f"{platform} 给了两次") - # AppImage 会被原地换成这个文件:换成别的东西就再也起不来了 - if platform.endswith("-appimage") and not file.endswith(".AppImage"): - sys.exit(f"{platform} 要的是 .AppImage,给的是 {file}") + # AppImage、绿色版的 exe 会被原地换成这个文件里的东西:换成别的就再也起不来了 + if not file.endswith(PLATFORMS[platform]): + sys.exit(f"{platform} 要的是 …{PLATFORMS[platform]},给的是 {file}") platforms[platform] = entry(pathlib.Path(file), version, want) # **少一个平台就不发。**缺掉的那个平台上,已经装好的每一份都会停在旧版本, # 而发布页上看起来一切正常 - missing = PLATFORMS - platforms.keys() + missing = PLATFORMS.keys() - platforms.keys() if missing: sys.exit(f"这一版缺了这些平台:{sorted(missing)}") @@ -118,7 +129,7 @@ def main() -> None: out = pathlib.Path(pairs[0].partition("=")[2]).parent / "latest.json" out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") for p, e in sorted(platforms.items()): - print(f"{p:23} {e['url'].rsplit('/', 1)[1]}") + print(f"{p:25} {e['url'].rsplit('/', 1)[1]}") print(f"签名密钥 {want.hex()},和应用里的公钥是同一把") diff --git a/scripts/release_notes.py b/scripts/release_notes.py index 4efd5cc3..0a38ab90 100755 --- a/scripts/release_notes.py +++ b/scripts/release_notes.py @@ -6,8 +6,9 @@ 正文是英文的,依次是: 1. `release-notes/<版本>.md`:这一版的说明。**可以没有**,没有就从下载表开始。 -2. 下载表:每个平台一行,文件名和 release.yml 挂上去的一字不差;再给 Homebrew - 和 Linux 安装脚本的命令。 +2. 下载表:每个文件一行(Windows 每个架构两行:安装程序、绿色版 zip),文件名和 + release.yml 挂上去的一字不差;再给 Homebrew 和 Linux 安装脚本的命令、绿色版的 + 用法。 3. 怎样用 `.sha256` 核对下载的文件。 4. 第二个参数的内容:GitHub 按上一版以来合并的 PR 生成的「What's Changed」,由 调用方用 `gh api repos/<仓库>/releases/generate-notes` 取来。拆成参数传进来, @@ -34,11 +35,13 @@ # 每个平台发的那一个文件。**和 release.yml 起的名字一字不差** —— # release_notes_test.py 拿 release.yml 核对这张表,对不上的话发布页上的链接就是 404。 DOWNLOADS = [ - ("macOS, Apple silicon", "ThinkWatch-Lite-{v}-arm64.dmg"), - ("Windows, x64", "ThinkWatch-Lite-{v}-x64-setup.exe"), - ("Windows, ARM64", "ThinkWatch-Lite-{v}-arm64-setup.exe"), - ("Linux, x86_64", "ThinkWatch-Lite-{v}-x86_64.AppImage"), - ("Linux, aarch64", "ThinkWatch-Lite-{v}-aarch64.AppImage"), + ("macOS, Apple silicon", "ThinkWatch-Lite-{v}-darwin-arm64.dmg"), + ("Windows, x64, installer", "ThinkWatch-Lite-{v}-windows-x64-setup.exe"), + ("Windows, x64, portable", "ThinkWatch-Lite-{v}-windows-x64-portable.zip"), + ("Windows, ARM64, installer", "ThinkWatch-Lite-{v}-windows-arm64-setup.exe"), + ("Windows, ARM64, portable", "ThinkWatch-Lite-{v}-windows-arm64-portable.zip"), + ("Linux, x86_64", "ThinkWatch-Lite-{v}-linux-x86_64.AppImage"), + ("Linux, aarch64", "ThinkWatch-Lite-{v}-linux-aarch64.AppImage"), ] BREW = "brew install --cask thinkwatchproject/tap/thinkwatch-lite" @@ -81,8 +84,14 @@ def render(version: str, changes: str, summary_text: str | None) -> str: raise NotesError(f"版本号的写法不对:{version}(要的是 2026.9.16 这样的)") base = f"https://github.com/{REPO}/releases/download/v{version}" files = [(platform, name.format(v=version)) for platform, name in DOWNLOADS] - dmg, x64 = files[0][1], files[1][1] - appimage = files[3][1] + + def named(suffix: str) -> str: + """下载表里以这一段结尾的那个文件。按名字找,表里加一行、换个顺序都不跟着错""" + (name,) = [n for _, n in files if n.endswith(suffix)] + return name + + dmg, x64 = named("-darwin-arm64.dmg"), named("-windows-x64-setup.exe") + appimage = named("-linux-x86_64.AppImage") parts = [] if summary_text: @@ -98,6 +107,8 @@ def render(version: str, changes: str, summary_text: str | None) -> str: Each file is published with a `.sha256` file beside it. `latest.json` is the manifest for in-app updates, and `install.sh` is the Linux install script. +On Windows, the portable ZIP runs without installation or administrator rights: extract it to any writable folder and start `thinkwatch-lite.exe`. Its settings stay in the `data` folder beside it, separate from an installed copy, and it updates itself in place. + On macOS, Homebrew installs the same disk image: ```sh diff --git a/scripts/release_notes_test.py b/scripts/release_notes_test.py index d3b9a760..e1a410c9 100755 --- a/scripts/release_notes_test.py +++ b/scripts/release_notes_test.py @@ -19,6 +19,7 @@ # 不在仓库里留 __pycache__ sys.dont_write_bytecode = True +import manifest # noqa: E402 import release_notes as rn # noqa: E402 ROOT = HERE.parent @@ -70,11 +71,13 @@ def test_every_platform_links_its_file_in_this_release(self): self.assertEqual( files, [ - "ThinkWatch-Lite-2026.9.16-arm64.dmg", - "ThinkWatch-Lite-2026.9.16-x64-setup.exe", - "ThinkWatch-Lite-2026.9.16-arm64-setup.exe", - "ThinkWatch-Lite-2026.9.16-x86_64.AppImage", - "ThinkWatch-Lite-2026.9.16-aarch64.AppImage", + "ThinkWatch-Lite-2026.9.16-darwin-arm64.dmg", + "ThinkWatch-Lite-2026.9.16-windows-x64-setup.exe", + "ThinkWatch-Lite-2026.9.16-windows-x64-portable.zip", + "ThinkWatch-Lite-2026.9.16-windows-arm64-setup.exe", + "ThinkWatch-Lite-2026.9.16-windows-arm64-portable.zip", + "ThinkWatch-Lite-2026.9.16-linux-x86_64.AppImage", + "ThinkWatch-Lite-2026.9.16-linux-aarch64.AppImage", ], ) @@ -82,9 +85,19 @@ def test_the_files_are_the_ones_the_workflow_publishes(self): # 写 latest.json 的那一步逐个点名每个平台的文件:`平台=dist/<文件>` published = set(re.findall(r"=dist/(ThinkWatch-Lite-\$VERSION-[A-Za-z0-9_.-]+)\"", WORKFLOW)) listed = {name.format(v="$VERSION") for _, name in rn.DOWNLOADS} - self.assertEqual(len(published), 5, published) + self.assertEqual(len(published), 7, published) self.assertEqual(listed, published) + def test_every_name_says_its_system_and_architecture(self): + # `ThinkWatch-Lite-<版本>-<系统>-<架构>…`,系统名是 darwin / windows / linux + for _, name in rn.DOWNLOADS: + self.assertRegex(name, r"^ThinkWatch-Lite-\{v\}-(darwin|windows|linux)-(arm64|x64|x86_64|aarch64)[-.]") + + def test_the_portable_zip_is_described(self): + body = rn.render("2026.9.16", CHANGES, None) + self.assertIn("portable ZIP runs without installation or administrator rights", body) + self.assertIn("`data` folder beside it", body) + def test_the_install_commands(self): body = rn.render("2026.9.16", CHANGES, None) self.assertIn("\nbrew install --cask thinkwatchproject/tap/thinkwatch-lite\n", body) @@ -97,11 +110,11 @@ def test_the_install_commands(self): def test_the_checksum_commands_name_real_files(self): body = rn.render("2026.9.16", CHANGES, None) - self.assertIn("shasum -a 256 -c ThinkWatch-Lite-2026.9.16-arm64.dmg.sha256\n", body) - self.assertIn("sha256sum -c ThinkWatch-Lite-2026.9.16-x86_64.AppImage.sha256\n", body) + self.assertIn("shasum -a 256 -c ThinkWatch-Lite-2026.9.16-darwin-arm64.dmg.sha256\n", body) + self.assertIn("sha256sum -c ThinkWatch-Lite-2026.9.16-linux-x86_64.AppImage.sha256\n", body) self.assertIn( - "(Get-FileHash .\\ThinkWatch-Lite-2026.9.16-x64-setup.exe).Hash -eq " - "(Get-Content .\\ThinkWatch-Lite-2026.9.16-x64-setup.exe.sha256).Split()[0]\n", + "(Get-FileHash .\\ThinkWatch-Lite-2026.9.16-windows-x64-setup.exe).Hash -eq " + "(Get-Content .\\ThinkWatch-Lite-2026.9.16-windows-x64-setup.exe.sha256).Split()[0]\n", body, ) @@ -114,6 +127,36 @@ def test_a_malformed_version_is_refused(self): rn.render(bad, CHANGES, None) +class Manifest(unittest.TestCase): + """latest.json 的键和文件(manifest.py),对着 release.yml 写清单的那一步核对。""" + + def pairs(self) -> dict[str, str]: + found = re.findall(r'"([a-z0-9_-]+)=dist/(ThinkWatch-Lite-\$VERSION-[A-Za-z0-9_.-]+)"', WORKFLOW) + return dict(found) + + def test_every_platform_gets_the_file_it_expects(self): + pairs = self.pairs() + self.assertEqual(set(pairs), set(manifest.PLATFORMS)) + for platform, file in pairs.items(): + with self.subTest(platform): + self.assertTrue(file.endswith(manifest.PLATFORMS[platform]), file) + + def test_the_keys_installed_copies_already_read_are_kept(self): + # 已经装着的旧版本只认这几个键,改了名它们就再也问不到新版本 + for key in [ + "darwin-aarch64", + "windows-x86_64", + "windows-aarch64", + "linux-x86_64-appimage", + "linux-aarch64-appimage", + ]: + self.assertIn(key, manifest.PLATFORMS) + + def test_the_portable_build_has_its_own_keys(self): + self.assertEqual(manifest.PLATFORMS["windows-x86_64-portable"], "-windows-x64-portable.zip") + self.assertEqual(manifest.PLATFORMS["windows-aarch64-portable"], "-windows-arm64-portable.zip") + + class Summary(unittest.TestCase): def test_absent_is_none(self): self.assertIsNone(rn.summary("2026.9.16", notes_dir(self, {}))) @@ -170,7 +213,7 @@ def test_writes_the_body_to_stdout(self): self.assertEqual(r.returncode, 0, r.stderr) out = r.stdout self.assertTrue(out.startswith("## Downloads\n")) - self.assertIn("ThinkWatch-Lite-2099.1.1-arm64.dmg", out) + self.assertIn("ThinkWatch-Lite-2099.1.1-darwin-arm64.dmg", out) def test_a_bad_version_fails(self): r = self.run_script("v2099.1.1") diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c61d36c8..13b6c0c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4804,6 +4804,7 @@ dependencies = [ "windows", "windows-sys 0.61.2", "zbus", + "zip", ] [[package]] @@ -6640,6 +6641,7 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.2", "memchr", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d40570f..a760211a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -101,6 +101,10 @@ semver = "1" # Homebrew 那一档的「复制命令」。**在 Rust 这边写剪贴板**,理由见 # `update_copy_command`。 tauri-plugin-clipboard-manager = "2" +# Windows 绿色版的更新包是 zip(见 `portable::unpack`)。版本是更新器插件在 Windows +# 上本来就拉进来的那一份,只多开 Deflate(flate2 也是锁里本来就有的那一份)。不分 +# 平台:拆包和替换的测试在每个平台上都跑 +zip = { version = "4.6.1", default-features = false, features = ["deflate-flate2-zlib-rs"] } [profile.release] lto = "thin" diff --git a/src-tauri/dmg/settings.py b/src-tauri/dmg/settings.py index fdfff6f0..ea752025 100644 --- a/src-tauri/dmg/settings.py +++ b/src-tauri/dmg/settings.py @@ -3,7 +3,7 @@ # 用法(release.yml 里就是这么调的): # dmgbuild -s src-tauri/dmg/settings.py \ # -D app="…/ThinkWatch Lite.app" -D dir=src-tauri/dmg -D icon=src-tauri/icons/icon.icns \ -# "ThinkWatch Lite" ThinkWatch-Lite-<版本>-arm64.dmg +# "ThinkWatch Lite" ThinkWatch-Lite-<版本>-darwin-arm64.dmg # # **为什么不让 Tauri 打这个映像。**Tauri 摆图标、贴背景靠一段驱动 Finder 的 # AppleScript,而在 CI 上它会自己跳过那一段(`CI=true` 时加 `--skip-jenkins`) diff --git a/src-tauri/src/connection/mod.rs b/src-tauri/src/connection/mod.rs index 265cd5dc..261ac3d8 100644 --- a/src-tauri/src/connection/mod.rs +++ b/src-tauri/src/connection/mod.rs @@ -821,7 +821,7 @@ pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<() launch::Why::Option => "option", launch::Why::Unfinished => "unfinished", }; - tauri::WebviewWindowBuilder::new(app, PICKER_WINDOW, tauri::WebviewUrl::default()) + let b = tauri::WebviewWindowBuilder::new(app, PICKER_WINDOW, tauri::WebviewUrl::default()) .title(tr!("选择连接", "Choose a Connection")) .initialization_script(format!( "{} window.__TW_PICK__ = {:?};", @@ -833,8 +833,13 @@ pub fn show_picker(app: &tauri::AppHandle, why: launch::Why) -> tauri::Result<() .minimizable(false) .maximizable(false) .center() - .visible(false) - .build()?; + .visible(false); + // 和主窗口同一个 WebView2 数据目录,见 `window::show_main_window` + let b = match crate::portable::webview_data_dir() { + Some(dir) => b.data_directory(dir), + None => b, + }; + b.build()?; Ok(()) } diff --git a/src-tauri/src/gateway.rs b/src-tauri/src/gateway.rs index df814530..1fd59078 100644 --- a/src-tauri/src/gateway.rs +++ b/src-tauri/src/gateway.rs @@ -50,6 +50,10 @@ pub const CORE_EXE: &str = if cfg!(windows) { /// (`tauri.windows.conf.json` 把它映射成 `twcore.exe`)。「是不是装好的那 /// 一份」和自更新问的是同一件事,所以同一个判断(旁边有没有卸载程序)。 /// +/// **Windows 的绿色版也只认自己旁边那一份**(zip 里就是这两个文件并排放着,见 +/// `portable`)。它旁边没有卸载程序,不走上面那个判断;不单独认出来的话,它会 +/// 被当成开发构建,往下去环境变量、工作目录、PATH 里找一个来跑。 +/// /// 以前这里只认 macOS 的布局,Windows 上装好的应用于是被当成开发构建,往下 /// 走到了开发那几条候选 —— 包里缺了 `twcore.exe` 的时候,它会去环境变量、 /// 工作目录、PATH 里找一个来跑,正是下面那段注释说要堵上的口子。界面上显示 @@ -68,7 +72,8 @@ pub(crate) fn bundled_core(product: &str) -> Option { return Some(dir.parent()?.join("Resources").join(CORE_EXE)); } #[cfg(windows)] - if update::nsis_installed(&exe) == update::Install::Standalone { + if crate::portable::is_portable() || update::nsis_installed(&exe) == update::Install::Standalone + { return Some(dir.join(CORE_EXE)); } #[cfg(target_os = "linux")] @@ -98,7 +103,14 @@ pub fn locate_core(app: &tauri::AppHandle) -> anyhow::Result { if inside.exists() { return Ok(inside); } - // **不往下走。**这里不是「再找找别处」,是这份安装包缺东西。 + // **不往下走。**这里不是「再找找别处」,是这份安装包缺东西。绿色版 + // 多半是只解压了 exe 一个文件 + if crate::portable::is_portable() { + anyhow::bail!(tr!( + "未找到 twcore 组件。请将压缩包完整解压后再运行。", + "The twcore component was not found. Extract the entire archive and run ThinkWatch Lite again." + )); + } anyhow::bail!(tr!( "安装包缺少 twcore 组件,请重新下载并安装。", "The app bundle is missing the twcore component. Download and install ThinkWatch Lite again." diff --git a/src-tauri/src/portable.rs b/src-tauri/src/portable.rs index 54651924..d6b4b344 100644 --- a/src-tauri/src/portable.rs +++ b/src-tauri/src/portable.rs @@ -3,21 +3,638 @@ //! 和安装版的区别只在三处:数据放在 exe 旁边的 `data\`(安装版在 //! `%APPDATA%\ThinkWatch`)、自更新换的是这个文件夹里的文件(不跑安装程序)、 //! twcore 只认同目录那一份。怎么认出自己是绿色版见 [`is_portable`]。 +//! +//! **两份数据互不相干。**安装版和绿色版是两套独立的设置,谁也不读谁的;同一时间 +//! 只能运行其中一个(见 `single`)。 +//! +//! 自更新(按一次之后全自动,不弹 UAC):更新器下载这一版的 zip、验签,网关停下 +//! 之后把文件夹里的两个 exe 各自**先改名成 `<名字>.old`,再写新的** —— Windows 不让 +//! 删一个正在运行的 exe,但让改它的名字。改名、写入任何一步失败都把改过的名字改 +//! 回来([`swap_in`])。留下的 `.old` 在下次启动时删掉([`prepare`])。 + +use std::path::{Path, PathBuf}; + +/// 绿色版 zip 里的应用本体。和 NSIS 装进安装目录的是同一个名字,也是 Tauri 按 +/// `Cargo.toml` 的包名起的那个 +pub const APP_EXE: &str = "thinkwatch-lite.exe"; + +/// 绿色版 zip 里的网关,放在应用本体旁边(见 `gateway::bundled_core`) +pub const CORE_EXE: &str = "twcore.exe"; + +/// 数据目录在 exe 旁边叫什么 +const DATA: &str = "data"; -use std::path::PathBuf; +/// WebView2 的数据(缓存、本地存储)放在数据目录下的哪里 +const WEBVIEW: &str = "webview"; /// 启动最早的一步:绿色版把数据目录定在 exe 旁边的 `data\`,并确认能写。 /// /// 写不进时弹系统对话框,然后退出进程。不是绿色版什么都不做。 /// **必须在任何线程起来之前调用**:它要改进程的环境变量(`THINKWATCH_HOME`)。 -pub fn prepare() {} +pub fn prepare() { + if !is_portable() { + return; + } + let Some(dir) = folder() else { + return; + }; + let data = data_dir_in(&dir); + if let Err(e) = writable(&data) { + tracing::error!("绿色版的数据目录写不进({}):{e}", data.display()); + // 设置在写不进的那个目录里,读不到;只能按系统的语言说 + crate::i18n::set(crate::i18n::system()); + crate::dialog::show( + "ThinkWatch Lite", + tr!("当前文件夹没有写入权限", "This folder is not writable"), + tr!( + "请将 ThinkWatch Lite 解压到可写入的位置后再运行。", + "Extract ThinkWatch Lite to a writable location and run it again." + ), + &[tr!("确定", "OK")], + ); + std::process::exit(1); + } + // **core 是子进程,继承这个变量**:界面和网关看的是同一个目录。从用户环境 + // 带给 core 的那一份变量里不含它(`supervisor::user_env::keep`),不会被盖掉。 + // + // SAFETY: `run()` 的第一步就是这里,在 Tauri、tokio、任何插件起线程之前;`main` + // 在这之前只装了日志订阅器,它不起线程。进程里此刻只有主线程,没有别的线程 + // 可能同时读写环境变量。 + unsafe { std::env::set_var("THINKWATCH_HOME", &data) }; + sweep(dir); +} -/// 这一份是不是绿色版。 +/// 这一份是不是绿色版:Windows、正式构建、旁边没有卸载程序(见 +/// [`crate::update::windows_kind`])。 +/// +/// 答案在进程的一生里不会变,问一次记下来。 pub fn is_portable() -> bool { - false + #[cfg(windows)] + { + static PORTABLE: std::sync::OnceLock = std::sync::OnceLock::new(); + *PORTABLE.get_or_init(|| crate::update::kind() == crate::update::Install::Portable) + } + #[cfg(not(windows))] + { + false + } } /// 绿色版的 WebView2 数据目录(`data\webview`)。不是绿色版时是 `None`,窗口用框架的默认位置。 +/// +/// 默认位置是 `%LOCALAPPDATA%\\EBWebView`,和安装版共用、而且不在这个 +/// 文件夹里 —— 删掉文件夹之后它还留在机器上。 pub fn webview_data_dir() -> Option { - None + if !is_portable() { + return None; + } + Some(data_dir_in(&folder()?).join(WEBVIEW)) +} + +/// 自更新时更新器在 latest.json 里找的键:`windows-x86_64-portable` 或 +/// `windows-aarch64-portable`。 +/// +/// **不指定的话插件按打包标记找。**zip 里的 exe 就是安装程序里的那一个,标记是 +/// NSIS,插件会找到 `windows-<架构>-nsis` 或 `windows-<架构>` —— 那是安装程序。 +pub fn updater_target() -> String { + target_for(std::env::consts::ARCH) +} + +fn target_for(arch: &str) -> String { + format!("windows-{arch}-portable") +} + +/// exe 所在的文件夹 +fn folder() -> Option { + Some(std::env::current_exe().ok()?.parent()?.to_path_buf()) +} + +fn data_dir_in(folder: &Path) -> PathBuf { + folder.join(DATA) +} + +/// 建出数据目录,再真的写一个文件试试。 +/// +/// **只看能不能写,别的一概不管。**文件夹在 U 盘、OneDrive 里都照常用。看目录的 +/// 权限位答不准(Windows 上是 ACL,继承、拒绝条目、管理员身份都算数),试一次最准。 +fn writable(data: &Path) -> std::io::Result<()> { + // 和安装版的数据目录一样只有本人能读:里面有 API 密钥 + crate::private_dir::create(data)?; + let probe = data.join(format!(".write-test-{}", std::process::id())); + std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&probe) + .and_then(|mut f| std::io::Write::write_all(&mut f, b"ok"))?; + std::fs::remove_file(&probe) +} + +/// 上次自更新留下的 `.old`(见 [`swap_in`])。 +/// +/// 只认自更新改出来的那两个名字,不按 `*.old` 扫:文件夹是用户的,别的 +/// `.old` 不是我们放的。 +fn leftovers(folder: &Path, exe_name: &std::ffi::OsStr) -> [PathBuf; 2] { + [ + old_path(&folder.join(exe_name)), + old_path(&folder.join(CORE_EXE)), + ] +} + +/// 删掉上次自更新留下的 `.old`,**在后台、多试几次**。 +/// +/// 自更新之后的第一次启动正是旧进程还没退干净的时候:重启是先拉起新进程、旧的 +/// 再退出,旧 exe(现在叫 `.old`)在它退出之前删不掉。这里不等它 —— 启动不该为 +/// 了删两个文件停下来;这一次没删掉的,下次启动再删,下次自更新改名之前也会先删。 +/// +/// 这个线程不碰环境变量,起在 `set_var` 之后。 +fn sweep(folder: PathBuf) { + let Some(name) = std::env::current_exe() + .ok() + .and_then(|e| e.file_name().map(|n| n.to_owned())) + else { + return; + }; + let files = leftovers(&folder, &name); + if !files.iter().any(|f| f.exists()) { + return; + } + std::thread::spawn(move || { + for _ in 0..40 { + if remove_all(&files) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + tracing::info!("上次更新留下的旧文件这次没删掉,下次启动再删"); + }); +} + +/// 把这些文件都删掉(本来就不在的算删掉了)。全都不在了返回 `true` +fn remove_all(files: &[PathBuf]) -> bool { + files.iter().all(|f| match std::fs::remove_file(f) { + Ok(()) => true, + Err(e) => e.kind() == std::io::ErrorKind::NotFound, + }) +} + +/// `<名字>.old` +fn old_path(file: &Path) -> PathBuf { + let mut name = file.file_name().unwrap_or_default().to_owned(); + name.push(".old"); + file.with_file_name(name) +} + +/// 从更新包里取出来的两个文件。 +pub struct Package { + app: Vec, + core: Vec, +} + +/// 拆开更新包(发布页上的 `…-portable.zip`),**只认根目录下的 +/// `thinkwatch-lite.exe` 和 `twcore.exe`,恰好这两个**。 +/// +/// 签名在这之前已经验过了(`Update::download` 验的是下载下来的原始字节,也就是 +/// 这个 zip),这里看的是形状:多一个、少一个、放在子目录里,都说明这不是绿色 +/// 版的包 —— 写进去之后应用就起不来了,而那时已经没有一个在跑的旧版本能把它 +/// 改回来。 +pub fn unpack(zip: &[u8]) -> Result { + let mut archive = zip::ZipArchive::new(std::io::Cursor::new(zip)).map_err(|e| { + tr!( + format!("无法打开更新包:{e}"), + format!("The update package could not be opened: {e}") + ) + })?; + let mut names: Vec<&str> = archive.file_names().collect(); + names.sort_unstable(); + let mut want = [APP_EXE, CORE_EXE]; + want.sort_unstable(); + if names != want { + let list = names.join(", "); + return Err(tr!( + format!("更新包中应恰好有 {APP_EXE} 和 {CORE_EXE} 两个文件,实际为:{list}"), + format!( + "The update package should hold exactly {APP_EXE} and {CORE_EXE}; it holds: {list}" + ) + )); + } + let mut read = |name: &str| -> Result, String> { + let unreadable = |e: &dyn std::fmt::Display| { + tr!( + format!("无法读取更新包中的 {name}:{e}"), + format!("{name} could not be read from the update package: {e}") + ) + }; + let mut entry = archive.by_name(name).map_err(|e| unreadable(&e))?; + if !entry.is_file() { + return Err(unreadable(&"not a file")); + } + let mut bytes = Vec::with_capacity(usize::try_from(entry.size()).unwrap_or(0)); + std::io::Read::read_to_end(&mut entry, &mut bytes).map_err(|e| unreadable(&e))?; + // 可执行文件以 `MZ` 开头。一个空的、截断的条目在这里就拦下 + if !bytes.starts_with(b"MZ") { + return Err(tr!( + format!("更新包中的 {name} 不是可执行文件"), + format!("{name} in the update package is not an executable") + )); + } + Ok(bytes) + }; + Ok(Package { + app: read(APP_EXE)?, + core: read(CORE_EXE)?, + }) +} + +/// 把包里的两个文件换进这个文件夹:应用本体换掉正在运行的这个 exe,网关换掉 +/// 旁边的 `twcore.exe`。调用之前网关要已经停下。 +/// +/// 换的是**正在运行的那个路径**,不管它叫什么:用户给 exe 改过名,重启拉起的 +/// 也还是这个名字(`restart` 跑的是进程启动时的路径)。 +pub fn install(package: Package) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let core = exe + .parent() + .ok_or_else(|| "the executable has no parent folder".to_string())? + .join(CORE_EXE); + swap_in(&[(exe, &package.app), (core, &package.core)]) +} + +/// 一个已经改了名、可能已经写了新内容的文件。回滚时照它改回去 +struct Moved { + target: PathBuf, + /// 原来的文件改名成了什么。原来就没有这个文件时是 `None` + old: Option, +} + +/// 把每个文件**先改名成 `<名字>.old`、再在原处写新的**;任何一步失败,就把 +/// 已经动过的全部改回来。 +/// +/// **不直接覆盖**:正在运行的 exe(应用本体自己,以及万一没停下来的网关)在 +/// Windows 上删不掉、也写不了,但能改名 —— 改了名的旧文件照常运行到进程退出, +/// 新文件写在它腾出来的名字上,下次启动就是新版本。 +/// +/// 上次留下的 `.old` 先删掉(那时它已经不在运行了);删不掉就不往下走。 +/// +/// 不依赖 `AppHandle`,测试直接调它。 +pub fn swap_in(files: &[(PathBuf, &[u8])]) -> Result<(), String> { + let mut moved: Vec = Vec::new(); + for (target, bytes) in files { + if let Err(e) = swap_one(target, bytes, &mut moved) { + let e = tr!( + format!("无法替换 {}:{e}", target.display()), + format!("{} could not be replaced: {e}", target.display()) + ); + return Err(match roll_back(&moved) { + Ok(()) => e, + Err(r) => { + tracing::error!("更新没换成,改回原来的文件也失败了:{r}"); + tr!( + format!("{e};未能还原原来的文件:{r}"), + format!("{e}; the previous files could not be restored: {r}") + ) + } + }); + } + } + Ok(()) +} + +fn swap_one(target: &Path, bytes: &[u8], moved: &mut Vec) -> std::io::Result<()> { + let old = old_path(target); + match std::fs::remove_file(&old) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + if target.exists() { + std::fs::rename(target, &old)?; + moved.push(Moved { + target: target.to_path_buf(), + old: Some(old), + }); + } else { + moved.push(Moved { + target: target.to_path_buf(), + old: None, + }); + } + // `create_new`:原来的已经挪走了,这里要是还有一个,说明有别人在同时写 + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(target)?; + std::io::Write::write_all(&mut f, bytes)?; + // 写到盘上再重启:断电之后不该是一个写了一半的 exe + f.sync_all() +} + +/// 倒着把动过的文件改回去:删掉写了(或写了一半)的新文件,把 `.old` 改回原名。 +/// +/// 一项失败也接着还原别的,报第一个错。 +fn roll_back(moved: &[Moved]) -> std::io::Result<()> { + let mut first = None; + for m in moved.iter().rev() { + let restored = match std::fs::remove_file(&m.target) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e), + _ => match &m.old { + Some(old) => std::fs::rename(old, &m.target), + None => Ok(()), + }, + }; + if let Err(e) = restored { + first.get_or_insert(e); + } + } + first.map_or(Ok(()), Err) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{Lang, with_lang}; + + /// 和 latest.json 里的键一字不差(`scripts/manifest.py` 的 `PLATFORMS`): + /// 拼错一个字母,绿色版就永远问不到新版本,而清单看起来完好 + #[test] + fn the_updater_key_is_the_one_the_manifest_writes() { + let manifest = include_str!("../../scripts/manifest.py"); + for arch in ["x86_64", "aarch64"] { + let key = target_for(arch); + assert!( + manifest.contains(&format!("\"{key}\"")), + "manifest.py 里没有 {key}" + ); + } + assert_eq!(target_for("x86_64"), "windows-x86_64-portable"); + assert!(updater_target().ends_with("-portable")); + } + + #[test] + fn the_data_lives_next_to_the_exe() { + let folder = Path::new("D:/Tools/ThinkWatch Lite"); + assert_eq!(data_dir_in(folder), folder.join("data")); + assert_eq!( + data_dir_in(folder).join(WEBVIEW), + folder.join("data").join("webview") + ); + } + + #[test] + fn a_writable_folder_gets_its_data_directory() { + let dir = tempfile::tempdir().unwrap(); + let data = data_dir_in(dir.path()); + writable(&data).unwrap(); + assert!(data.is_dir()); + // 试写的文件不留下 + assert_eq!(std::fs::read_dir(&data).unwrap().count(), 0); + // 已经在了也照样能过 + writable(&data).unwrap(); + } + + /// 只读的文件夹(解压到了 Program Files 之类的地方) + #[cfg(unix)] + #[test] + fn a_read_only_folder_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let folder = dir.path().join("ro"); + std::fs::create_dir(&folder).unwrap(); + std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o500)).unwrap(); + let refused = writable(&data_dir_in(&folder)); + std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o700)).unwrap(); + // root 不受权限位约束,那种环境下这条说明不了什么 + if unsafe { libc::geteuid() } != 0 { + assert!(refused.is_err()); + } + } + + #[test] + fn leftovers_are_the_two_files_the_update_renamed() { + let folder = Path::new("C:/ThinkWatch"); + assert_eq!( + leftovers(folder, std::ffi::OsStr::new("thinkwatch-lite.exe")), + [ + folder.join("thinkwatch-lite.exe.old"), + folder.join("twcore.exe.old") + ] + ); + } + + #[test] + fn removing_leftovers_counts_missing_files_as_removed() { + let dir = tempfile::tempdir().unwrap(); + let a = dir.path().join("a.exe.old"); + std::fs::write(&a, b"x").unwrap(); + let b = dir.path().join("b.exe.old"); + assert!(remove_all(&[a.clone(), b])); + assert!(!a.exists()); + // 删不掉的(这里是一个目录)就是没删完 + let c = dir.path().join("c.exe.old"); + std::fs::create_dir(&c).unwrap(); + assert!(!remove_all(&[c])); + } + + fn zip_of(entries: &[(&str, &[u8])]) -> Vec { + use std::io::Write; + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + // 发版用 Deflate 压(release.yml),这里也用它:拆包要认得这种压缩 + let opts = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (name, bytes) in entries { + if name.ends_with('/') { + w.add_directory(*name, opts).unwrap(); + } else { + w.start_file(*name, opts).unwrap(); + w.write_all(bytes).unwrap(); + } + } + w.finish().unwrap().into_inner() + } + + #[test] + fn a_portable_package_holds_the_two_executables() { + let zip = zip_of(&[ + ("thinkwatch-lite.exe", b"MZ app".repeat(1000).as_slice()), + ("twcore.exe", b"MZ core"), + ]); + let p = unpack(&zip).unwrap(); + assert_eq!(p.app, b"MZ app".repeat(1000)); + assert_eq!(p.core, b"MZ core"); + } + + /// 多一个、少一个、放进了子目录、不是 zip、不是可执行文件:都不装 + #[test] + fn anything_else_is_refused_before_it_is_written() { + let refused = [ + zip_of(&[("thinkwatch-lite.exe", b"MZ")]), + zip_of(&[ + ("thinkwatch-lite.exe", b"MZ"), + ("twcore.exe", b"MZ"), + ("README.txt", b"hi"), + ]), + zip_of(&[ + ("ThinkWatch Lite/thinkwatch-lite.exe", b"MZ"), + ("ThinkWatch Lite/twcore.exe", b"MZ"), + ]), + zip_of(&[ + ("data/", b""), + ("thinkwatch-lite.exe", b"MZ"), + ("twcore.exe", b"MZ"), + ]), + zip_of(&[("thinkwatch-lite.exe", b"MZ"), ("twcore.exe", b"")]), + b"MZ not a zip".to_vec(), + ]; + for (i, zip) in refused.iter().enumerate() { + assert!(unpack(zip).is_err(), "第 {i} 个不该过"); + } + with_lang(Lang::En, || { + let e = unpack(&refused[2]).err().unwrap(); + assert!( + e.contains("exactly thinkwatch-lite.exe and twcore.exe"), + "{e}" + ); + assert!(e.contains("ThinkWatch Lite/twcore.exe"), "{e}"); + }); + } + + /// 一个文件夹:应用本体和网关,内容是 `old` + fn folder_with_old_copies() -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let app = dir.path().join(APP_EXE); + let core = dir.path().join(CORE_EXE); + std::fs::write(&app, b"old app").unwrap(); + std::fs::write(&core, b"old core").unwrap(); + (dir, app, core) + } + + #[test] + fn both_files_are_renamed_aside_and_replaced() { + let (_dir, app, core) = folder_with_old_copies(); + // 上次更新留下的 `.old` 先被删掉,不挡这一次 + std::fs::write(old_path(&core), b"older core").unwrap(); + + swap_in(&[(app.clone(), b"new app"), (core.clone(), b"new core")]).unwrap(); + + assert_eq!(std::fs::read(&app).unwrap(), b"new app"); + assert_eq!(std::fs::read(&core).unwrap(), b"new core"); + assert_eq!(std::fs::read(old_path(&app)).unwrap(), b"old app"); + assert_eq!(std::fs::read(old_path(&core)).unwrap(), b"old core"); + } + + /// 第二个写不进(目录不在):第一个已经换好的也改回去,`.old` 不留 + #[test] + fn a_failure_puts_every_file_back() { + let (dir, app, core) = folder_with_old_copies(); + let nowhere = dir.path().join("missing").join(CORE_EXE); + + let e = swap_in(&[(app.clone(), b"new app"), (nowhere, b"new core")]); + + assert!(e.is_err()); + assert_eq!(std::fs::read(&app).unwrap(), b"old app"); + assert_eq!(std::fs::read(&core).unwrap(), b"old core"); + assert!(!old_path(&app).exists()); + } + + /// 上次留下的 `.old` 删不掉(这里是一个目录):不往下走,已经动过的改回去 + #[test] + fn a_leftover_that_cannot_be_removed_stops_the_swap() { + let (_dir, app, core) = folder_with_old_copies(); + std::fs::create_dir(old_path(&core)).unwrap(); + + assert!(swap_in(&[(app.clone(), b"new app"), (core.clone(), b"new core")]).is_err()); + + assert_eq!(std::fs::read(&app).unwrap(), b"old app"); + assert_eq!(std::fs::read(&core).unwrap(), b"old core"); + assert!(!old_path(&app).exists()); + } + + /// 原来没有网关(被用户删了):照样写进去;失败时把写进去的删掉 + #[test] + fn a_missing_file_is_written_and_removed_again_on_failure() { + let dir = tempfile::tempdir().unwrap(); + let core = dir.path().join(CORE_EXE); + swap_in(&[(core.clone(), b"new core")]).unwrap(); + assert_eq!(std::fs::read(&core).unwrap(), b"new core"); + assert!(!old_path(&core).exists()); + + let fresh = dir.path().join("fresh.exe"); + let nowhere = dir.path().join("missing").join("x.exe"); + assert!(swap_in(&[(fresh.clone(), b"x"), (nowhere, b"y")]).is_err()); + assert!(!fresh.exists()); + } + + /// 给下面那条测试当「正在运行的 exe」:设了这个变量就睡半分钟,平时直接过 + #[cfg(windows)] + #[test] + fn sleeps_when_asked() { + if std::env::var_os(SLEEP).is_some() { + std::thread::sleep(std::time::Duration::from_secs(30)); + } + } + + #[cfg(windows)] + const SLEEP: &str = "TW_PORTABLE_TEST_SLEEP"; + + /// **这条是整个自更新成立的前提**:Windows 上正在运行的 exe 能改名、腾出来 + /// 的名字上能写新文件、失败时能改回去;进程退出之后 `.old` 删得掉。 + /// + /// 把这个测试程序自己拷两份当应用本体和网关,只跑上面那条会睡的测试,对着 + /// 它们换、回滚,再停掉进程、清理。 + #[cfg(windows)] + #[test] + fn running_executables_are_renamed_aside_and_put_back() { + let original = std::fs::read(std::env::current_exe().unwrap()).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let app = dir.path().join(APP_EXE); + let core = dir.path().join(CORE_EXE); + let mut running = Vec::new(); + for exe in [&app, &core] { + std::fs::write(exe, &original).unwrap(); + running.push( + std::process::Command::new(exe) + .args(["--exact", "portable::tests::sleeps_when_asked"]) + .env(SLEEP, "1") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .unwrap(), + ); + } + // 等它们真的把映像映射上 + std::thread::sleep(std::time::Duration::from_millis(500)); + + // 失败:第二个写不进,正在运行的第一个被改名之后又改了回来 + let nowhere = dir.path().join("missing").join(CORE_EXE); + assert!(swap_in(&[(app.clone(), b"MZ new app"), (nowhere, b"MZ new core")]).is_err()); + assert_eq!(std::fs::read(&app).unwrap(), original); + assert!(!old_path(&app).exists()); + + // 成功:两个正在运行的都挪开了,新的写在原来的名字上 + swap_in(&[(app.clone(), b"MZ new app"), (core.clone(), b"MZ new core")]).unwrap(); + assert_eq!(std::fs::read(&app).unwrap(), b"MZ new app"); + assert_eq!(std::fs::read(&core).unwrap(), b"MZ new core"); + assert_eq!(std::fs::read(old_path(&app)).unwrap(), original); + assert_eq!(std::fs::read(old_path(&core)).unwrap(), original); + + // 还在运行的旧进程不受影响 + for p in running.iter_mut() { + assert!(p.try_wait().unwrap().is_none(), "改名不该让进程退出"); + } + for mut p in running { + let _ = p.kill(); + let _ = p.wait(); + } + // 进程退出之后,下次启动就删得掉 + let gone = leftovers(dir.path(), std::ffi::OsStr::new(APP_EXE)); + let mut removed = false; + for _ in 0..40 { + if remove_all(&gone) { + removed = true; + break; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + assert!(removed, "进程退出之后 .old 应当删得掉"); + } } diff --git a/src-tauri/src/update.rs b/src-tauri/src/update.rs index 563db9dd..58df564e 100644 --- a/src-tauri/src/update.rs +++ b/src-tauri/src/update.rs @@ -12,7 +12,8 @@ //! **Windows 上没有 Homebrew 那一档。**winget 装的和网页下载的是同一个安装 //! 程序,已装版本记在「添加/删除程序」的注册表项里;自己更新时跑的是新版本 //! 的安装程序,它会把那一项一起改掉,所以 winget 之后看到的就是新版本,不会 -//! 拿旧的盖回来。 +//! 拿旧的盖回来。**绿色版**(解压即用的 zip)也自己更新,换的是自己文件夹里的 +//! 两个 exe,不跑安装程序(见 `portable`)。 //! //! **Linux 上只发 AppImage,看打包时写进二进制的那个标记,不按路径猜。**打包器 //! 给 AppImage 打了补丁(`tauri::utils::platform::bundle_type()` 读的就是它), @@ -57,6 +58,9 @@ pub enum Install { /// 手工下载解压的 `.app`(Windows 上是安装程序装的,Linux 上是 AppImage)。 /// 这一种可以自己更新。 Standalone, + /// Windows 的绿色版:zip 解压出来的文件夹(见 `portable`)。也自己更新, + /// 换的是文件夹里的两个 exe。 + Portable, /// 根本不在一个 `.app` 里 —— `tauri dev`。 Dev, } @@ -64,7 +68,7 @@ pub enum Install { impl Install { /// 这一份能不能自己把自己换掉。 pub fn can_self_update(self) -> bool { - self == Install::Standalone + matches!(self, Install::Standalone | Install::Portable) } } @@ -145,6 +149,25 @@ pub fn nsis_installed(exe: &Path) -> Install { } } +/// Windows 上这一份是怎么装上来的:安装程序装的、绿色版,还是开发构建。 +/// +/// **旁边有卸载程序的是安装版**(见 [`nsis_installed`]);没有的,正式构建就是 +/// 绿色版 —— 发出去的 exe 只有这两种去处,zip 里没有卸载程序。开发构建(`tauri +/// dev`、`cargo build`)旁边也没有,靠 `release` 分开:它们不该把数据放到 +/// `target\` 里,也不该去替换自己。 +/// +/// `cargo build --release` 出来的那一份因此也算绿色版,数据在 `target\release\data\` +/// —— 和用户解压出来的那一份行为一致,正好用来试。 +// 判断本身和平台无关,所以在哪都测;只有 Windows 上真的拿它来用 +#[cfg_attr(not(windows), allow(dead_code))] +pub fn windows_kind(exe: &Path, release: bool) -> Install { + match nsis_installed(exe) { + Install::Standalone => Install::Standalone, + _ if release => Install::Portable, + _ => Install::Dev, + } +} + /// Linux 上这一份是怎么装上来的。 /// /// **AppImage 还要看 `APPIMAGE` 在不在。**同一个打过补丁的二进制,用户用 @@ -207,7 +230,7 @@ pub fn kind() -> Install { }; #[cfg(windows)] { - nsis_installed(&exe) + windows_kind(&exe, !cfg!(debug_assertions)) } #[cfg(target_os = "macos")] { @@ -217,8 +240,10 @@ pub fn kind() -> Install { } /// 发布页。自动更新装不上时,从这里手动下载新版本。 -#[cfg(target_os = "linux")] -const RELEASES_URL: &str = "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest"; +// macOS 上装不上时插件自己说原因,用不到它 +#[cfg_attr(target_os = "macos", allow(dead_code))] +pub(crate) const RELEASES_URL: &str = + "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest"; /// 插件替换 AppImage 失败时的那一句。 /// @@ -252,6 +277,22 @@ pub fn appimage_failure(e: &tauri_plugin_updater::Error) -> String { } } +/// 绿色版没换成时的那一句(见 `updater::install_portable`)。 +/// +/// 走到这里时改过名的文件已经改回去、网关也接回来了,用的还是原来那一版。拆包、 +/// 改名、写入失败都不是重试能解决的(包不对、文件夹被别的程序占着、杀毒软件拦了 +/// 写入),说清楚去哪下载新版本,解压覆盖就行。 +// 判断本身和平台无关,所以在哪都测;只有 Windows 上真的拿它来用 +#[cfg_attr(not(windows), allow(dead_code))] +pub fn portable_failure(e: &str) -> String { + tr!( + format!("更新未安装:{e}。新版本可从 {RELEASES_URL} 下载。"), + format!( + "The update was not installed: {e}. The new version can be downloaded from {RELEASES_URL}." + ) + ) +} + /// 从 cask 文件里读出版本号。 /// /// 只认 `version "x"` 这一种写法。`version :latest` 之类不带具体版本的 @@ -402,6 +443,32 @@ mod tests { std::fs::remove_dir_all(&root).unwrap(); } + /// 旁边没有卸载程序的正式构建是绿色版;开发构建不管放在哪都是开发构建; + /// 有卸载程序的永远是安装版。 + #[test] + fn a_release_build_without_an_uninstaller_is_the_portable_copy() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("thinkwatch-lite.exe"); + std::fs::write(&exe, b"").unwrap(); + + assert_eq!(windows_kind(&exe, true), Install::Portable); + assert!(windows_kind(&exe, true).can_self_update()); + assert_eq!(windows_kind(&exe, false), Install::Dev); + + std::fs::write(dir.path().join("uninstall.exe"), b"").unwrap(); + assert_eq!(windows_kind(&exe, true), Install::Standalone); + assert_eq!(windows_kind(&exe, false), Install::Standalone); + } + + /// 界面认的是这个词(`src/updateFlow.ts` 的 `Install`) + #[test] + fn the_portable_copy_is_called_portable_on_the_wire() { + assert_eq!( + serde_json::to_string(&Install::Portable).unwrap(), + "\"portable\"" + ); + } + /// Linux 上信打包时写进去的标记,不信路径和环境变量。 #[cfg(target_os = "linux")] #[test] @@ -448,6 +515,22 @@ mod tests { assert!(!appimage_failure(&Error::BinaryNotFoundInArchive).contains(RELEASES_URL)); } + /// 绿色版没换成:说原因,也说去哪下载 + #[test] + fn a_failed_portable_update_says_where_to_download() { + let said = portable_failure("拒绝访问。 (os error 5)"); + assert!(said.starts_with("更新未安装:拒绝访问。"), "{said}"); + assert!(said.contains(RELEASES_URL)); + crate::i18n::with_lang(crate::i18n::Lang::En, || { + let said = portable_failure("Access is denied. (os error 5)"); + assert!( + said.starts_with("The update was not installed: Access"), + "{said}" + ); + assert!(said.ends_with(&format!("{RELEASES_URL}."))); + }); + } + /// 读的是 tap 里真实的那份 cask —— 格式变了,这条先红。 #[test] fn the_version_comes_out_of_a_real_cask() { @@ -455,7 +538,7 @@ mod tests { version "2026.9.2" sha256 "bccc9014b1b1df1fb4e33fa5534f1ccb7c431ac41a1b415aa932090c4dfd0cf4" - url "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/download/v#{version}/ThinkWatch-Lite-#{version}-arm64.dmg" + url "https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/download/v#{version}/ThinkWatch-Lite-#{version}-darwin-arm64.dmg" end "#; assert_eq!(cask_version(cask), Some("2026.9.2")); diff --git a/src-tauri/src/updater.rs b/src-tauri/src/updater.rs index f8e9427c..002f6dba 100644 --- a/src-tauri/src/updater.rs +++ b/src-tauri/src/updater.rs @@ -82,14 +82,28 @@ pub struct OfferView { /// /// 读的是一份几百字节的 JSON,不下载任何别的东西。 pub(crate) async fn look(app: &tauri::AppHandle) -> Result, String> { - use tauri_plugin_updater::UpdaterExt; - let u = app.updater().map_err(|e| e.to_string())?; - let found = u.check().await.map_err(|e| e.to_string())?; + let found = updater(app)?.check().await.map_err(|e| e.to_string())?; Ok(found.map(|up| Found { version: up.version.clone(), })) } +/// 更新器。查和装用的是同一个,**两处必须找 latest.json 里的同一个键**。 +/// +/// 绿色版指定自己的键(见 `portable::updater_target`):它的 exe 就是安装程序里 +/// 那一个,不指定的话插件按打包标记找到的是安装程序 —— 查到的版本对,下下来的却 +/// 是 setup.exe,拆包这一步才失败。 +fn updater(app: &tauri::AppHandle) -> Result { + use tauri_plugin_updater::UpdaterExt; + let builder = app.updater_builder(); + let builder = if crate::portable::is_portable() { + builder.target(crate::portable::updater_target()) + } else { + builder + }; + builder.build().map_err(|e| e.to_string()) +} + /// 从网上读一段文本。 /// /// **和更新器插件同一套 TLS。**插件在它的第一次请求之前,把 ring 装成进程 @@ -165,7 +179,7 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { w.set_focus()?; return Ok(()); } - WebviewWindowBuilder::new(app, UPDATE_WINDOW, WebviewUrl::default()) + let b = WebviewWindowBuilder::new(app, UPDATE_WINDOW, WebviewUrl::default()) .title(tr!("软件更新", "Software Update")) .initialization_script(i18n::init_script()) // 高度先随便给一个:网页画完量出内容有多高,再由 `update_fit` 定下来 @@ -174,8 +188,13 @@ pub(crate) fn show_update_window(app: &tauri::AppHandle) -> tauri::Result<()> { .minimizable(false) .maximizable(false) .center() - .visible(false) - .build()?; + .visible(false); + // 和主窗口同一个 WebView2 数据目录,见 `window::show_main_window` + let b = match crate::portable::webview_data_dir() { + Some(dir) => b.data_directory(dir), + None => b, + }; + b.build()?; Ok(()) } @@ -382,7 +401,6 @@ pub async fn update_install( hub: tauri::State<'_, Updates>, ) -> Out<()> { use std::sync::atomic::Ordering; - use tauri_plugin_updater::UpdaterExt; let install = update::kind(); if !install.can_self_update() { @@ -417,9 +435,7 @@ pub async fn update_install( } let _release = Release(&hub.installing); - let up = app - .updater() - .map_err(|e| e.to_string())? + let up = updater(&app)? .check() .await .map_err(|e| e.to_string())? @@ -436,6 +452,11 @@ pub async fn update_install( ) .await .map_err(|e| tr!(format!("下载失败:{e}"), format!("Download failed: {e}")))?; + // 绿色版不跑安装程序,自己换文件夹里的两个 exe —— 见 `install_portable` + #[cfg(windows)] + if install == update::Install::Portable { + return install_portable(&app, &state, bytes).await; + } // 发布页上只有 DMG,插件只装 `.app.tar.gz` —— 见 `dmg`。放在等请求之前: // 包打不开的话,不该先让用户白等几分钟 #[cfg(target_os = "macos")] @@ -510,6 +531,48 @@ pub async fn update_install( app.restart() } +/// 绿色版的安装:拆包、等请求结束、停网关、换掉文件夹里的两个 exe、重启。**不弹 +/// UAC**:文件夹是用户自己解压的,启动时已经确认过能写(`portable::prepare`)。 +/// +/// **包先拆开看。**形状不对(见 `portable::unpack`)不该先让用户白等几分钟,网关 +/// 也还没停。 +/// +/// 换文件之前先记下退出时的样子([`record_exit`]),和其他平台一样:重启之后照它 +/// 恢复窗口。换不成就把改过名的文件改回来(`portable::swap_in` 自己做)、把停掉的 +/// 网关接回来,说清楚去哪下载。 +#[cfg(windows)] +async fn install_portable(app: &tauri::AppHandle, state: &AppState, zip: Vec) -> Out<()> { + use crate::portable; + let package = tauri::async_runtime::spawn_blocking(move || portable::unpack(&zip)) + .await + .map_err(|e| e.to_string())? + .map_err(|e| update::portable_failure(&e))?; + + // 问的是本机的 core:要重启的是它。连着远程时它本来就停着,不用等 + wait_for_quiet(state.supervisor.control(), |in_flight| { + let _ = app.emit("update-step", Step::Waiting { in_flight }); + }) + .await; + + let _ = app.emit("update-step", Step::Installing); + record_exit(app); + // `twcore.exe` 也要换:先停下,等它真的退出 + state + .supervisor + .stop_and_wait(std::time::Duration::from_secs(5)) + .await; + let swapped = tauri::async_runtime::spawn_blocking(move || portable::install(package)) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r); + if let Err(e) = swapped { + resume_after_failed_update(app).await; + return Err(update::portable_failure(&e).into()); + } + let _ = app.emit("update-step", Step::Restarting); + app.restart() +} + /// 装下载好的那一份,**在阻塞线程上**。 /// /// `install` 从头到尾都是阻塞的活:macOS 上要替换的位置写不进去时,插件把管理员密码 @@ -534,7 +597,7 @@ async fn install_blocking( /// 返回了,而循环要再走一步才把「正在守护」放下;这之间去拉,会被当成 /// 「守护还在,重启一下」,然后因为 core 不在跑而什么也不做。 /// -/// Windows 的安装程序是先停网关再装,装不成走这里。 +/// Windows 的安装程序是先停网关再装,装不成走这里;绿色版换文件没换成也走这里。 #[cfg(windows)] pub(crate) async fn resume_after_failed_update(app: &tauri::AppHandle) { use std::sync::atomic::Ordering; diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs index 7ce82692..b0fa1133 100644 --- a/src-tauri/src/window.rs +++ b/src-tauri/src/window.rs @@ -124,6 +124,12 @@ pub(crate) fn show_main_window(app: &tauri::AppHandle) -> tauri::Result<()> { .inner_size(1100.0, 720.0) .min_inner_size(820.0, 560.0) .visible(!warm); + // 绿色版的 WebView2 数据放在它自己的文件夹里(见 `portable::webview_data_dir`)。 + // 三扇窗(这里、更新窗口、连接选择)要一致:同一个进程里的 WebView2 共用一个数据目录 + let b = match crate::portable::webview_data_dir() { + Some(dir) => b.data_directory(dir), + None => b, + }; // 把内容顶到标题栏里、藏掉标题:**这两样只有 macOS 有**,那里红绿灯 // 浮在内容上,界面顶部那几处 `data-tauri-drag-region` 就是为它留的。 // Windows 上用系统标题栏,所以那些留白按平台去掉了(见 App.tsx 里用 diff --git a/src/Update.i18n.ts b/src/Update.i18n.ts index f3821ce1..8f7e137c 100644 --- a/src/Update.i18n.ts +++ b/src/Update.i18n.ts @@ -10,6 +10,7 @@ export const updateText = messages( current: (version: string) => `当前版本 ${version}`, available: (version: string) => `ThinkWatch Lite ${version} 可用`, closeWhileWaiting: "关闭此窗口不影响更新,更新完成后将发送通知。", + /** 能在窗口里直接装的那几档(网页下载的、Windows 的绿色版)都是这一句 */ standalone: "下载完成后自动安装。网关将在进行中的请求全部结束后重新启动。", later: "稍后", install: "下载并安装", diff --git a/src/updateFlow.test.ts b/src/updateFlow.test.ts index 695ca721..761e46aa 100644 --- a/src/updateFlow.test.ts +++ b/src/updateFlow.test.ts @@ -15,6 +15,10 @@ describe("谁能在窗口里直接装", () => { expect(canInstall("standalone")).toBe(true); }); + it("Windows 的绿色版也可以", () => { + expect(canInstall("portable")).toBe(true); + }); + it("开发构建不自己更新", () => { expect(canInstall("dev")).toBe(false); }); diff --git a/src/updateFlow.ts b/src/updateFlow.ts index ef4f217f..896624b1 100644 --- a/src/updateFlow.ts +++ b/src/updateFlow.ts @@ -7,8 +7,13 @@ import { textOf } from "@/i18n"; import { updateText } from "./Update.i18n"; -/** 这一份是怎么装上来的。决定更新由谁做。 */ -export type Install = "homebrew" | "standalone" | "dev"; +/** + * 这一份是怎么装上来的。决定更新由谁做。 + * + * `portable` 是 Windows 的绿色版(解压即用的 zip):和 `standalone` 一样在窗口里 + * 一键装好,换的是它自己文件夹里的文件。 + */ +export type Install = "homebrew" | "standalone" | "portable" | "dev"; /** 查到的新版本。 */ export interface Found { @@ -48,7 +53,7 @@ export type Step = * 的 —— 它承诺了一件做不到的事,用户要点下去才知道。 */ export function canInstall(install: Install): boolean { - return install === "standalone"; + return install === "standalone" || install === "portable"; } const MB = 1_048_576; From 5cc7c1f512f6c02e54c62803c40a2647be3b8026 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:30:41 +0800 Subject: [PATCH 03/23] Windows portable: system dialogs, HKCU registrations, one instance dialog: TaskDialogIndirect with our own button labels, a main instruction and content, loaded from comctl32 at call time so the test binaries (no v6 manifest) still start; MessageBoxW as fallback. dialog::wait shows a marquee dialog that cannot be dismissed while a closure runs on another thread and closes itself when it returns. winreg: claim() points thinkwatch:// at the running exe under HKCU, repoints an existing autostart Run entry to it, and registers the notification AUMID (DisplayName + IconUri to a png written into the data dir). release_all() removes only entries that point at the given exe, plus this copy's AUMID registration and icon. Dev builds never touch the registry. Autostart on Windows no longer goes through the plugin: the toggle is the one shared Run entry (on = present anywhere and not disabled in Settings > Apps > Startup), enabling writes a quoted command for this exe, disabling deletes it. Notifications: AUMID app.thinkwatch.lite (installed) or app.thinkwatch.lite.portable; availability follows the registration instead of the Start menu shortcut. single: precheck() asks when the running instance is a different exe (pure verdict() decides, tested everywhere), signals a named event the running side creates in listen(), shows the waiting dialog until the plugin's mutex is released (abandoned counts), and releases and closes its own handle before Tauri starts. The running side waits for in-flight requests (up to 3 minutes), marks the exit as user-initiated, stops core and exits. Co-Authored-By: Claude Opus 5.5 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 7 + src-tauri/src/autostart.rs | 60 +- src-tauri/src/dialog.rs | 309 ++++++++++- src-tauri/src/lib.rs | 4 +- src-tauri/src/notices/sink.rs | 2 +- src-tauri/src/notices/windows/mod.rs | 54 +- src-tauri/src/notices/windows/toast.rs | 32 +- src-tauri/src/settings.rs | 81 +-- src-tauri/src/single.rs | 580 ++++++++++++++++++- src-tauri/src/winreg.rs | 735 ++++++++++++++++++++++++- 11 files changed, 1736 insertions(+), 129 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c61d36c8..1e2b7bd8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4802,6 +4802,7 @@ dependencies = [ "tw-types", "url", "windows", + "windows-registry 0.6.1", "windows-sys 0.61.2", "zbus", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d40570f..10953e46 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -177,7 +177,14 @@ windows-sys = { version = "0.61", features = [ # 醒来的那个常量在后一个里 "Win32_System_Power", "Win32_UI_WindowsAndMessaging", + # 应用起来之前的系统对话框(`dialog`):TaskDialog 的结构和常量在前一个里,函数本身 + # 用的时候现从 comctl32 里取,所以还要后一个 + "Win32_UI_Controls", + "Win32_System_LibraryLoader", ] } +# HKCU 下的链接、开机自启、通知登记(`winreg`)。版本是 reqwest 的系统代理在 Windows 上 +# 已经拉进来的那一份,不多编一套 +windows-registry = "0.6" # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 windows = { version = "0.61", features = [ diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs index a998c06c..9501395a 100644 --- a/src-tauri/src/autostart.rs +++ b/src-tauri/src/autostart.rs @@ -9,7 +9,8 @@ //! //! **Linux 上连插件都不用**,自启项整个自己写(理由见 `linux` 子模块头上)。 //! 调用方一律经 [`launcher`] 拿开关、不直接碰插件 —— 这样 Linux 上不会有 -//! 哪一处漏走插件那条路。 +//! 哪一处漏走插件那条路。**Windows 上设置里的开关也不经插件**,理由见 +//! `approved_from_bytes` 上面那一段。 /// 纯文本的编码和解析在每个平台都编译,测试因此在哪都跑(和 /// `approved_from_bytes` 同一条理由);读写文件那几样只有 Linux 调。 @@ -85,46 +86,17 @@ pub fn allowed_in_this_build() -> bool { !cfg!(debug_assertions) } -/// 用户在「设置 → 应用 → 启动」里把它关掉了吗。 -/// -/// **这是 Windows 上那第三件插件给不了的事**(前两件见模块头)。在那个开关里 -/// 关掉之后,Windows 写的是 `StartupApproved\Run` 里的一个标志,而 -/// **我们在 `Run` 下的那一项原封不动** —— 于是插件的 `is_enabled()`(它只看 -/// `Run` 里那一项在不在)会说「开着呢」,而实际上开机时它不会被拉起来。 -/// 界面上那个勾选框因此在撒谎,和 macOS 那个 plist 路径漂移是完全同一类。 -/// -/// `None` = 这个开关没碰过它(多数情况),按插件说的算。 -#[cfg(windows)] -pub fn disabled_by_windows(app_name: &str) -> Option { - use windows_sys::Win32::System::Registry::{ - HKEY_CURRENT_USER, RRF_RT_REG_BINARY, RegGetValueW, - }; - - fn wide(s: &str) -> Vec { - s.encode_utf16().chain(std::iter::once(0)).collect() - } - let sub = wide(r"Software\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"); - let name = wide(app_name); - let mut buf = [0u8; 32]; - let mut len = buf.len() as u32; - // SAFETY: 两个字符串都以 NUL 结尾;`len` 一开始是缓冲区的大小,函数不会 - // 写超过它。 - let rc = unsafe { - RegGetValueW( - HKEY_CURRENT_USER, - sub.as_ptr(), - name.as_ptr(), - RRF_RT_REG_BINARY, - std::ptr::null_mut(), - buf.as_mut_ptr().cast(), - &mut len, - ) - }; - if rc != 0 { - return None; - } - approved_from_bytes(&buf[..len as usize]).map(|on| !on) -} +// **Windows 上开关不经插件**,读写的是 `HKCU\…\Run` 里那一项,见 `winreg`。 +// +// 那一项全机只有一条、安装版和绿色版共用,开关的意思随之变成「这台机器上开机时拉起 +// ThinkWatch Lite」:指向哪一份都算开着,打开时写指向自己的,启动时把开着的那一条改成 +// 指向正在运行的这一份(谁运行就听谁的)。插件做不到这些:它只认自己的路径,写的路径 +// 还不加引号。 +// +// 还有一件插件给不了的事:用户在「设置 → 应用 → 启动」里把它关掉之后,Windows 写的是 +// `StartupApproved\Run` 里的一个标志,而 **`Run` 下的那一项原封不动** —— 只看那一项 +// 在不在的话,界面上的开关会说「开着」,而开机时它不会被拉起来。所以「开着」还要看 +// 这个标志,见下面的 `approved_from_bytes`。 /// `StartupApproved\Run` 里那串字节说的是「开着」还是「被关掉了」。 /// @@ -145,7 +117,7 @@ pub fn disabled_by_windows(app_name: &str) -> Option { /// (顺带:`clippy --all-targets` 看不出它在别处是死代码,因为测试目标用了 /// 它。单独编 lib 才会报。) #[cfg_attr(not(windows), allow(dead_code))] -fn approved_from_bytes(v: &[u8]) -> Option { +pub(crate) fn approved_from_bytes(v: &[u8]) -> Option { Some(v.first()? & 1 == 0) } @@ -201,9 +173,7 @@ mod tests { } /// **只在 macOS 上**:LaunchAgent 和 plist 是那个平台的机制。Windows 上 - /// 自启走 `HKCU\Run`,对应的那道检查(用户在「设置 → 应用 → 启动」里关掉 - /// 之后,Run 键还在、插件仍然说「开着」)是 `disabled_by_windows`,它自己 - /// 带着测试。 + /// 自启走 `HKCU\Run`,读写和路径跟随都在 `winreg`,测试也在那里。 #[cfg(target_os = "macos")] #[test] fn the_plist_lives_next_to_the_other_launch_agents() { diff --git a/src-tauri/src/dialog.rs b/src-tauri/src/dialog.rs index 5643bfa4..d17f4036 100644 --- a/src-tauri/src/dialog.rs +++ b/src-tauri/src/dialog.rs @@ -3,10 +3,19 @@ //! 绿色版的文件夹写不进、缺 WebView2、已经有一个实例在运行,这几件事都发生在 //! Tauri 起来之前,没有网页可用,只能用系统自己的对话框。只有 Windows 上有; //! 其他平台上什么都不做。 +//! +//! Windows 上是 `TaskDialogIndirect`:按钮上的字由这里定(`MessageBoxW` 只有系统给的 +//! 「确定」「取消」,而且跟着系统语言,不跟应用设置的语言),主句加大、正文另起。它只在 +//! comctl32 的第 6 版里有,而那一版要靠程序清单里的依赖才拿得到 —— tauri-build 给 exe +//! 嵌的默认清单里有(`common-controls-v6`),**测试程序没有**。所以不静态链接它,用的时候 +//! 现取:取不到(没有清单的进程)就退回 `MessageBoxW`。静态链接的话,测试程序会因为 +//! 缺这个入口点根本起不来。 /// 弹一个对话框,返回按下的按钮的下标;关掉对话框、或者这个平台不支持时是 `None`。 /// -/// `buttons` 从左到右排,第一个是默认按钮。 +/// `instruction` 是加大显示的那一句主句,`content` 是它下面的正文(可以为空)。 +/// `buttons` 从左到右排,第一个是默认按钮,字原样显示。没有父窗口:调用的时候 +/// Tauri 还没起来,进程里没有别的窗口。 pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> Option { #[cfg(windows)] { @@ -19,19 +28,309 @@ pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> } } +/// 一边在另一个线程上跑 `work`,一边显示一个等待的对话框(主句、正文和一条来回走的 +/// 进度条);`work` 一结束对话框就关掉,返回 `work` 的结果。 +/// +/// **这个对话框关不掉。**唯一的按钮「取消」是灰的,Esc、Alt+F4、标题栏的关闭按钮都 +/// 不管用:等的那件事一旦开始就收不回来(比如已经请运行中的程序退出了),这时放弃只会 +/// 两头落空。所以 `work` 自己要有上限。`work` 发生 panic 也会关掉对话框,panic 照旧 +/// 传给调用方。 +/// +/// 对话框占着调用它的线程跑消息循环,`work` 因此在另一个线程上跑,要能 `Send`;它可以 +/// 借用调用方手里的东西。不是 Windows、或者系统对话框用不了时,直接在当前线程上跑 +/// `work`,什么都不显示。 +pub fn wait( + title: &str, + instruction: &str, + content: &str, + work: impl FnOnce() -> T + Send, +) -> T { + #[cfg(windows)] + { + imp::wait(title, instruction, content, work) + } + #[cfg(not(windows))] + { + let _ = (title, instruction, content); + work() + } +} + #[cfg(windows)] mod imp { + use std::sync::atomic::{AtomicBool, Ordering}; + + use windows_sys::Win32::Foundation::{HWND, LPARAM, S_FALSE, S_OK, WPARAM}; + use windows_sys::Win32::System::LibraryLoader::{ + GetModuleHandleW, GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, + }; + use windows_sys::Win32::UI::Controls::{ + TASKDIALOG_BUTTON, TASKDIALOGCONFIG, TDF_ALLOW_DIALOG_CANCELLATION, TDF_CALLBACK_TIMER, + TDF_SHOW_MARQUEE_PROGRESS_BAR, TDM_CLICK_BUTTON, TDM_ENABLE_BUTTON, + TDM_SET_PROGRESS_BAR_MARQUEE, TDN_BUTTON_CLICKED, TDN_CREATED, TDN_TIMER, + }; use windows_sys::Win32::UI::WindowsAndMessaging::{ - IDCANCEL, IDOK, MB_ICONINFORMATION, MB_OK, MB_OKCANCEL, MessageBoxW, + IDCANCEL, IDOK, MB_ICONINFORMATION, MB_OK, MB_OKCANCEL, MessageBoxW, PostMessageW, + SendMessageW, }; + use windows_sys::core::{BOOL, HRESULT}; + + /// 自定义按钮的编号从这里起:避开 `IDOK`、`IDCANCEL` 这些系统按钮的编号 + const FIRST_BUTTON: i32 = 100; + + type TaskDialogIndirect = unsafe extern "system" fn( + config: *const TASKDIALOGCONFIG, + button: *mut i32, + radio: *mut i32, + verified: *mut BOOL, + ) -> HRESULT; fn wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } - /// 骨架:`MessageBoxW`,按钮文字由系统定、最多两个。B 路换成 `TaskDialogIndirect` + /// 现取 `TaskDialogIndirect`,见模块说明。**只在系统目录里找**(和清单指定的并排 + /// 程序集):绿色版的文件夹在哪都可能,不能让旁边一个同名的 dll 被加载进来 + fn task_dialog() -> Option { + let name = wide("comctl32.dll"); + // SAFETY: 名字以 0 结尾。模块不释放:对话框随时可能再用到它,进程退出时自然卸载 + let module = unsafe { + LoadLibraryExW( + name.as_ptr(), + std::ptr::null_mut(), + LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if module.is_null() { + return None; + } + // SAFETY: 模块句柄有效,名字以 0 结尾 + let f = unsafe { GetProcAddress(module, c"TaskDialogIndirect".as_ptr().cast()) }?; + // SAFETY: 导出的就是这个签名(commctrl.h) + Some(unsafe { + std::mem::transmute:: isize, TaskDialogIndirect>(f) + }) + } + + /// 对话框所在的线程按每个显示器的 DPI 画。**不设的话在缩放过的屏幕上是糊的**:这时 + /// Tauri 还没把进程设成感知 DPI。只改这个线程,用完改回去,不动整个进程的设置 + /// (那是 Tauri 起来时自己要设的)。系统太旧、没有这个函数就算了 + struct SharpText(Option<(SetContext, isize)>); + + type SetContext = unsafe extern "system" fn(isize) -> isize; + + impl SharpText { + fn on() -> Self { + // DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 + const PER_MONITOR_V2: isize = -4; + let user32 = wide("user32.dll"); + // SAFETY: 名字以 0 结尾;user32 一定已经加载(`MessageBoxW` 就从它导入) + let set = unsafe { + let module = GetModuleHandleW(user32.as_ptr()); + if module.is_null() { + return Self(None); + } + GetProcAddress(module, c"SetThreadDpiAwarenessContext".as_ptr().cast()) + }; + let Some(set) = set else { + return Self(None); + }; + // SAFETY: 签名见 winuser.h,DPI_AWARENESS_CONTEXT 是一个指针大小的句柄 + let set = unsafe { + std::mem::transmute:: isize, SetContext>(set) + }; + // SAFETY: 传的是系统定义的伪句柄;返回原来的设置,失败是 0 + let before = unsafe { set(PER_MONITOR_V2) }; + Self((before != 0).then_some((set, before))) + } + } + + impl Drop for SharpText { + fn drop(&mut self) { + if let Some((set, before)) = self.0 { + // SAFETY: 还原成 `on` 时拿到的那个设置 + unsafe { set(before) }; + } + } + } + pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> Option { - let text = wide(&format!("{instruction}\n\n{content}")); + let Some(dialog) = task_dialog() else { + return message_box(title, instruction, content, buttons); + }; + let _sharp = SharpText::on(); + let title_w = wide(title); + let instruction_w = wide(instruction); + let content_w = wide(content); + let labels: Vec> = buttons.iter().map(|b| wide(b)).collect(); + let specs: Vec = labels + .iter() + .zip(FIRST_BUTTON..) + .map(|(label, id)| TASKDIALOG_BUTTON { + nButtonID: id, + pszButtonText: label.as_ptr(), + }) + .collect(); + let config = TASKDIALOGCONFIG { + cbSize: std::mem::size_of::() as u32, + // 标题栏的关闭按钮和 Esc 能关掉它,算「没选」 + dwFlags: TDF_ALLOW_DIALOG_CANCELLATION, + pszWindowTitle: title_w.as_ptr(), + pszMainInstruction: instruction_w.as_ptr(), + pszContent: if content.is_empty() { + std::ptr::null() + } else { + content_w.as_ptr() + }, + cButtons: specs.len() as u32, + pButtons: specs.as_ptr(), + nDefaultButton: FIRST_BUTTON, + ..Default::default() + }; + let mut pressed = 0; + // SAFETY: 配置里的每个指针都指向上面那几个在调用期间一直活着的缓冲区 + let hr = unsafe { + dialog( + &config, + &mut pressed, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if hr < 0 { + tracing::warn!(hr, "系统对话框没能弹出来,换成简单的那一种"); + return message_box(title, instruction, content, buttons); + } + usize::try_from(pressed - FIRST_BUTTON) + .ok() + .filter(|&i| i < buttons.len()) + } + + /// 等待对话框唯一的那个按钮。它一直是灰的,只在 `work` 结束时由这里按下去 + const WAIT_BUTTON: i32 = FIRST_BUTTON; + + pub fn wait( + title: &str, + instruction: &str, + content: &str, + work: impl FnOnce() -> T + Send, + ) -> T { + let Some(dialog) = task_dialog() else { + return work(); + }; + let done = AtomicBool::new(false); + std::thread::scope(|scope| { + let worker = scope.spawn(|| { + // panic 也要放下这个记号,否则对话框永远等下去 + struct Done<'a>(&'a AtomicBool); + impl Drop for Done<'_> { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let _done = Done(&done); + work() + }); + + let _sharp = SharpText::on(); + let title_w = wide(title); + let instruction_w = wide(instruction); + let content_w = wide(content); + let cancel = wide(tr!("取消", "Cancel")); + let button = TASKDIALOG_BUTTON { + nButtonID: WAIT_BUTTON, + pszButtonText: cancel.as_ptr(), + }; + let config = TASKDIALOGCONFIG { + cbSize: std::mem::size_of::() as u32, + // 不加 TDF_ALLOW_DIALOG_CANCELLATION:没有「取消」类的系统按钮时, + // 关闭按钮、Esc、Alt+F4 都不管用 + dwFlags: TDF_SHOW_MARQUEE_PROGRESS_BAR | TDF_CALLBACK_TIMER, + pszWindowTitle: title_w.as_ptr(), + pszMainInstruction: instruction_w.as_ptr(), + pszContent: if content.is_empty() { + std::ptr::null() + } else { + content_w.as_ptr() + }, + cButtons: 1, + pButtons: &button, + nDefaultButton: WAIT_BUTTON, + pfCallback: Some(waiting), + lpCallbackData: &done as *const AtomicBool as isize, + ..Default::default() + }; + // SAFETY: 配置里的指针都指向在调用期间活着的缓冲区;回调拿到的是 `done` 的 + // 地址,而对话框关掉之前这个函数不会返回 + let hr = unsafe { + dialog( + &config, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if hr < 0 { + tracing::warn!(hr, "等待的对话框没能弹出来,不显示,照样等"); + } + match worker.join() { + Ok(out) => out, + Err(panic) => std::panic::resume_unwind(panic), + } + }) + } + + /// 等待对话框的回调,跑在对话框的线程上。 + /// + /// 系统每隔约 200 毫秒叫一次 `TDN_TIMER`,看到 `work` 结束了就把按钮按下去: + /// 对话框只能从它自己的线程上关,这样不用跨线程发消息找它 + unsafe extern "system" fn waiting( + hwnd: HWND, + msg: u32, + _: WPARAM, + _: LPARAM, + data: isize, + ) -> HRESULT { + // SAFETY: `data` 是 `wait` 里 `done` 的地址,对话框关掉之前它一直活着 + let done = unsafe { &*(data as *const AtomicBool) }; + let finished = done.load(Ordering::SeqCst); + match msg as i32 { + TDN_CREATED => { + // SAFETY: 发给对话框自己的窗口,在它自己的线程上 + unsafe { + SendMessageW(hwnd, TDM_SET_PROGRESS_BAR_MARQUEE as u32, 1, 0); + SendMessageW(hwnd, TDM_ENABLE_BUTTON as u32, WAIT_BUTTON as usize, 0); + } + S_OK + } + TDN_TIMER if finished => { + // 灰的按钮按不下去:先点亮,再排一个「按下」到消息队列里 + // SAFETY: 同上 + unsafe { + SendMessageW(hwnd, TDM_ENABLE_BUTTON as u32, WAIT_BUTTON as usize, 1); + PostMessageW(hwnd, TDM_CLICK_BUTTON as u32, WAIT_BUTTON as usize, 0); + } + S_OK + } + // 没等完的时候,不管从哪来的「按下」都不让它关 + TDN_BUTTON_CLICKED if !finished => S_FALSE, + _ => S_OK, + } + } + + /// 取不到 `TaskDialogIndirect` 时的退路:按钮上的字由系统定,最多两个 —— + /// 第一个对「确定」,第二个对「取消」 + fn message_box( + title: &str, + instruction: &str, + content: &str, + buttons: &[&str], + ) -> Option { + let text = if content.is_empty() { + wide(instruction) + } else { + wide(&format!("{instruction}\n\n{content}")) + }; let caption = wide(title); let kind = if buttons.len() >= 2 { MB_OKCANCEL @@ -48,7 +347,7 @@ mod imp { ) }; match r { - IDOK => Some(0), + IDOK if !buttons.is_empty() => Some(0), IDCANCEL if buttons.len() >= 2 => Some(1), _ => None, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3e8b20fa..02c16528 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -343,8 +343,8 @@ pub fn run() { } else { Box::new(notices::SystemSink::new(handle.clone())) }; - // Windows 上「装好的」是指开始菜单里有带 AUMID 的快捷方式,见 `notices::windows`。 - // 点开走协议激活(下面的 `on_open_url`),不用在这里接回调 + // Windows 上看这次启动有没有登记上通知的 AUMID(上面的 `winreg::claim`),见 + // `notices::windows`。点开走协议激活(下面的 `on_open_url`),不用在这里接回调 #[cfg(windows)] let system: Box = if notices::windows::available(&handle) { Box::new(notices::windows::NativeSink::new(handle.clone())) diff --git a/src-tauri/src/notices/sink.rs b/src-tauri/src/notices/sink.rs index 43d44d6a..931e26ad 100644 --- a/src-tauri/src/notices/sink.rs +++ b/src-tauri/src/notices/sink.rs @@ -69,7 +69,7 @@ impl Sink for AppSink { /// D-Bus 不要求发送方是装好的应用,而这个插件在 Linux 上走的也是同一条总线, /// 总线不通时它一样发不出去)。这个插件在桌面端只能「发出即不管」—— 不能按 id /// 原地更新、不能撤回、点了没有回调,所以 `update` 和 `withdraw` 是空的。留着它是给 -/// `tauri dev`(macOS 上不在应用包里,Windows 上没有带 AUMID 的开始菜单快捷方式) +/// `tauri dev`(macOS 上不在应用包里,Windows 上开发构建不登记通知的 AUMID) /// 和还没有原生实现的平台用。 pub struct SystemSink { app: tauri::AppHandle, diff --git a/src-tauri/src/notices/windows/mod.rs b/src-tauri/src/notices/windows/mod.rs index d0c29a3b..2a9fe38c 100644 --- a/src-tauri/src/notices/windows/mod.rs +++ b/src-tauri/src/notices/windows/mod.rs @@ -13,15 +13,19 @@ //! | 点开 | delegate 回调 | 协议激活 `thinkwatch://notice/<键>` | //! | 窗口在前台 | delegate 只给 `List` | `SuppressPopup`:直接进操作中心,不弹横幅 | //! -//! # 前提:AUMID +//! # 前提:AUMID 的登记 //! -//! 未打包的应用发 toast,要一个开始菜单快捷方式带着 `System.AppUserModel.ID`, -//! 值等于发 toast 时给的 AUMID。**对不上是静默失败** —— 调用全都成功,通知就是不出现。 -//! Tauri 的 NSIS 模板建快捷方式时把它设成 bundle identifier(`SetLnkAppUserModelId`), -//! 所以这里的 AUMID 就是 `tauri.conf.json` 的 `identifier`。 +//! 未打包的应用发 toast 要给一个 AUMID,系统按它找通知上显示的名字和图标。这里在当前 +//! 用户名下登记:`HKCU\Software\Classes\AppUserModelId\` 的 `DisplayName` 和 +//! `IconUri`(数据目录里的一张 png,不能是 exe),由 `winreg::claim` 每次启动时写。 +//! **不靠开始菜单快捷方式**:绿色版没有快捷方式。Windows App SDK 和社区工具包对未打包 +//! 应用也是这么做的。没登记的 AUMID 一样弹得出来,只是名字是原始的 ID、没有图标。 //! -//! `tauri dev` 那种没装过的没有快捷方式,[`available`] 探不到就退回 `SystemSink` —— -//! 那个插件在开发时借 PowerShell 的 AUMID,至少弹得出来。 +//! 安装版和绿色版各用各的 AUMID(见 [`aumid`]):两份是两套独立的设置,通知各进各的 +//! 分组,清理时也只删自己那一份的登记。 +//! +//! 开发构建不登记(不碰注册表),[`available`] 说不能用,退回 `SystemSink` —— 那个插件 +//! 在开发时借 PowerShell 的 AUMID,至少弹得出来。 //! //! 真正调 WinRT 的那几行在 [`toast`],这里只是把它接到总线上。下面这些纯函数**哪个 //! 平台都编**,测试在 macOS 的 CI 上也跑。 @@ -32,6 +36,18 @@ pub mod toast; use super::NOTICE_URL; use super::sink::thread_of; +/// 发通知用的 AUMID:安装版是应用标识,绿色版在后面加 `.portable`。 +/// +/// **两份不共用一个**:数据、设置互不相干,通知中心里也该是两组 —— 不然撤回、原地更新 +/// 按 tag 找到的可能是另一份发的那一条,清理时删登记也会删掉另一份的名字和图标 +pub fn aumid(portable: bool) -> String { + if portable { + format!("{}.portable", crate::winreg::IDENTIFIER) + } else { + crate::winreg::IDENTIFIER.to_string() + } +} + /// 键 → tag。**必须哈希**:tag 最长 64 个字符(老一些的系统上只有 16),而键是 /// `upstream:<名字>`,名字是用户起的、可以任意长、多半是中文。 /// @@ -74,8 +90,8 @@ pub fn launch_url(key: &str) -> String { /// 初值和后来的更新都从 `NotificationData` 给 —— 要用 `Update` 原地改,只能这样写。 /// 顺带,用户起的上游名字从头到尾没有机会被当成 XML 解析。 /// -/// 没有 `appLogoOverride` 图标:安装目录里没有单独的 png,toast 的标题栏本来就用 -/// 快捷方式(也就是 exe)的图标 +/// 没有 `appLogoOverride` 图标:toast 标题栏上的图标来自 AUMID 登记里的 `IconUri` +/// (见模块说明),每一条都一样,不必在 XML 里再给 pub fn toast_xml(key: &str) -> String { format!( concat!( @@ -114,24 +130,23 @@ mod native { use super::super::sink::counted_title; use super::super::{Notice, Sink}; - use super::{group_of, tag_of, toast, toast_xml}; + use super::{aumid, group_of, tag_of, toast, toast_xml}; - /// 能不能用:装过的才行(见模块说明里的 AUMID) - pub fn available(app: &tauri::AppHandle) -> bool { - let installed = crate::update::kind() == crate::update::Install::Standalone; - toast::available(installed, &app.package_info().name) + /// 能不能用:这次启动登记上了才行(见模块说明里的 AUMID)。开发构建不登记 + pub fn available(_app: &tauri::AppHandle) -> bool { + crate::winreg::notifications_registered() } /// Windows 原生的系统通知 pub struct NativeSink { app: tauri::AppHandle, - /// 和开始菜单快捷方式上的那一个是同一个值:bundle identifier + /// 这一份登记的那一个,见 [`aumid`] aumid: String, } impl NativeSink { pub fn new(app: tauri::AppHandle) -> Self { - let aumid = app.config().identifier.clone(); + let aumid = aumid(crate::portable::is_portable()); Self { app, aumid } } @@ -269,6 +284,13 @@ mod tests { assert!(!xml.contains("")); } + /// 安装版就是应用标识(开始菜单快捷方式上写的也是它),绿色版另起一个 + #[test] + fn each_kind_has_its_own_aumid() { + assert_eq!(aumid(false), "app.thinkwatch.lite"); + assert_eq!(aumid(true), "app.thinkwatch.lite.portable"); + } + #[test] fn escape_covers_all_five() { assert_eq!(escape(r#"a&bd"e'f"#), "a&b<c>d"e'f"); diff --git a/src-tauri/src/notices/windows/toast.rs b/src-tauri/src/notices/windows/toast.rs index 5d4f40f8..cee5c0e7 100644 --- a/src-tauri/src/notices/windows/toast.rs +++ b/src-tauri/src/notices/windows/toast.rs @@ -5,8 +5,10 @@ //! 有 C 依赖,交叉编译不了),也能在真机上换一个 AUMID 单独验证。 //! //! 不用初始化 COM:`windows` crate 取激活工厂时发现线程没进单元,会自己进隐式 MTA。 - -use std::path::Path; +//! +//! 给哪个 AUMID 发都行,**登记是另一回事**:名字和图标靠注册表里的那一项(见 +//! `winreg`),没登记的 AUMID 照样弹得出来,只是名字是原始的 ID、没有图标。别调 +//! `ToastNotifier.Setting` 去探「能不能发」:一个 AUMID 第一次发通知之前它报 0x80070490。 use ::windows::Data::Xml::Dom::XmlDocument; use ::windows::UI::Notifications::{ @@ -14,26 +16,6 @@ use ::windows::UI::Notifications::{ }; use ::windows::core::{HSTRING, Result}; -/// 能不能用:**是装过的那一份,而且开始菜单里有它的快捷方式**。 -/// -/// 快捷方式是 AUMID 唯一的来处(NSIS 建它的时候写上去),没有它 toast 静默不出现。 -/// 只看快捷方式还不够:机器上装过一份、又在跑 `tauri dev` 的时候,快捷方式在,但点开 -/// 通知拉起的是装好的那一份。所以还要当前这个 exe 就是装好的那一份 —— 这个判断 -/// 自更新也要,由调用方用 `update::kind()` 回答后传进来,两处不各写一遍 -pub fn available(installed: bool, product_name: &str) -> bool { - let lnk = format!("{product_name}.lnk"); - let shortcut = ["ProgramData", "APPDATA"] - .into_iter() - .filter_map(std::env::var_os) - .map(|base| { - Path::new(&base) - .join(r"Microsoft\Windows\Start Menu\Programs") - .join(&lnk) - }) - .any(|p| p.is_file()); - installed && shortcut -} - /// 标题和正文:XML 里的 `{title}`、`{body}` 从这里取值 fn data(title: &str, body: &str) -> Result { let data = NotificationData::new()?; @@ -89,3 +71,9 @@ pub fn withdraw(aumid: &str, tag: &str, group: &str) -> Result<()> { &HSTRING::from(aumid), ) } + +/// 清掉通知中心里这个 AUMID 的全部通知。卸载时用:登记删掉之后,留下的那几条只剩 +/// 一个原始的 ID,点开也落不到任何地方 +pub fn clear(aumid: &str) -> Result<()> { + ToastNotificationManager::History()?.ClearWithId(&HSTRING::from(aumid)) +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 25346d3f..c54bcac4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -127,22 +127,19 @@ pub fn autostart_enabled(app: tauri::AppHandle) -> bool { if !autostart::allowed_in_this_build() { return false; } + // Windows 上是全机共用的那一条 Run 项,指向哪一份都算开着;被「设置 → 应用 → + // 启动」关掉的不算。见 `winreg` 和 `autostart` 里 `approved_from_bytes` 上面那段 + #[cfg(windows)] + { + let _ = app; + crate::winreg::autostart_on() + } // Linux 上的 `is_enabled` 自己就认桌面「关掉了」的那两种写法,见 // `autostart::linux::disabled_by_desktop` - if !matches!(autostart::launcher(&app).is_enabled(), Ok(true)) { - return false; - } - // **插件说「开着」还不够。**Windows 的「设置 → 应用 → 启动」里关掉之后, - // 我们在 `Run` 下那一项原封不动,而插件只看那一项在不在 —— 见 - // `autostart::disabled_by_windows`。 - // - // 键名问 `package_info().name` 要,**和插件写进去时用的是同一个来源** - // (它就是这么取的),不是照着猜一个。 - #[cfg(windows)] - if autostart::disabled_by_windows(&app.package_info().name) == Some(true) { - return false; + #[cfg(not(windows))] + { + matches!(autostart::launcher(&app).is_enabled(), Ok(true)) } - true } /// 开或关开机自启。 @@ -159,29 +156,38 @@ pub fn set_autostart(app: tauri::AppHandle, on: bool) -> Out { ) .into()); } - // **插件不建目录。**它把 plist 直接写进 `~/Library/LaunchAgents/`, - // 而那个目录在一台从没注册过登录项的 Mac 上根本不存在 —— 写文件 - // 得到的是 `No such file or directory (os error 2)`,一句既不说 - // 哪个文件、也不说该怎么办的话。 - // - // 这不是边角情况:全新系统、新建用户、以及任何 HOME 被换掉的运行 - // 环境都会撞上。所以自己先建。(只有 macOS 写 plist;Linux 那份自己建目录) - #[cfg(target_os = "macos")] - if on - && let Some(plist) = autostart::plist_path(&app.config().identifier) - && let Some(dir) = plist.parent() + // Windows 上打开 = 写一条指向自己的,关掉 = 删掉那一条(不管它指向哪一份),见 `winreg` + #[cfg(windows)] { - std::fs::create_dir_all(dir).map_err(|e| { - tr!( - format!("无法创建目录 {}:{e}", dir.display()), - format!("The directory {} could not be created: {e}", dir.display()) - ) - })?; + let _ = app; + crate::winreg::set_autostart(on).map_err(Into::into) + } + #[cfg(not(windows))] + { + // **插件不建目录。**它把 plist 直接写进 `~/Library/LaunchAgents/`, + // 而那个目录在一台从没注册过登录项的 Mac 上根本不存在 —— 写文件 + // 得到的是 `No such file or directory (os error 2)`,一句既不说 + // 哪个文件、也不说该怎么办的话。 + // + // 这不是边角情况:全新系统、新建用户、以及任何 HOME 被换掉的运行 + // 环境都会撞上。所以自己先建。(只有 macOS 写 plist;Linux 那份自己建目录) + #[cfg(target_os = "macos")] + if on + && let Some(plist) = autostart::plist_path(&app.config().identifier) + && let Some(dir) = plist.parent() + { + std::fs::create_dir_all(dir).map_err(|e| { + tr!( + format!("无法创建目录 {}:{e}", dir.display()), + format!("The directory {} could not be created: {e}", dir.display()) + ) + })?; + } + let mgr = autostart::launcher(&app); + let r = if on { mgr.enable() } else { mgr.disable() }; + r.map_err(|e| format!("{e}"))?; + Ok(matches!(mgr.is_enabled(), Ok(true))) } - let mgr = autostart::launcher(&app); - let r = if on { mgr.enable() } else { mgr.disable() }; - r.map_err(|e| format!("{e}"))?; - Ok(matches!(mgr.is_enabled(), Ok(true))) } /// 提醒现在是哪一档 @@ -301,6 +307,11 @@ pub(crate) fn check_autostart_path(app: &tauri::AppHandle) { } } +/// Windows 上 Run 里那一项在启动时就改成指向正在运行的这个 exe 了(`winreg::claim`), +/// 这里没有要补的 +#[cfg(windows)] +pub(crate) fn check_autostart_path(_app: &tauri::AppHandle) {} + /// plist 里的路径还指着现在这个二进制吗。 /// /// 不一致就重新注册一次。这件事插件不做,而它的失败模式是**静默的**: @@ -309,7 +320,7 @@ pub(crate) fn check_autostart_path(app: &tauri::AppHandle) { /// **开发构建不碰**,和 Linux 那一份一样:装好的应用开着自启的机器上跑一次 /// `cargo tauri dev`,这里会觉得「路径不对」,把 plist 改指向 `target/debug/…` /// —— 之后每次开机拉起的是一个开发构建,或者一个已经被 `cargo clean` 掉的文件 -#[cfg(not(target_os = "linux"))] +#[cfg(target_os = "macos")] pub(crate) fn check_autostart_path(app: &tauri::AppHandle) { use tauri_plugin_autostart::ManagerExt; if !autostart::allowed_in_this_build() { diff --git a/src-tauri/src/single.rs b/src-tauri/src/single.rs index 798a99be..39e8d716 100644 --- a/src-tauri/src/single.rs +++ b/src-tauri/src/single.rs @@ -3,12 +3,590 @@ //! 安装版和绿色版用的是同一把锁(单实例插件按应用标识起名),所以它们也互斥。 //! 插件只会把第二个进程的参数转交给第一个、然后让第二个退出;这里补上插件 //! 不做的那部分:开的是另一个位置的程序时说明情况,并且可以切换过去。 +//! +//! # 切换怎么走 +//! +//! 1. 新开的这一个([`precheck`],Tauri 起来之前)看见锁被占着、占着它的程序在别的位置, +//! 问一句;选了切换,就置位运行中那一个建的命名事件([`handoff_name`])。 +//! 2. 运行中的那一个([`listen`])收到事件:等手上的请求结束(最多三分钟,和装更新时同一套)、 +//! 停掉 core、退出。退出时插件放开那把锁。 +//! 3. 新开的这一个一直等到锁被放开([`WAIT_LIMIT`]),**关掉自己手里的锁句柄**,接着 +//! 正常启动 —— 插件随后建锁时看到的是一把没人用的新锁。 +//! +//! 锁和窗口的名字以 `tauri-plugin-single-instance` 2.4.5 的 `platform_impl/windows.rs` +//! 为准:锁 `<标识>-sim`、接收转交的窗口类 `<标识>-sic`、窗口名 `<标识>-siw` +//! (没开 `semver` 特性,名字里不带版本号)。插件升级时要核对一遍。 +//! +//! 判断要不要问的那一步是纯函数([`verdict`]),哪个平台都测;剩下的只有 Windows 有。 + +// 名字、文案、判断在每个平台都编译,测试因此在哪都跑;真正调它们的只有 Windows +#![cfg_attr(not(windows), allow(dead_code))] + +use crate::winreg::{IDENTIFIER, SCHEME, same_path}; + +/// 等运行中的程序退出,最多等多久。它那边等请求结束最多三分钟 +/// (`updater::DRAIN_LIMIT`),再加上停 core 的几秒 +pub(crate) const WAIT_LIMIT: std::time::Duration = std::time::Duration::from_secs(200); + +/// 单实例插件建的锁 +pub(crate) fn mutex_name() -> String { + format!("{IDENTIFIER}-sim") +} + +/// 单实例插件接收转交的那个隐藏窗口:类名、窗口名 +pub(crate) fn window_names() -> (String, String) { + (format!("{IDENTIFIER}-sic"), format!("{IDENTIFIER}-siw")) +} + +/// 「请退出、让给我」的命名事件,由运行中的那一个建。**会话内可见**(`Local\`),和插件 +/// 那把锁在同一个范围:别的登录用户的实例不归这里管 +pub(crate) fn handoff_name() -> String { + format!(r"Local\{IDENTIFIER}-handoff") +} + +/// 锁被占着的时候怎么办 +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Verdict { + /// 交给插件:它把参数转给运行中的那个、把它的窗口叫到前面,本进程退出 + Forward, + /// 运行中的程序在另一个位置(这里是它的路径):说明情况,问要不要切换 + Ask(String), +} + +/// 锁被占着时,要不要问一句。 +/// +/// - 带着 `thinkwatch://` 链接或开机标记的:**不问**。链接是浏览器、通知拉起来的,要交给 +/// 运行中的那一个处理;开机拉起来的不该在登录时弹框。 +/// - 运行中的程序就是这一个(同一个路径),或者查不出它在哪:不问,插件把它的窗口叫到前面。 +/// - 在别的位置:问。 +/// +/// `args` 不含程序名;两个路径不碰磁盘地比(见 `winreg::same_path`),要展开的由调用方先展开 +pub(crate) fn verdict>( + args: &[S], + running: Option<&str>, + current: Option<&str>, +) -> Verdict { + let link = args.iter().any(|a| { + a.as_ref() + .split_once("://") + .is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case(SCHEME)) + }); + if link || crate::autostart::launched_by_autostart(args) { + return Verdict::Forward; + } + match (running, current) { + (Some(running), Some(current)) if !same_path(running, current) => { + Verdict::Ask(running.to_string()) + } + _ => Verdict::Forward, + } +} + +/// 对话框的标题 +const TITLE: &str = "ThinkWatch Lite"; + +/// 这几个对话框共用的主句 +pub(crate) fn running_text() -> &'static str { + tr!( + "ThinkWatch Lite 已在运行", + "ThinkWatch Lite is already running" + ) +} + +/// 运行中的程序在别处时问的那一句:主句、正文、两个按钮(第一个是切换) +pub(crate) fn ask_text(running: &str) -> (&'static str, String, [&'static str; 2]) { + ( + running_text(), + tr!( + format!("运行中的程序位于:{running}。同一时间只能运行一个 ThinkWatch Lite。"), + format!( + "The running program is at {running}. Only one ThinkWatch Lite can run at a time." + ) + ), + [ + tr!( + "停止运行中的程序并启动此程序", + "Stop the running program and start this one" + ), + tr!("取消", "Cancel"), + ], + ) +} + +/// 等运行中的程序退出时的主句 +pub(crate) fn waiting_text() -> &'static str { + tr!( + "正在等待进行中的请求完成…", + "Waiting for requests in progress to finish…" + ) +} + +/// 运行中的程序是不支持切换的旧版本(没建那个事件) +pub(crate) fn unsupported_text() -> &'static str { + tr!( + "运行中的程序不支持切换,请先退出该程序。", + "The running program does not support switching. Quit it first." + ) +} + +/// 等到上限它还没退 +pub(crate) fn timeout_text() -> &'static str { + tr!( + "运行中的程序未能退出。", + "The running program did not quit." + ) +} /// 程序真正启动之前调用。已经有一个实例在运行时,按情况静默转交、提示后退出, /// 或者等运行中的那个退出后接着启动。没有在运行的实例、或者不是 Windows,什么都不做。 -pub fn precheck() {} +pub fn precheck() { + #[cfg(windows)] + imp::precheck(); +} /// 运行中的实例这一侧:等「切换」的请求,收到后等进行中的请求结束、停掉 core、退出。 +/// 不是 Windows 什么都不做。 pub fn listen(app: &tauri::AppHandle) { + #[cfg(windows)] + imp::listen(app); + #[cfg(not(windows))] let _ = app; } + +#[cfg(windows)] +mod imp { + use std::time::{Duration, Instant}; + + use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_ABANDONED, WAIT_OBJECT_0}; + use windows_sys::Win32::System::Threading::{ + CreateEventW, EVENT_MODIFY_STATE, INFINITE, OpenEventW, OpenMutexW, OpenProcess, + PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + ReleaseMutex, SYNCHRONIZATION_SYNCHRONIZE, SetEvent, WaitForSingleObject, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::{FindWindowW, GetWindowThreadProcessId}; + + use super::*; + use crate::dialog; + use crate::winreg::plain_path; + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + /// 一个内核对象的句柄,离开作用域就关掉 + pub(super) struct Handle(HANDLE); + + // SAFETY: 内核对象的句柄是进程范围的,哪个线程用、哪个线程关都行 + unsafe impl Send for Handle {} + + impl Handle { + fn new(h: HANDLE) -> Option { + (!h.is_null()).then_some(Self(h)) + } + } + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: 这个句柄是这里独有的,只关这一次 + unsafe { CloseHandle(self.0) }; + } + } + + pub fn precheck() { + // 锁不在:没有在运行的实例 + let Some(lock) = open_mutex(&mutex_name()) else { + return; + }; + let args: Vec = std::env::args_os() + .skip(1) + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let running = running_exe().map(|p| canonical(&p)); + let current = std::env::current_exe() + .ok() + .map(|p| canonical(&p.to_string_lossy())); + let Verdict::Ask(running) = verdict(&args, running.as_deref(), current.as_deref()) else { + return; + }; + let (instruction, content, buttons) = ask_text(&running); + if dialog::show(TITLE, instruction, &content, &buttons) != Some(0) { + // 取消、关掉对话框:照插件的老样子,运行中的那个到前面来,这一个退出 + return; + } + if !signal(&handoff_name(), Duration::from_secs(2)) { + tell(unsupported_text()); + std::process::exit(1); + } + tracing::info!(%running, "已请运行中的程序退出,等它放开单实例的锁"); + let released = dialog::wait(TITLE, waiting_text(), "", move || { + wait_released(lock, WAIT_LIMIT) + }); + if !released { + tell(timeout_text()); + std::process::exit(1); + } + tracing::info!("运行中的程序已退出,接着启动"); + } + + /// 说一句话,只有一个「确定」 + fn tell(content: &str) { + dialog::show(TITLE, running_text(), content, &[tr!("确定", "OK")]); + } + + /// 打开一把已经在的命名锁(只要能等它的权限);不在是 `None` + pub(super) fn open_mutex(name: &str) -> Option { + let name = wide(name); + // SAFETY: 名字以 0 结尾 + Handle::new(unsafe { OpenMutexW(SYNCHRONIZATION_SYNCHRONIZE, 0, name.as_ptr()) }) + } + + /// 等这把锁被放开,至多 `limit`。放开了是 `true`。 + /// + /// 等到了,这个线程就**拿到了**这把锁:先还回去,再关句柄 —— 两样都在返回之前做完, + /// 而且在等它的同一个线程上(锁属于线程)。不然插件随后 `CreateMutexW` 时这把锁还在、 + /// 还被这里占着,它会以为另有一个实例在跑。对方没放就退出了(崩溃、被强杀)是 + /// `WAIT_ABANDONED`,也算放开了 + pub(super) fn wait_released(lock: Handle, limit: Duration) -> bool { + let ms = u32::try_from(limit.as_millis()).unwrap_or(u32::MAX - 1); + // SAFETY: 句柄有效,有 SYNCHRONIZE 权限 + let r = unsafe { WaitForSingleObject(lock.0, ms) }; + let released = r == WAIT_OBJECT_0 || r == WAIT_ABANDONED; + if released { + // SAFETY: 这个线程刚等到它,是它的主人 + unsafe { ReleaseMutex(lock.0) }; + } + drop(lock); + released + } + + /// 置位运行中那一个建的事件。它刚启动、还没建好的话,等一小会儿再试(`patience`); + /// 一直打不开是 `false`:那是不支持切换的旧版本 + pub(super) fn signal(name: &str, patience: Duration) -> bool { + let name = wide(name); + let deadline = Instant::now() + patience; + loop { + // SAFETY: 名字以 0 结尾 + let event = Handle::new(unsafe { OpenEventW(EVENT_MODIFY_STATE, 0, name.as_ptr()) }); + if let Some(event) = event { + // SAFETY: 句柄有效,有 EVENT_MODIFY_STATE 权限 + return unsafe { SetEvent(event.0) } != 0; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(100)); + } + } + + /// 运行中的那个实例的 exe:插件那个隐藏窗口 → 进程号 → 进程的映像路径 + fn running_exe() -> Option { + let (class, window) = window_names(); + let (class, window) = (wide(&class), wide(&window)); + // SAFETY: 两个名字都以 0 结尾 + let hwnd = unsafe { FindWindowW(class.as_ptr(), window.as_ptr()) }; + if hwnd.is_null() { + return None; + } + let mut pid = 0; + // SAFETY: 窗口句柄来自上面;`pid` 是一个可写的 u32 + unsafe { GetWindowThreadProcessId(hwnd, &mut pid) }; + (pid != 0).then(|| process_image(pid)).flatten() + } + + /// 一个进程的 exe 路径。**只要最低一档的查询权限**:以管理员身份运行的那一个也查得到 + pub(super) fn process_image(pid: u32) -> Option { + // SAFETY: 只是打开一个进程句柄;失败是空句柄 + let process = + Handle::new(unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) })?; + let mut buf = vec![0u16; 32 * 1024]; + let mut len = buf.len() as u32; + // SAFETY: 缓冲区和它的长度(以字符计)对得上 + let ok = unsafe { + QueryFullProcessImageNameW(process.0, PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len) + }; + (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize])) + } + + /// 比路径之前先展开(短文件名、链接、`subst`);展开不了就用原样 + fn canonical(p: &str) -> String { + std::fs::canonicalize(p) + .map(|c| plain_path(&c.to_string_lossy())) + .unwrap_or_else(|_| p.to_string()) + } + + pub fn listen(app: &tauri::AppHandle) { + let name = wide(&handoff_name()); + // 自动复位、一开始没置位 + // SAFETY: 名字以 0 结尾;安全属性用默认的 + let Some(event) = + Handle::new(unsafe { CreateEventW(std::ptr::null(), 0, 0, name.as_ptr()) }) + else { + tracing::warn!("建不了切换用的事件,另一个位置的程序将无法切换过来"); + return; + }; + let app = app.clone(); + let spawned = std::thread::Builder::new() + .name("handoff".into()) + .spawn(move || { + // SAFETY: 句柄有效 + if unsafe { WaitForSingleObject(event.0, INFINITE) } != WAIT_OBJECT_0 { + return; + } + // **事件留着,直到进程退出**:这时又有一个新开的来请求,它打得开、置得上, + // 然后和第一个一样等锁,而不是被告知「不支持切换」 + std::mem::forget(event); + tracing::info!("另一个位置的程序请求切换:等进行中的请求结束后退出"); + tauri::async_runtime::spawn(step_aside(app)); + }); + if let Err(e) = spawned { + tracing::warn!("起不了等切换的线程:{e}"); + } + } + + /// 让位:等手上的请求结束(最多三分钟,和装更新时同一套)→ 记成用户自己退出的 → + /// 停掉 core、等它真的走 → 退出。 + /// + /// **core 要在退出之前停干净**:插件在退出的那一刻放开锁,新的那一个马上起来拉它自己的 + /// core,旧的还占着网关的端口就会撞上 + async fn step_aside(app: tauri::AppHandle) { + use tauri::Manager; + let sup = app + .try_state::() + .map(|st| st.supervisor.clone()); + if let Some(sup) = sup { + crate::updater::wait_for_quiet(sup.control(), |_| {}).await; + // 用户在另一个程序里选了「停止运行中的程序」:和菜单里点「退出」一样,退出时 + // 删掉 `.last-exit`,之后再打开这一份不当成更新之后的重开 + crate::updater::quitting_by_user(); + sup.stop_and_wait(Duration::from_secs(5)).await; + } + app.exit(0); + } + + /// 锁、事件、进程路径这几样只能在真的 Windows 上测。名字带上进程号,不和这台机器上 + /// 正在跑的 ThinkWatch Lite 撞 + #[cfg(test)] + mod tests { + use windows_sys::Win32::Foundation::{ERROR_ALREADY_EXISTS, GetLastError}; + use windows_sys::Win32::System::Threading::CreateMutexW; + + use super::*; + + fn unique(what: &str) -> String { + format!(r"Local\thinkwatch-test-{}-{what}", std::process::id()) + } + + /// 建一把锁并占着它,像插件那样 + fn create_owned(name: &str) -> (Handle, bool) { + let w = wide(name); + // SAFETY: 名字以 0 结尾 + let h = unsafe { CreateMutexW(std::ptr::null(), 1, w.as_ptr()) }; + // SAFETY: 紧跟在上一个调用之后 + let existed = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS; + (Handle::new(h).unwrap(), existed) + } + + /// 对方放开之后这里等得到;**等完之后锁已经没人拿着**:再建一把是新的 + #[test] + fn waiting_ends_when_the_owner_lets_go_and_leaves_no_trace() { + let name = unique("release"); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let owner = std::thread::spawn({ + let name = name.clone(); + move || { + let (h, existed) = create_owned(&name); + assert!(!existed); + ready_tx.send(()).unwrap(); + std::thread::sleep(Duration::from_millis(300)); + // SAFETY: 这个线程是它的主人 + unsafe { ReleaseMutex(h.0) }; + drop(h); + } + }); + ready_rx.recv().unwrap(); + let lock = open_mutex(&name).expect("锁在"); + assert!(wait_released(lock, Duration::from_secs(10))); + owner.join().unwrap(); + assert!(open_mutex(&name).is_none(), "等完之后不该还有句柄留着它"); + let (_h, existed) = create_owned(&name); + assert!(!existed, "插件随后建锁时应当是一把新锁"); + } + + /// 对方没放就走了(崩溃、强杀)也算放开 + #[test] + fn an_abandoned_lock_counts_as_released() { + let name = unique("abandon"); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let (go_tx, go_rx) = std::sync::mpsc::channel::<()>(); + let owner = std::thread::spawn({ + let name = name.clone(); + move || { + let (h, _) = create_owned(&name); + ready_tx.send(()).unwrap(); + go_rx.recv().unwrap(); + // 不放就关句柄、线程结束:锁成了无主的 + drop(h); + } + }); + ready_rx.recv().unwrap(); + let lock = open_mutex(&name).expect("锁在"); + go_tx.send(()).unwrap(); + assert!(wait_released(lock, Duration::from_secs(10))); + owner.join().unwrap(); + let (_h, existed) = create_owned(&name); + assert!(!existed); + } + + #[test] + fn waiting_gives_up_at_the_limit() { + let name = unique("timeout"); + let (_h, _) = create_owned(&name); + // 主人是另一个线程才等不到(同一个线程可以重入) + let lock = std::thread::spawn({ + let name = name.clone(); + move || { + let lock = open_mutex(&name).unwrap(); + wait_released(lock, Duration::from_millis(100)) + } + }); + assert!(!lock.join().unwrap()); + } + + /// 运行中的那一边建了事件,这一边置得上;没建(旧版本)就置不上 + #[test] + fn the_handoff_reaches_the_listener_or_reports_an_old_version() { + let name = unique("handoff"); + assert!(!signal(&name, Duration::from_millis(200))); + + let w = wide(&name); + // SAFETY: 同 `listen` + let event = + Handle::new(unsafe { CreateEventW(std::ptr::null(), 0, 0, w.as_ptr()) }).unwrap(); + assert!(signal(&name, Duration::from_millis(200))); + // SAFETY: 句柄有效 + assert_eq!(unsafe { WaitForSingleObject(event.0, 1000) }, WAIT_OBJECT_0); + } + + #[test] + fn the_image_path_of_a_process_is_its_exe() { + let me = process_image(std::process::id()).unwrap(); + let exe = std::env::current_exe().unwrap(); + assert!(same_path( + &canonical(&me), + &canonical(&exe.to_string_lossy()) + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HERE: &str = r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe"; + const THERE: &str = r"D:\Tools\ThinkWatch Lite\thinkwatch-lite.exe"; + + #[test] + fn another_location_is_asked_about() { + assert_eq!( + verdict::<&str>(&[], Some(THERE), Some(HERE)), + Verdict::Ask(THERE.to_string()) + ); + } + + /// 同一个程序再点一次:插件把窗口叫到前面,不问 + #[test] + fn the_same_program_is_forwarded() { + assert_eq!( + verdict::<&str>(&[], Some(HERE), Some(HERE)), + Verdict::Forward + ); + assert_eq!( + verdict::<&str>(&[], Some(&HERE.to_uppercase()), Some(HERE)), + Verdict::Forward + ); + assert_eq!( + verdict::<&str>(&[], Some(&format!(r"\\?\{HERE}")), Some(HERE)), + Verdict::Forward + ); + } + + /// 查不出运行中的在哪(窗口没找到、进程打不开),或者连自己在哪都不知道:不问 + #[test] + fn unknown_locations_are_forwarded() { + assert_eq!(verdict::<&str>(&[], None, Some(HERE)), Verdict::Forward); + assert_eq!(verdict::<&str>(&[], Some(THERE), None), Verdict::Forward); + } + + /// 链接和开机自启从来不问,哪怕程序在别处 + #[test] + fn links_and_login_launches_are_never_asked_about() { + for args in [ + vec!["thinkwatch://notice/core"], + vec!["ThinkWatch://import?url=x"], + vec!["--autostart"], + ] { + assert_eq!( + verdict(&args[..], Some(THERE), Some(HERE)), + Verdict::Forward, + "{args:?}" + ); + } + // 别的链接、别的参数照常问 + assert_eq!( + verdict( + &["https://example.com", "--hidden"], + Some(THERE), + Some(HERE) + ), + Verdict::Ask(THERE.to_string()) + ); + } + + #[test] + fn names_follow_the_plugin() { + assert_eq!(mutex_name(), "app.thinkwatch.lite-sim"); + assert_eq!( + window_names(), + ( + "app.thinkwatch.lite-sic".to_string(), + "app.thinkwatch.lite-siw".to_string() + ) + ); + assert_eq!(handoff_name(), r"Local\app.thinkwatch.lite-handoff"); + } + + /// 文案照定稿,中英两套 + #[test] + fn the_dialog_text_is_written_out_in_both_languages() { + use crate::i18n::{Lang, with_lang}; + let (instruction, content, buttons) = with_lang(Lang::Zh, || ask_text(THERE)); + assert_eq!(instruction, "ThinkWatch Lite 已在运行"); + assert_eq!( + content, + format!("运行中的程序位于:{THERE}。同一时间只能运行一个 ThinkWatch Lite。") + ); + assert_eq!(buttons, ["停止运行中的程序并启动此程序", "取消"]); + let (instruction, content, buttons) = with_lang(Lang::En, || ask_text(THERE)); + assert_eq!(instruction, "ThinkWatch Lite is already running"); + assert_eq!( + content, + format!( + "The running program is at {THERE}. Only one ThinkWatch Lite can run at a time." + ) + ); + assert_eq!( + buttons, + ["Stop the running program and start this one", "Cancel"] + ); + assert_eq!( + with_lang(Lang::Zh, waiting_text), + "正在等待进行中的请求完成…" + ); + assert_eq!( + with_lang(Lang::Zh, unsupported_text), + "运行中的程序不支持切换,请先退出该程序。" + ); + assert_eq!(with_lang(Lang::Zh, timeout_text), "运行中的程序未能退出。"); + } +} diff --git a/src-tauri/src/winreg.rs b/src-tauri/src/winreg.rs index 7b9a230a..3dea1fa2 100644 --- a/src-tauri/src/winreg.rs +++ b/src-tauri/src/winreg.rs @@ -2,17 +2,748 @@ //! //! **谁运行就听谁的。**安装版和绿色版同一时间只会有一个在运行,所以每次启动都把 //! 这几项改成指向正在运行的这个 exe;清理时只删指向自己的那几项。 +//! +//! - 链接:`HKCU\Software\Classes\thinkwatch`。它盖过安装程序写在 HKLM 的那一份;删掉 +//! HKCU 这一条,就回到 HKLM 的(安装版还在的话)。 +//! - 开机自启:`HKCU\…\Run` 里的一个值,值名是产品名(自启插件就是这么起名的,老版本 +//! 写下的那一条因此也认得)。**全机只有这一条**,开关是两份共用的:开着 = 这一项在 +//! (指向哪一份都算),而且没被 Windows 的「启动应用」关掉。 +//! - 系统通知:`HKCU\Software\Classes\AppUserModelId\` 的名字和图标,见 +//! `notices::windows`。安装版、绿色版各登记各的 AUMID。 +//! +//! **开发构建不碰注册表**(`autostart::allowed_in_this_build`):开发机上多半还装着一份, +//! 跑一次 `tauri dev` 不该把链接和自启改指向 `target\debug\…`。 +//! +//! 路径怎么比、命令行怎么拼、怎么认出「指向自己」,是下面几个纯函数,哪个平台都测; +//! 读写注册表的在 `imp` 里,只有 Windows 有,测试在 Windows 的 CI 上对着一个临时子键跑。 + +// 纯函数和常量在每个平台都编译,测试因此在哪都跑;真正调它们的只有 Windows +#![cfg_attr(not(windows), allow(dead_code))] use std::path::Path; +/// 应用标识,`tauri.conf.json` 的 `identifier`。单实例的锁和窗口、系统通知的 AUMID、 +/// 链接的说明都从它来。**有几处用在 Tauri 起来之前**(`single::precheck`、无界面的清理), +/// 那时没有 AppHandle 可问,所以抄一份;测试核对它和配置文件一致 +pub(crate) const IDENTIFIER: &str = "app.thinkwatch.lite"; + +/// 产品名,`tauri.conf.json` 的 `productName`:开机自启的值名(插件取的是 +/// `package_info().name`,也就是它)、系统通知上显示的名字。清理时同样没有 AppHandle +pub(crate) const PRODUCT_NAME: &str = "ThinkWatch Lite"; + +/// 链接的协议名 +pub(crate) const SCHEME: &str = "thinkwatch"; + +/// 系统通知的图标,写在数据目录里。**登记里只认图片文件**,填 exe 不行 +pub(crate) const NOTIFICATION_ICON: &str = "notification-icon.png"; + +/// 这次启动有没有登记上系统通知(见 [`notifications_registered`]) +static REGISTERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + /// 启动时调用:链接、开机自启(开着的话)、通知登记都改成指向正在运行的这个 exe。 -/// 不是 Windows 什么都不做。 +/// 不是 Windows、或者是开发构建,什么都不做。 +/// +/// **要在挑选系统通知的投递端之前调**:那里问 [`notifications_registered`]。 pub fn claim(app: &tauri::AppHandle) { let _ = app; + #[cfg(windows)] + if crate::autostart::allowed_in_this_build() { + match std::env::current_exe() { + Ok(exe) => { + let aumid = crate::notices::windows::aumid(crate::portable::is_portable()); + let ok = imp::claim( + &imp::Hive::user(), + &plain_path(&exe.to_string_lossy()), + &crate::data_dir(), + &aumid, + ); + REGISTERED.store(ok, std::sync::atomic::Ordering::SeqCst); + } + Err(e) => tracing::warn!("找不到自己的路径,链接、自启、通知登记都没动:{e}"), + } + } } -/// 清理:删掉指向 `exe` 的注册项(链接、开机自启、这一份的通知登记),每一项一行结果。 +/// 清理:删掉指向 `exe` 的注册项(链接、开机自启、这一份的通知登记和图标),做了什么、 +/// 哪一项没做成,各一行。**指向别处的不动**:那是另一份 ThinkWatch Lite 的。 +/// +/// 不是 Windows、或者是开发构建(它从不写注册表),什么都不做。 pub fn release_all(exe: &Path) -> Vec { + #[cfg(windows)] + if crate::autostart::allowed_in_this_build() { + let aumid = crate::notices::windows::aumid(crate::portable::is_portable()); + return imp::release_all( + &imp::Hive::user(), + &plain_path(&exe.to_string_lossy()), + &crate::data_dir(), + &aumid, + ); + } let _ = exe; Vec::new() } + +/// 系统通知登记上了没有。没登记上(开发构建、写不进注册表)就不用原生的通知, +/// 见 `notices::windows::available` +pub(crate) fn notifications_registered() -> bool { + REGISTERED.load(std::sync::atomic::Ordering::SeqCst) +} + +/// 开机自启现在开着吗:Run 里有这一项(指向哪一份都算),而且没被 Windows 关掉 +#[cfg(windows)] +pub(crate) fn autostart_on() -> bool { + imp::autostart_on(&imp::Hive::user()) +} + +/// 打开(写一条指向自己的)或关掉(删掉这一项)开机自启,返回之后实际的状态 +#[cfg(windows)] +pub(crate) fn set_autostart(on: bool) -> Result { + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + imp::set_autostart(&imp::Hive::user(), &plain_path(&exe.to_string_lossy()), on) +} + +/// 去掉 `\\?\` 这种前缀:`canonicalize` 给出的是带它的写法,注册表里、别的程序认的都是 +/// 不带的 +pub(crate) fn plain_path(p: &str) -> String { + if let Some(rest) = p.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = p.strip_prefix(r"\\?\") { + rest.to_string() + } else { + p.to_string() + } +} + +/// 两个路径是不是同一个文件:去掉 `\\?\`、斜杠统一成反斜杠、**不分大小写**(Windows 的 +/// 文件名不分)。不碰磁盘,链接、短文件名要由调用方先展开 +pub(crate) fn same_path(a: &str, b: &str) -> bool { + fn norm(p: &str) -> String { + plain_path(p.trim()).replace('/', "\\").to_lowercase() + } + norm(a) == norm(b) +} + +/// 一条命令行启动的是哪个程序。 +/// +/// 带引号的取引号里那一段。**不带引号的也得认**:老版本用自启插件写的那一条就不带, +/// 而路径里可以有空格(`C:\Program Files\…`)—— 取到后面紧跟空白或结尾的 `.exe` 为止, +/// 没有这样的 `.exe` 就取到第一个空白 +pub(crate) fn command_exe(cmd: &str) -> Option<&str> { + let cmd = cmd.trim(); + if let Some(rest) = cmd.strip_prefix('"') { + let end = rest.find('"').unwrap_or(rest.len()); + return Some(&rest[..end]).filter(|s| !s.is_empty()); + } + // 只把 ASCII 换成小写,字节位置不变,下标可以拿回原串用 + let lower = cmd.to_ascii_lowercase(); + let end = lower + .match_indices(".exe") + .map(|(i, _)| i + 4) + .find(|&i| cmd[i..].chars().next().is_none_or(char::is_whitespace)) + .or_else(|| cmd.find(char::is_whitespace)) + .unwrap_or(cmd.len()); + Some(&cmd[..end]).filter(|s| !s.is_empty()) +} + +/// 这条命令行启动的是不是 `exe` +pub(crate) fn command_runs(cmd: &str, exe: &str) -> bool { + command_exe(cmd).is_some_and(|c| same_path(c, exe)) +} + +/// 点开链接时执行的命令 +pub(crate) fn open_command(exe: &str) -> String { + format!("\"{exe}\" \"%1\"") +} + +/// 开机自启的命令:带上标记,启动时靠它知道是开机拉起来的(见 `autostart`) +pub(crate) fn autostart_command(exe: &str) -> String { + format!("\"{exe}\" {}", crate::autostart::AUTOSTART_FLAG) +} + +#[cfg(windows)] +mod imp { + use std::path::Path; + + use windows_registry::{CURRENT_USER, Key, Result, Type}; + + use super::{ + IDENTIFIER, NOTIFICATION_ICON, PRODUCT_NAME, SCHEME, autostart_command, command_exe, + command_runs, open_command, same_path, + }; + use crate::wire::UninstallStep; + + const RUN: &str = r"Microsoft\Windows\CurrentVersion\Run"; + /// 「设置 › 应用 › 启动」的开关记在这里,键名和 Run 里的值名相同 + const APPROVED: &str = r"Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"; + /// `StartupApproved` 里「开着」的那一串(见 `autostart::approved_from_bytes`), + /// 和自启插件写的相同 + const APPROVED_ON: [u8; 12] = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + /// 要动的那一块注册表:平时是 `HKCU\Software`,测试换成它下面的一个临时子键 + pub(super) struct Hive { + base: String, + } + + impl Hive { + pub(super) fn user() -> Self { + Self { + base: "Software".into(), + } + } + + #[cfg(test)] + pub(super) fn under(base: String) -> Self { + Self { base } + } + + fn path(&self, sub: &str) -> String { + format!(r"{}\{sub}", self.base) + } + + /// 只读打开;不存在是 `None` + fn open(&self, sub: &str) -> Result> { + absent_is_none(CURRENT_USER.open(self.path(sub))) + } + + /// 读写打开,**不存在不建**(删值之前用:不该为了删一个值建出一个键来) + fn open_rw(&self, sub: &str) -> Result> { + absent_is_none(CURRENT_USER.options().read().write().open(self.path(sub))) + } + + /// 读写打开,不存在就建 + fn create(&self, sub: &str) -> Result { + CURRENT_USER.create(self.path(sub)) + } + + fn remove_tree(&self, sub: &str) -> Result<()> { + CURRENT_USER.remove_tree(self.path(sub)) + } + } + + fn link_key() -> String { + format!(r"Classes\{SCHEME}") + } + + fn aumid_key(aumid: &str) -> String { + format!(r"Classes\AppUserModelId\{aumid}") + } + + /// 键或值不存在算 `None`,别的错照旧 + fn absent_is_none(r: Result) -> Result> { + match r { + Ok(v) => Ok(Some(v)), + // HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND) + Err(e) if e.code().0 == 0x8007_0002_u32 as i32 => Ok(None), + Err(e) => Err(e), + } + } + + /// 一个字符串值;键或值不存在是 `None` + fn read_string(hive: &Hive, sub: &str, name: &str) -> Result> { + let Some(key) = hive.open(sub)? else { + return Ok(None); + }; + absent_is_none(key.get_string(name)) + } + + /// 启动时的那一套。返回系统通知登记上了没有 + pub(super) fn claim(hive: &Hive, exe: &str, dir: &Path, aumid: &str) -> bool { + if let Err(e) = claim_link(hive, exe) { + tracing::warn!("未能把 thinkwatch:// 链接指向这个程序:{e}"); + } + if let Err(e) = follow_autostart(hive, exe) { + tracing::warn!("未能把开机自启指向这个程序:{e}"); + } + match register_notifications(hive, dir, aumid) { + Ok(()) => true, + Err(e) => { + tracing::warn!("未能登记系统通知,退回插件:{e}"); + false + } + } + } + + /// 链接写成指向 `exe`。和安装程序写在 HKLM 的那一份同一个写法,只是位置不同 + fn claim_link(hive: &Hive, exe: &str) -> Result<()> { + let key = hive.create(&link_key())?; + key.set_string("", format!("URL:{IDENTIFIER} protocol"))?; + key.set_string("URL Protocol", "")?; + key.create("DefaultIcon")? + .set_string("", format!("\"{exe}\",0"))?; + key.create(r"shell\open\command")? + .set_string("", open_command(exe)) + } + + /// 开机自启在的话,改成指向 `exe`(保留开机标记)。 + /// + /// **被 Windows 关掉的也改**:「启动应用」里那个开关按值名记,改了命令它照样关着; + /// 不改的话,用户之后在那里重新打开,拉起来的是另一份 + fn follow_autostart(hive: &Hive, exe: &str) -> Result<()> { + let Some(run) = hive.open_rw(RUN)? else { + return Ok(()); + }; + let Some(cmd) = absent_is_none(run.get_string(PRODUCT_NAME))? else { + return Ok(()); + }; + let want = autostart_command(exe); + if cmd != want { + run.set_string(PRODUCT_NAME, &want)?; + tracing::info!(from = %cmd, "开机自启改为指向这个程序"); + } + Ok(()) + } + + /// 系统通知的名字和图标。图标从二进制里写出来;写不出来也照样登记名字(只是没有图标) + fn register_notifications(hive: &Hive, dir: &Path, aumid: &str) -> Result<()> { + let icon = dir.join(NOTIFICATION_ICON); + let icon_ok = write_icon(&icon) + .inspect_err(|e| tracing::warn!(icon = %icon.display(), "通知图标写不出来:{e}")) + .is_ok(); + let key = hive.create(&aumid_key(aumid))?; + // 两个都写成 REG_EXPAND_SZ,和 Windows App SDK 给未打包应用登记时一样 + key.set_expand_string("DisplayName", PRODUCT_NAME)?; + if icon_ok { + key.set_expand_string("IconUri", icon.to_string_lossy())?; + } else { + // 别留着一个指向不存在的文件的旧值 + let _ = key.remove_value("IconUri"); + } + Ok(()) + } + + /// 内容没变就不重写 + fn write_icon(path: &Path) -> std::io::Result<()> { + const PNG: &[u8] = include_bytes!("../icons/128x128.png"); + if std::fs::read(path).is_ok_and(|b| b == PNG) { + return Ok(()); + } + crate::atomic_file::write(path, PNG) + } + + pub(super) fn autostart_on(hive: &Hive) -> bool { + let present = matches!(read_string(hive, RUN, PRODUCT_NAME), Ok(Some(_))); + present && approved(hive) != Some(false) + } + + /// 「设置 › 应用 › 启动」里的开关:`Some(false)` = 被关掉了;`None` = 没碰过它 + fn approved(hive: &Hive) -> Option { + let key = hive.open(APPROVED).ok()??; + let v = key.get_value(PRODUCT_NAME).ok()?; + if v.ty() != Type::Bytes { + return None; + } + crate::autostart::approved_from_bytes(&v) + } + + pub(super) fn set_autostart( + hive: &Hive, + exe: &str, + on: bool, + ) -> std::result::Result { + let r = if on { + enable_autostart(hive, exe) + } else { + disable_autostart(hive) + }; + r.map_err(|e| e.to_string())?; + Ok(autostart_on(hive)) + } + + fn enable_autostart(hive: &Hive, exe: &str) -> Result<()> { + hive.create(RUN)? + .set_string(PRODUCT_NAME, autostart_command(exe))?; + // 在「启动应用」里被关掉过:一并打开,否则勾上之后开关自己弹回去 + if approved(hive) == Some(false) { + hive.create(APPROVED)? + .set_bytes(PRODUCT_NAME, Type::Bytes, &APPROVED_ON)?; + } + Ok(()) + } + + fn disable_autostart(hive: &Hive) -> Result<()> { + if let Some(run) = hive.open_rw(RUN)? { + absent_is_none(run.remove_value(PRODUCT_NAME))?; + } + Ok(()) + } + + pub(super) fn release_all( + hive: &Hive, + exe: &str, + dir: &Path, + aumid: &str, + ) -> Vec { + let mut out = Vec::new(); + release_link(hive, exe, &mut out); + release_autostart(hive, exe, &mut out); + release_notifications(hive, dir, aumid, &mut out); + out + } + + fn release_link(hive: &Hive, exe: &str, out: &mut Vec) { + let command = read_string(hive, &format!(r"{}\shell\open\command", link_key()), ""); + let removed = match command { + // 没有,或者指向另一份:不是这一份的,不提 + Ok(None) => return, + Ok(Some(cmd)) if !command_runs(&cmd, exe) => return, + Ok(Some(_)) => absent_is_none(hive.remove_tree(&link_key())).map(|_| ()), + Err(e) => Err(e), + }; + out.push(match removed { + Ok(()) => UninstallStep::done(tr!( + "已移除 thinkwatch:// 链接", + "thinkwatch:// links unregistered" + )), + Err(e) => UninstallStep::failed(tr!( + format!("未能移除 thinkwatch:// 链接({e})"), + format!("thinkwatch:// links could not be unregistered ({e})") + )), + }); + } + + fn release_autostart(hive: &Hive, exe: &str, out: &mut Vec) { + let removed = match read_string(hive, RUN, PRODUCT_NAME) { + Ok(None) => return, + Ok(Some(cmd)) if !command_runs(&cmd, exe) => { + let other = command_exe(&cmd).unwrap_or(&cmd).to_string(); + out.push(UninstallStep::done(tr!( + format!("开机启动项指向 {other},未作更改"), + format!("The launch-at-login entry points to {other} and was left unchanged") + ))); + return; + } + Ok(Some(_)) => disable_autostart(hive).map(|()| { + // 「启动应用」里那个开关跟着 Run 里这一项走,项没了它也不该留着 + if let Ok(Some(key)) = hive.open_rw(APPROVED) { + let _ = key.remove_value(PRODUCT_NAME); + } + }), + Err(e) => Err(e), + }; + out.push(match removed { + Ok(()) => UninstallStep::done(tr!("已取消开机启动", "Launch at login turned off")), + Err(e) => UninstallStep::failed(tr!( + format!("未能取消开机启动({e})。请在「设置 › 应用 › 启动」中关闭 ThinkWatch Lite。"), + format!( + "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in Settings › Apps › Startup." + ) + )), + }); + } + + /// 这一份的通知登记:**图标指着这一份的数据目录才算**。两个绿色版放在不同的文件夹里 + /// 共用一个 AUMID,登记归最后启动的那一个;删掉一个不该把另一个的名字和图标也拿走 + fn release_notifications(hive: &Hive, dir: &Path, aumid: &str, out: &mut Vec) { + let icon = dir.join(NOTIFICATION_ICON); + let ours = matches!( + read_string(hive, &aumid_key(aumid), "IconUri"), + Ok(Some(uri)) if same_path(&uri, &icon.to_string_lossy()) + ); + if ours { + // 通知中心里这一份留下的几条一并清掉:登记没了,它们就只剩一个原始的 ID + #[cfg(not(test))] + let _ = crate::notices::windows::toast::clear(aumid); + out.push(match absent_is_none(hive.remove_tree(&aumid_key(aumid))) { + Ok(_) => UninstallStep::done(tr!( + "已移除系统通知的登记", + "System notification registration removed" + )), + Err(e) => UninstallStep::failed(tr!( + format!("未能移除系统通知的登记({e})"), + format!("The system notification registration could not be removed ({e})") + )), + }); + } + match std::fs::remove_file(&icon) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => out.push(UninstallStep::failed(tr!( + format!("未能删除通知图标:{}({e})", icon.display()), + format!( + "The notification icon could not be deleted: {} ({e})", + icon.display() + ) + ))), + } + } + + /// 对着 `HKCU\Software` 下面一个临时子键跑,跑完删掉;不碰这台机器真的那几项 + #[cfg(test)] + mod tests { + use super::*; + use crate::autostart::AUTOSTART_FLAG; + + struct Scratch(Hive, String); + + impl Scratch { + fn new(name: &str) -> Self { + let base = format!(r"Software\ThinkWatchLiteTest\{}-{name}", std::process::id()); + Self(Hive::under(base.clone()), base) + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let _ = CURRENT_USER.remove_tree(&self.1); + } + } + + const EXE: &str = r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe"; + const OTHER: &str = r"D:\Tools\ThinkWatch Lite\thinkwatch-lite.exe"; + + fn command(hive: &Hive) -> Option { + read_string(hive, &format!(r"{}\shell\open\command", link_key()), "").unwrap() + } + + fn run_entry(hive: &Hive) -> Option { + read_string(hive, RUN, PRODUCT_NAME).unwrap() + } + + #[test] + fn claiming_points_the_link_at_this_exe_every_time() { + let s = Scratch::new("link"); + let dir = tempfile::tempdir().unwrap(); + assert!(claim(&s.0, OTHER, dir.path(), "a.test")); + assert_eq!(command(&s.0).unwrap(), format!("\"{OTHER}\" \"%1\"")); + assert!(claim(&s.0, EXE, dir.path(), "a.test")); + assert_eq!(command(&s.0).unwrap(), format!("\"{EXE}\" \"%1\"")); + let key = s.0.open(&link_key()).unwrap().unwrap(); + assert_eq!(key.get_string("URL Protocol").unwrap(), ""); + assert_eq!( + key.get_string("").unwrap(), + "URL:app.thinkwatch.lite protocol" + ); + assert_eq!( + s.0.open(&format!(r"{}\DefaultIcon", link_key())) + .unwrap() + .unwrap() + .get_string("") + .unwrap(), + format!("\"{EXE}\",0") + ); + } + + /// 开着就改成指向自己;**没开着不替用户打开** + #[test] + fn claiming_moves_autostart_only_when_it_exists() { + let s = Scratch::new("follow"); + let dir = tempfile::tempdir().unwrap(); + claim(&s.0, EXE, dir.path(), "a.test"); + assert_eq!(run_entry(&s.0), None); + + // 老版本的插件写的:不带引号 + s.0.create(RUN) + .unwrap() + .set_string(PRODUCT_NAME, format!("{OTHER} {AUTOSTART_FLAG}")) + .unwrap(); + claim(&s.0, EXE, dir.path(), "a.test"); + assert_eq!( + run_entry(&s.0).unwrap(), + format!("\"{EXE}\" {AUTOSTART_FLAG}") + ); + } + + #[test] + fn the_toggle_is_shared_and_respects_windows_settings() { + let s = Scratch::new("toggle"); + assert!(!autostart_on(&s.0)); + + // 另一份打开的也算开着 + s.0.create(RUN) + .unwrap() + .set_string(PRODUCT_NAME, autostart_command(OTHER)) + .unwrap(); + assert!(autostart_on(&s.0)); + + // 在「启动应用」里关掉了 + s.0.create(APPROVED) + .unwrap() + .set_bytes( + PRODUCT_NAME, + Type::Bytes, + &[3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + ) + .unwrap(); + assert!(!autostart_on(&s.0)); + + // 在这里打开:写指向自己的,连「启动应用」那边一起打开 + assert_eq!(set_autostart(&s.0, EXE, true), Ok(true)); + assert_eq!(run_entry(&s.0).unwrap(), autostart_command(EXE)); + assert_eq!(approved(&s.0), Some(true)); + + assert_eq!(set_autostart(&s.0, EXE, false), Ok(false)); + assert_eq!(run_entry(&s.0), None); + // 已经关着再关一次不算错 + assert_eq!(set_autostart(&s.0, EXE, false), Ok(false)); + } + + #[test] + fn notifications_are_registered_with_a_name_and_an_image_file() { + let s = Scratch::new("aumid"); + let dir = tempfile::tempdir().unwrap(); + assert!(claim(&s.0, EXE, dir.path(), "a.test.portable")); + let key = s.0.open(&aumid_key("a.test.portable")).unwrap().unwrap(); + let name = key.get_value("DisplayName").unwrap(); + assert_eq!(name.ty(), Type::ExpandString); + assert_eq!(key.get_string("DisplayName").unwrap(), "ThinkWatch Lite"); + let icon = key.get_string("IconUri").unwrap(); + assert!(same_path( + &icon, + &dir.path().join(NOTIFICATION_ICON).to_string_lossy() + )); + let png = std::fs::read(&icon).unwrap(); + assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n"); + } + + /// 只删指向自己的;指向别处的留着,并且说一声开机启动还在 + #[test] + fn releasing_removes_only_what_points_here() { + let s = Scratch::new("release"); + let here = tempfile::tempdir().unwrap(); + let there = tempfile::tempdir().unwrap(); + + // 另一份最后启动过:链接、自启、登记都是它的 + claim(&s.0, OTHER, there.path(), "a.test"); + set_autostart(&s.0, OTHER, true).unwrap(); + // 这一份的图标还在自己的数据目录里 + write_icon(&here.path().join(NOTIFICATION_ICON)).unwrap(); + + let steps = crate::i18n::with_lang(crate::i18n::Lang::Zh, || { + release_all(&s.0, EXE, here.path(), "a.test") + }); + assert!(steps.iter().all(|s| s.ok), "{:?}", texts(&steps)); + assert_eq!(texts(&steps), [format!("开机启动项指向 {OTHER},未作更改")]); + assert!(command(&s.0).is_some()); + assert!(run_entry(&s.0).is_some()); + assert!(s.0.open(&aumid_key("a.test")).unwrap().is_some()); + assert!(!here.path().join(NOTIFICATION_ICON).exists()); + assert!(there.path().join(NOTIFICATION_ICON).exists()); + + // 现在这一份启动过:三样都归它,清理时三样都删 + claim(&s.0, EXE, here.path(), "a.test"); + let steps = crate::i18n::with_lang(crate::i18n::Lang::Zh, || { + release_all(&s.0, EXE, here.path(), "a.test") + }); + assert_eq!( + texts(&steps), + [ + "已移除 thinkwatch:// 链接", + "已取消开机启动", + "已移除系统通知的登记" + ] + ); + assert!(steps.iter().all(|s| s.ok)); + assert!(s.0.open(&link_key()).unwrap().is_none()); + assert_eq!(run_entry(&s.0), None); + assert!(s.0.open(&aumid_key("a.test")).unwrap().is_none()); + assert!(!here.path().join(NOTIFICATION_ICON).exists()); + + // 什么都没有了:再清一次什么都不说 + assert!(release_all(&s.0, EXE, here.path(), "a.test").is_empty()); + } + + fn texts(steps: &[UninstallStep]) -> Vec { + steps.iter().map(|s| s.text.clone()).collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 抄出来的两个常量和配置文件里的是同一个:单实例的锁名、通知的 AUMID、自启的值名 + /// 都靠它们和 Tauri 那一侧对上 + #[test] + fn the_copied_constants_match_tauri_conf() { + let conf: serde_json::Value = + serde_json::from_str(include_str!("../tauri.conf.json")).unwrap(); + assert_eq!(conf["identifier"], IDENTIFIER); + assert_eq!(conf["productName"], PRODUCT_NAME); + assert_eq!( + conf["plugins"]["deep-link"]["desktop"]["schemes"][0], + SCHEME + ); + } + + #[test] + fn the_program_of_a_command_line() { + // 带引号的:链接和这一版写的自启项 + assert_eq!( + command_exe(r#""C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe" "%1""#), + Some(r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe") + ); + // 老版本的插件写的:不带引号,路径里有空格 + assert_eq!( + command_exe(r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe --autostart"), + Some(r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe") + ); + assert_eq!( + command_exe(r"C:\Tools\THINKWATCH-LITE.EXE"), + Some(r"C:\Tools\THINKWATCH-LITE.EXE") + ); + // 文件夹名里带 .exe 的不算到那里为止 + assert_eq!( + command_exe(r"C:\a.exe.d\thinkwatch-lite.exe --autostart"), + Some(r"C:\a.exe.d\thinkwatch-lite.exe") + ); + // 没有 .exe:取到第一个空白 + assert_eq!(command_exe("prog --x"), Some("prog")); + // 引号没合上:引号后面全算 + assert_eq!(command_exe(r#""C:\x y\a.exe"#), Some(r"C:\x y\a.exe")); + assert_eq!(command_exe(""), None); + assert_eq!(command_exe(r#""" x"#), None); + } + + #[test] + fn paths_compare_like_windows_does() { + let exe = r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe"; + assert!(same_path(exe, exe)); + assert!(same_path( + exe, + r"c:\program files\thinkwatch lite\THINKWATCH-LITE.EXE" + )); + assert!(same_path( + exe, + r"\\?\C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe" + )); + assert!(same_path( + exe, + "C:/Program Files/ThinkWatch Lite/thinkwatch-lite.exe" + )); + assert!(!same_path(exe, r"D:\ThinkWatch Lite\thinkwatch-lite.exe")); + // 中文路径也不分大小写地比,且不会因为非 ASCII 字符出错 + assert!(same_path( + r"D:\工具\ThinkWatch\A.exe", + r"d:\工具\thinkwatch\a.EXE" + )); + assert!(same_path(r"\\?\UNC\nas\share\a.exe", r"\\nas\share\a.exe")); + } + + #[test] + fn commands_point_at_the_exe_and_survive_spaces() { + let exe = r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe"; + assert_eq!( + open_command(exe), + r#""C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe" "%1""# + ); + assert_eq!( + autostart_command(exe), + r#""C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe" --autostart"# + ); + assert!(command_runs(&open_command(exe), exe)); + assert!(command_runs(&autostart_command(exe), &exe.to_uppercase())); + assert!(!command_runs( + &autostart_command(exe), + r"D:\x\thinkwatch-lite.exe" + )); + } + + /// 不是 Windows(或者开发构建)时清理什么都不做、什么都不说 + #[cfg(not(windows))] + #[test] + fn releasing_elsewhere_is_a_no_op() { + assert!(release_all(Path::new("/x/thinkwatch-lite")).is_empty()); + assert!(!notifications_registered()); + } +} From f605da7395b4430ef20e8051a36902e0c3fa6e14 Mon Sep 17 00:00:00 2001 From: fylorn <249551762+fylorn@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:36:21 +0800 Subject: [PATCH 04/23] Windows portable path C: uninstall cleanup, other-instance clients, WebView2 check - cleanup: `--uninstall-cleanup [--delete-data]` runs before Tauri. It restores this copy's clients (WSL too), calls winreg::release_all for the running exe, waits for this copy's twcore.exe to exit, deletes the data directory only when asked and every client came back (retrying while the core lets go), and exits 0/1. - uninstall: the in-app uninstall and the cleanup share the steps (restore_everywhere, restore_steps, drop_data, last_line). On Windows the in-app uninstall releases the HKCU entries through winreg::release_all; the portable build ends with "the whole folder can now be deleted" and keeps its in-use data\webview when deleting data. - NSIS: PREUNINSTALL hook (skipped on /UPDATE) closes the running app and runs the cleanup, with --delete-data when the checkbox is ticked. English and SimpChinese language files copied from Tauri CLI 2.11.4 with the checkbox reworded. - Clients taken over by another ThinkWatch Lite (the full backup named in the sidecar is not in this copy's backups dir) are marked as such: no restore or adopt here, restore-all skips and lists them, key sync and retargeting leave them alone, and the backend refuses to touch them (adopt.other_instance). - webview2: refuse to start without the WebView2 runtime, offering Microsoft's download page. - Settings: the uninstall section says the system uninstaller now does the same steps (installed) or that the folder can be deleted (portable). Co-Authored-By: Claude Opus 5.5 --- scripts/shots/mock/clients.ts | 1 + scripts/shots/mock/commands.ts | 1 + src-tauri/Cargo.toml | 2 + src-tauri/crates/tw-adopt/src/detect.rs | 11 +- src-tauri/crates/tw-adopt/src/foreign.rs | 82 ++++ src-tauri/msg-codes.txt | 1 + src-tauri/src/cleanup.rs | 271 +++++++++++- src-tauri/src/clients/mod.rs | 39 +- src-tauri/src/clients/ops.rs | 164 +++++-- src-tauri/src/settings.rs | 2 + src-tauri/src/uninstall.rs | 539 +++++++++++++++++------ src-tauri/src/webview2.rs | 66 ++- src-tauri/src/wire.rs | 4 + src-tauri/tauri.windows.conf.json | 7 +- src-tauri/windows/English.nsh | 30 ++ src-tauri/windows/SimpChinese.nsh | 30 ++ src-tauri/windows/hooks.nsh | 26 ++ src/clients/ClientStatus.tsx | 5 + src/clients/ClientsPage.tsx | 13 +- src/clients/ClientsTable.tsx | 3 +- src/clients/DetailDialog.tsx | 2 + src/clients/api.ts | 2 + src/clients/clients.i18n.tsx | 11 + src/clients/status.test.ts | 11 + src/clients/status.ts | 10 +- src/generated/lite-api.ts | 11 +- src/i18n/core.zh.json | 1 + src/settings/SettingsPage.i18n.tsx | 90 +++- src/settings/UninstallSection.tsx | 6 +- src/settings/api.ts | 2 + src/settings/uninstall.test.ts | 6 + 31 files changed, 1215 insertions(+), 234 deletions(-) create mode 100644 src-tauri/windows/English.nsh create mode 100644 src-tauri/windows/SimpChinese.nsh create mode 100644 src-tauri/windows/hooks.nsh diff --git a/scripts/shots/mock/clients.ts b/scripts/shots/mock/clients.ts index 8c49a390..18a84c65 100644 --- a/scripts/shots/mock/clients.ts +++ b/scripts/shots/mock/clients.ts @@ -198,6 +198,7 @@ function detected(x: Partial & { id: string; name: string; path: installed: true, has_config: true, adopted_at_ms: null, + other_instance: false, endpoint: null, shadows: [], takes_effect: "immediately", diff --git a/scripts/shots/mock/commands.ts b/scripts/shots/mock/commands.ts index e60663f7..07bfcabc 100644 --- a/scripts/shots/mock/commands.ts +++ b/scripts/shots/mock/commands.ts @@ -149,6 +149,7 @@ const COMMANDS: Table = { identifier: "app.thinkwatch.lite", data_dir: "/Users/alex/.thinkwatch", core_bin: "/Applications/ThinkWatch Lite.app/Contents/Resources/twcore", + portable: false, }), app_language: langView, app_theme: themeView, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0d40570f..05be1493 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -177,6 +177,8 @@ windows-sys = { version = "0.61", features = [ # 醒来的那个常量在后一个里 "Win32_System_Power", "Win32_UI_WindowsAndMessaging", + # 卸载程序删文件之前,等这一份的 core 退出:按进程表找它(`cleanup`) + "Win32_System_Diagnostics_ToolHelp", ] } # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 diff --git a/src-tauri/crates/tw-adopt/src/detect.rs b/src-tauri/crates/tw-adopt/src/detect.rs index 3ac89da0..3a6e681c 100644 --- a/src-tauri/crates/tw-adopt/src/detect.rs +++ b/src-tauri/crates/tw-adopt/src/detect.rs @@ -35,6 +35,9 @@ pub struct Detected { pub has_config: bool, /// 接管过,时间戳来自旁文件 pub adopted_at_ms: Option, + /// 接管时留的那份全文备份,旁文件里记的路径。它在哪个数据目录里,就是哪一份 + /// ThinkWatch Lite 接管的([`foreign::is_ours`])。没接管着就没有 + pub backup: Option, /// 配置里此刻的端点。**读出来的,不是我们记的** —— 「我们写过」 /// 和「现在还是那样」是两回事 pub endpoint: Option, @@ -107,15 +110,15 @@ pub fn detect_one(c: &Client, home: &Path) -> Detected { let text = std::fs::read_to_string(&real).ok(); let rec: Option = std::fs::read_to_string(sentinel::sidecar_path(&real)) .ok() - .and_then(|t| serde_json::from_str(&t).ok()); + .and_then(|t| serde_json::from_str::(&t).ok()) + .filter(|r| r.client == c.id); Detected { id: c.id, name: c.name, installed: c.installed(home) || text.is_some(), has_config: text.is_some(), - adopted_at_ms: rec - .filter(|r: &SidecarRecord| r.client == c.id) - .map(|r| r.adopted_at_ms), + adopted_at_ms: rec.as_ref().map(|r| r.adopted_at_ms), + backup: rec.map(|r| PathBuf::from(r.backup)), endpoint: text.as_deref().and_then(|t| endpoint_of(c, t)), models: text .as_deref() diff --git a/src-tauri/crates/tw-adopt/src/foreign.rs b/src-tauri/crates/tw-adopt/src/foreign.rs index 05c1a753..7aa57981 100644 --- a/src-tauri/crates/tw-adopt/src/foreign.rs +++ b/src-tauri/crates/tw-adopt/src/foreign.rs @@ -474,6 +474,45 @@ pub fn backups_of(root: &Path, real: &Path) -> Vec { out } +/// 接管记录(旁文件)里那份全文备份是不是 `root` 这个备份目录里的,也就是:这个客户端 +/// 是不是**这一份** ThinkWatch Lite 接管的。 +/// +/// 安装版和绿色版各有各的数据目录,也就各有各的备份目录;而接管记录写在客户端配置 +/// 旁边,两份都看得见。记录里存的是接管那一刻备份的绝对路径(重复接管也指着第一次那份, +/// 见 `plan::apply`),按它认: +/// +/// - 在 `root` 下面:这一份接管的; +/// - 不在,但 `root` 下面有同一份(`<毫秒>-<序号>/<压平的路径>`):也是这一份的,只是数据 +/// 目录挪过地方 —— 绿色版的整个文件夹可以被挪走。目录名里有毫秒时间戳,两份数据目录 +/// 撞上同一个名字,得是同一毫秒接管了同一个文件; +/// - 都不是:另一个 ThinkWatch Lite 接管的。它的备份不在这里,还原也该由它来做。 +/// +/// 记录里没有备份路径的,说不上是谁的,算这一份的:照旧能还原。 +/// +/// **不分大小写**:Windows 上同一个目录可能写成 `C:\Users` 或 `c:\users`。别的平台上 +/// 两个只差大小写的数据目录不会同时存在。 +pub fn is_ours(root: &Path, backup: &Path) -> bool { + if backup.as_os_str().is_empty() { + return true; + } + let parts = |p: &Path| -> Vec { + p.components() + .map(|c| c.as_os_str().to_string_lossy().to_lowercase()) + .collect() + }; + let (r, b) = (parts(root), parts(backup)); + if b.len() > r.len() && b[..r.len()] == r[..] { + return true; + } + match ( + backup.parent().and_then(|d| d.file_name()), + backup.file_name(), + ) { + (Some(dir), Some(file)) => root.join(dir).join(file).is_file(), + _ => false, + } +} + fn backup_to(root: &Path, real: &Path, text: impl AsRef<[u8]>) -> Result { backup_at(root, real, text, now_ms()) } @@ -1037,6 +1076,49 @@ mod tests { assert_eq!(backups_of(&root, &p), vec![first, second]); } + /// 接管记录里的备份在哪一份的备份目录里,就是哪一份接管的。安装版和绿色版 + /// 各有一个数据目录,同一台机器上的客户端两边都看得见 + #[test] + fn a_backup_belongs_to_the_backup_directory_it_is_in() { + let d = tempfile::tempdir().unwrap(); + let (mine, theirs) = (d.path().join("a/backups"), d.path().join("b/backups")); + let p = d.path().join("settings.json"); + let b = backup_at(&mine, &p, "原来的", 1_700_000_000_000).unwrap(); + assert!(is_ours(&mine, &b)); + assert!(!is_ours(&theirs, &b), "另一份的备份目录里没有它"); + // 备份文件被删了也还是这一份的:认的是路径 + std::fs::remove_file(&b).unwrap(); + assert!(is_ours(&mine, &b)); + // 记录里没有备份路径:说不上是谁的,照旧当成这一份的 + assert!(is_ours(&theirs, Path::new(""))); + } + + /// 不分大小写:Windows 上同一个目录可能写成 `C:\Users` 或 `c:\users` + #[test] + fn the_backup_directory_is_compared_without_case() { + let d = tempfile::tempdir().unwrap(); + let root = d.path().join("Data/backups"); + let b = d.path().join("data/BACKUPS/1700000000000-0000/x.json"); + assert!(is_ours(&root, &b)); + } + + /// 整个数据目录挪过地方(绿色版的文件夹被挪走):记录里还是旧路径,而同一份备份 + /// 跟着到了新的目录下 —— 仍是这一份接管的 + #[test] + fn a_moved_data_directory_still_owns_its_backups() { + let d = tempfile::tempdir().unwrap(); + let (old, new) = (d.path().join("old/backups"), d.path().join("new/backups")); + let p = d.path().join("settings.json"); + let b = backup_at(&old, &p, "原来的", 1_700_000_000_000).unwrap(); + std::fs::create_dir_all(d.path().join("new")).unwrap(); + std::fs::rename(&old, &new).unwrap(); + assert!(is_ours(&new, &b), "{}", b.display()); + // 另一份有自己的备份,但不是记录里的这一份 + let other = d.path().join("other/backups"); + backup_at(&other, &p, "别人那次的", 1_700_000_000_001).unwrap(); + assert!(!is_ours(&other, &b)); + } + /// 备份里是原样的配置:用户自己的 API key、换上的网关密钥。**只给自己看**: /// 目录 0700、文件 0600;以前按默认权限建的根目录收一次 #[cfg(unix)] diff --git a/src-tauri/msg-codes.txt b/src-tauri/msg-codes.txt index 6356a4f6..a9febbda 100644 --- a/src-tauri/msg-codes.txt +++ b/src-tauri/msg-codes.txt @@ -140,6 +140,7 @@ adopt.mcp.toml_null adopt.mcp.unknown_client adopt.mcp.unverified_format adopt.mcp.zed_structure +adopt.other_instance adopt.path.adopted adopt.path.fixed adopt.path.folder diff --git a/src-tauri/src/cleanup.rs b/src-tauri/src/cleanup.rs index 5325f348..838675e7 100644 --- a/src-tauri/src/cleanup.rs +++ b/src-tauri/src/cleanup.rs @@ -1,7 +1,272 @@ //! 系统卸载程序调用的无界面清理:`--uninstall-cleanup`(可带 `--delete-data`)。 //! -//! 和应用内「卸载」走同一套步骤:还原接管过的客户端、删掉指向这个 exe 的注册项、 -//! 按要求删除数据目录。不起窗口,做完就退出,退出码说明结果。 +//! 和应用内「卸载」走同一套步骤(见 `uninstall`):还原接管过的客户端(含 WSL 里的)、 +//! 删掉指向这个 exe 的注册项、按要求删除数据目录。不起窗口,做完就退出,退出码说明 +//! 结果:每一步都做成了是 0,否则是 1。 +//! +//! 调用方是安装版的卸载程序(`src-tauri/windows/hooks.nsh`):它先关掉正在运行的应用, +//! 再带着这个参数运行同一个 exe、等它退出,然后才删文件。所以这里**不碰单实例的锁** +//! (在 `single::precheck` 之前)、不要 WebView2。 +//! +//! **它清理的是运行它的那个用户。**卸载程序以管理员身份运行;标准账户输入管理员的 +//! 密码卸载时,跑在管理员账户下,清理的是管理员的配置 —— 这种情况应先在应用内卸载 +//! (README 里写了)。 + +use std::time::Duration; + +use crate::uninstall::{self, Retry}; +use crate::wire::UninstallStep; + +/// 命令行里要的那一次清理 +#[derive(Debug, PartialEq, Eq)] +struct Request { + /// `--delete-data`:卸载程序里「同时删除数据」那个勾选框勾上了 + delete_data: bool, +} + +const FLAG: &str = "--uninstall-cleanup"; +const DELETE_DATA: &str = "--delete-data"; + +/// 卸载程序删数据目录时等 core 放手:它靠 `--parent` 守望两秒看一次父进程,应用被关掉 +/// 之后最晚两三秒就走;留出余量,又不能让卸载程序一直卡着 +const DROP_RETRY: Retry = Retry { + tries: 20, + pause: Duration::from_millis(500), +}; + +/// 等 core 退出最多等多久,见 [`wait_for_core`] +const CORE_EXIT: Duration = Duration::from_secs(10); + +fn parse>(args: impl IntoIterator) -> Option { + let args: Vec = args.into_iter().collect(); + let has = |flag: &str| args.iter().skip(1).any(|a| a.as_ref() == flag); + has(FLAG).then(|| Request { + delete_data: has(DELETE_DATA), + }) +} /// 命令行里有 `--uninstall-cleanup` 就执行清理并退出进程;没有就什么都不做。 -pub fn dispatch() {} +pub fn dispatch() { + let Some(req) = parse(std::env::args()) else { + return; + }; + let log = run(&req); + for s in &log { + if s.ok { + tracing::info!("{}", s.text); + } else { + tracing::warn!("{}", s.text); + } + } + std::process::exit(exit_code(&log)); +} + +fn run(req: &Request) -> Vec { + let backups = tw_adopt::foreign::backup_root(); + let (mut log, restored_all) = uninstall::restore_steps(uninstall::restore_everywhere(&backups)); + let exe = std::env::current_exe(); + match &exe { + Ok(exe) => log.extend(crate::winreg::release_all(exe)), + Err(e) => log.push(UninstallStep::failed(tr!( + format!("未能确定程序位置,注册项未清理({e})"), + format!( + "The program location could not be determined, so its registry entries were left in place ({e})" + ) + ))), + } + // 卸载程序接着要删 twcore.exe,数据目录也要等它放手:不等它走,那个文件删不掉、 + // 留在安装目录里。等不到也接着做,删不掉的那一步会说 + if let Ok(exe) = &exe + && let Some(dir) = exe.parent() + { + wait_for_core(&dir.join(crate::gateway::CORE_EXE), CORE_EXIT); + } + if req.delete_data { + log.push(uninstall::drop_data( + &crate::data_dir(), + true, + restored_all, + None, + DROP_RETRY, + )); + } + log +} + +/// 每一步都做成了是 0,否则是 1 +fn exit_code(log: &[UninstallStep]) -> i32 { + if log.iter().all(|s| s.ok) { 0 } else { 1 } +} + +/// 等这一份的 core(`core` 这个文件跑起来的进程)退出,至多等 `limit`。不是 Windows、 +/// 或者它没在跑,立刻回来。返回它是不是已经不在了 +#[cfg(windows)] +fn wait_for_core(core: &std::path::Path, limit: Duration) -> bool { + imp::wait_gone(core, limit) +} + +#[cfg(not(windows))] +fn wait_for_core(core: &std::path::Path, limit: Duration) -> bool { + let _ = (core, limit); + true +} + +#[cfg(windows)] +mod imp { + use std::path::Path; + use std::time::{Duration, Instant}; + + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE, WAIT_TIMEOUT}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + QueryFullProcessImageNameW, WaitForSingleObject, + }; + + /// 进程表里文件名对得上的几个,再按完整路径挑出跑的是 `core` 这个文件的:另一份 + /// ThinkWatch Lite 的 core(绿色版、安装版各一份)同名,不等它 + pub fn wait_gone(core: &Path, limit: Duration) -> bool { + let Some(name) = core.file_name().map(|n| n.to_string_lossy().to_lowercase()) else { + return true; + }; + let want = core.to_string_lossy().to_lowercase(); + let deadline = Instant::now() + limit; + let mut gone = true; + for pid in pids_named(&name) { + // SAFETY: 只要等待和查路径两种权限;拿不到(进程已经没了)就是不用等 + let h = unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE, + 0, + pid, + ) + }; + if h.is_null() { + continue; + } + if image_of(h).is_some_and(|p| p.to_lowercase() == want) { + let left = deadline.saturating_duration_since(Instant::now()); + let ms = u32::try_from(left.as_millis()).unwrap_or(u32::MAX); + // SAFETY: `h` 是上面打开的、带 SYNCHRONIZE 的进程句柄 + if unsafe { WaitForSingleObject(h, ms) } == WAIT_TIMEOUT { + gone = false; + } + } + // SAFETY: 关的是自己打开的句柄,只关一次 + unsafe { CloseHandle(h) }; + } + gone + } + + fn pids_named(name: &str) -> Vec { + let mut out = Vec::new(); + // SAFETY: 拍一张进程表的快照;失败返回 INVALID_HANDLE_VALUE + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snap == INVALID_HANDLE_VALUE { + return out; + } + // SAFETY: PROCESSENTRY32W 是纯数据,全零是合法的初值;dwSize 按文档先填好 + let mut e: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + e.dwSize = std::mem::size_of::() as u32; + // SAFETY: `snap` 是有效的快照句柄,`e` 的 dwSize 已填 + let mut more = unsafe { Process32FirstW(snap, &mut e) } != 0; + while more { + let len = e + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(e.szExeFile.len()); + if String::from_utf16_lossy(&e.szExeFile[..len]).to_lowercase() == name { + out.push(e.th32ProcessID); + } + // SAFETY: 同上 + more = unsafe { Process32NextW(snap, &mut e) } != 0; + } + // SAFETY: 关的是上面拍的快照,只关一次 + unsafe { CloseHandle(snap) }; + out + } + + fn image_of(h: windows_sys::Win32::Foundation::HANDLE) -> Option { + let mut buf = vec![0u16; 32 * 1024]; + let mut len = buf.len() as u32; + // SAFETY: `buf` 有 `len` 个 u16 那么大,函数写入后把 `len` 改成实际长度 + let ok = unsafe { + QueryFullProcessImageNameW(h, PROCESS_NAME_WIN32, buf.as_mut_ptr(), &mut len) + }; + (ok != 0).then(|| String::from_utf16_lossy(&buf[..len as usize])) + } + + #[cfg(test)] + mod tests { + use super::*; + + /// 一个正在跑的进程:按完整路径认得出来,等不到它退出就报还在;它退出之后 + /// 立刻回来。同名、别的位置的不等 + #[test] + fn a_running_copy_is_waited_for_by_its_full_path() { + // 一个跑几秒就自己退出的程序,换个名字放到临时目录里跑:系统的 ping 就行 + let ping = std::path::Path::new(&std::env::var("SystemRoot").unwrap()) + .join("System32") + .join("PING.EXE"); + let d = tempfile::tempdir().unwrap(); + let copy = d.path().join("twcore-test.exe"); + std::fs::copy(&ping, ©).unwrap(); + let mut child = std::process::Command::new(©) + .args(["-n", "4", "127.0.0.1"]) + .stdout(std::process::Stdio::null()) + .spawn() + .unwrap(); + let elsewhere = d.path().join("other").join("twcore-test.exe"); + assert!( + wait_gone(&elsewhere, Duration::from_secs(1)), + "别的位置的同名程序不等" + ); + assert!(!wait_gone(©, Duration::from_millis(300)), "还在跑"); + assert!(wait_gone(©, Duration::from_secs(20)), "退出之后就回来"); + child.wait().unwrap(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_cleanup_flag_starts_a_cleanup() { + assert_eq!(parse(["thinkwatch-lite.exe"]), None); + // 第一个是程序自己:路径里碰巧有这几个字不算 + assert_eq!(parse(["--uninstall-cleanup"]), None); + assert_eq!(parse(["x.exe", "--autostart"]), None); + assert_eq!(parse(["x.exe", "--delete-data"]), None, "光有删数据不算"); + assert_eq!( + parse(["x.exe", "--uninstall-cleanup"]), + Some(Request { delete_data: false }) + ); + assert_eq!( + parse(["x.exe", "--uninstall-cleanup", "--delete-data"]), + Some(Request { delete_data: true }) + ); + assert_eq!( + parse(["x.exe", "--delete-data", "--uninstall-cleanup"]), + Some(Request { delete_data: true }) + ); + } + + #[test] + fn any_step_that_did_not_work_makes_the_exit_code_one() { + assert_eq!(exit_code(&[]), 0); + assert_eq!( + exit_code(&[UninstallStep::done("a"), UninstallStep::done("b")]), + 0 + ); + assert_eq!( + exit_code(&[UninstallStep::done("a"), UninstallStep::failed("b")]), + 1 + ); + } +} diff --git a/src-tauri/src/clients/mod.rs b/src-tauri/src/clients/mod.rs index 3f0c94d8..f466a398 100644 --- a/src-tauri/src/clients/mod.rs +++ b/src-tauri/src/clients/mod.rs @@ -294,7 +294,7 @@ pub async fn list_clients(state: tauri::State<'_, AppState>) -> Out) -> Out (ops::list_wsl(&w, &gw), None), + Ok(w) => (ops::list_wsl(&w, &backups(), &gw), None), Err(e) => (Vec::new(), Some(e)), }; wire::WslGroup { @@ -727,22 +727,23 @@ impl LeftBehind { error, })], }; - Self::of(here, opened) + Self::of(here, opened, &backups()) } /// [`find`](Self::find) 的本体:这台电脑的 home(不看这台电脑时不给),和读过的 - /// 发行版 - fn of(here: Option, wsl: Vec>) -> LeftBehind { + /// 发行版。`backups` 是这一份的备份目录:另一个 ThinkWatch Lite 接管的不算 + /// ([`ops::theirs`]) + fn of(here: Option, wsl: Vec>, backups: &Path) -> LeftBehind { let here = here.map(|home| { Ok(Behind { - clients: ops::adopted_on_this_machine(&home), + clients: ops::adopted_on_this_machine(&home, backups), place: Place::Here, home, }) }); let wsl = wsl.into_iter().map(|w| { w.map(|w| Behind { - clients: ops::adopted_on_this_machine_wsl(&w), + clients: ops::adopted_on_this_machine_wsl(&w, backups), home: w.home.clone(), place: Place::Wsl(w), }) @@ -992,7 +993,7 @@ pub(crate) async fn sync_rotated( /// —— 这会唤醒它,而用户此刻正要删、换这把密钥。**读不出来就说读不出来**,不当 /// 成「没接管」:那样删掉的是它配置里正写着的钥匙,换掉的新值也同步不过去。 pub(crate) fn adopted_owner(keys: &[tw_api::ClientView], name: &str) -> Result, Msg> { - if let Some(client) = ops::adopted_owner(&home_dir(), keys, name) { + if let Some(client) = ops::adopted_owner(&home_dir(), &backups(), keys, name) { return Ok(Some(Owner { client, place: Place::Here, @@ -1019,12 +1020,13 @@ pub(crate) fn adopted_owner(keys: &[tw_api::ClientView], name: &str) -> Result = r @@ -1213,7 +1215,7 @@ mod tests { let backups_of = || tw_adopt::foreign::backups_of(&b, &real).len(); let kept = backups_of(); - let found = LeftBehind::of(None, vec![Ok(w.clone())]); + let found = LeftBehind::of(None, vec![Ok(w.clone())], &b); // 切换确认框里数的也只有还指着本机的那一个 let a = found.adopted(); assert_eq!((a.count, a.places.len()), (1, 1)); @@ -1224,7 +1226,7 @@ mod tests { assert_eq!(read(&settings), before); assert_eq!(backups_of(), kept); - let again = LeftBehind::of(None, vec![Ok(w)]) + let again = LeftBehind::of(None, vec![Ok(w)], &b) .retarget(SERVER, &b, issued) .await; assert!(again.synced.is_empty() && again.failed.is_empty()); @@ -1244,7 +1246,7 @@ mod tests { &Around::default(), ) .unwrap(); - let found = || LeftBehind::of(None, vec![Err(unreadable(d.path())), Ok(w.clone())]); + let found = || LeftBehind::of(None, vec![Err(unreadable(d.path())), Ok(w.clone())], &b); // 数不出来的不算进确认框里的数 let a = found().adopted(); @@ -1300,6 +1302,7 @@ mod tests { let r = LeftBehind::of( Some(here.path().to_path_buf()), vec![Ok(w), Err(unreadable(d.path()))], + &b, ) .all_failed(why); assert!(r.synced.is_empty()); @@ -1375,7 +1378,7 @@ mod tests { &Around::default(), ) .unwrap(); - let a = LeftBehind::of(Some(here.path().to_path_buf()), vec![Ok(w)]).adopted(); + let a = LeftBehind::of(Some(here.path().to_path_buf()), vec![Ok(w)], &b).adopted(); assert_eq!(a.count, 3); assert_eq!(a.local_addr.as_deref(), Some("127.0.0.1:8788")); let places: Vec<_> = a @@ -1390,7 +1393,7 @@ mod tests { assert_eq!(json["places"][1]["distro"], "Ubuntu"); let (_d, empty) = wsl_home(); - let a = LeftBehind::of(None, vec![Ok(empty)]).adopted(); + let a = LeftBehind::of(None, vec![Ok(empty)], &b).adopted(); assert_eq!((a.count, a.places.len(), a.local_addr), (0, 0, None)); } } diff --git a/src-tauri/src/clients/ops.rs b/src-tauri/src/clients/ops.rs index bb984040..66a67b6c 100644 --- a/src-tauri/src/clients/ops.rs +++ b/src-tauri/src/clients/ops.rs @@ -77,8 +77,11 @@ pub fn unknown(id: &str) -> Msg { /// `models` 是要把模型写进配置的客户端(opencode、Pi、oh-my-pi、Grok Build、Qwen Code)和要从中 /// 挑一个默认模型的(Hermes Agent)此刻从网关问到的模型清单,按客户端 id:拿它和配置里写着的 /// 比,不一样就提示更新;手动配置的那几项也照它写。问不到的不在里面。 +/// +/// `backups` 是这一份的备份目录:接管着的客户端按它分出是不是这一份接管的([`ours`])。 pub fn list( home: &Path, + backups: &Path, gw: &Gateway, models: &BTreeMap>, ) -> wire::ClientsResponse { @@ -103,14 +106,14 @@ pub fn list( ..placeholder.clone() }; let manual = setup_of(c, c.manual_steps(), &gw); - // 接管着、而且两边都读得到才比:没接管的没有清单可比,问不到的 + // 这一份接管着、而且两边都读得到才比:没接管的没有清单可比,问不到的 // 不知道该是什么 - let stale = d.adopted_at_ms.is_some() + let stale = ours(&d, backups) && matches!( (&d.models, now), (Some(written), Some(now)) if tw_adopt::opencode::models_stale(written, now) ); - let mut v = detected_view(d, key, last_seen_ms.flatten(), manual, |p| { + let mut v = detected_view(d, backups, key, last_seen_ms.flatten(), manual, |p| { p.display().to_string() }); v.models_stale = stale; @@ -147,13 +150,19 @@ pub fn list( /// 一个检测结果在界面上的样子。`shown` 决定路径怎么写:这台电脑上原样,WSL /// 里的写成 WSL 终端里的样子 +/// +/// **`adopted_at_ms` 只给这一份接管的。**另一个 ThinkWatch Lite 接管的([`theirs`])标成 +/// `other_instance`,界面上不给还原、也不给接管 —— 它的备份在那一份的数据目录里, +/// 从这里动它,那一份的接管记录就对不上了。 fn detected_view( d: detect::Detected, + backups: &Path, key: Option, last_seen_ms: Option, manual: wire::ManualSetup, shown: impl Fn(&Path) -> String, ) -> wire::DetectedClient { + let (mine, other) = (ours(&d, backups), theirs(&d, backups)); wire::DetectedClient { last_seen_ms, key, @@ -164,7 +173,8 @@ fn detected_view( real: shown(&d.real), installed: d.installed, has_config: d.has_config, - adopted_at_ms: d.adopted_at_ms, + adopted_at_ms: d.adopted_at_ms.filter(|_| mine), + other_instance: other, endpoint: d.endpoint, shadows: d.shadows.iter().map(|p| shown(p)).collect(), takes_effect: d.takes_effect.into(), @@ -187,7 +197,11 @@ fn detected_view( /// /// 和这台电脑上的一样检测、一样给手动配置的方法,只是 home 是 WSL 里的,密钥是 /// 为 WSL 里这一份单独发的那把(`tw_adopt::wsl::key_id`)。 -pub fn list_wsl(w: &tw_adopt::wsl::WslHome, gw: &Gateway) -> Vec { +pub fn list_wsl( + w: &tw_adopt::wsl::WslHome, + backups: &Path, + gw: &Gateway, +) -> Vec { let placeholder = clients::Gateway { base: gw.base.clone(), key: Some(String::new()), @@ -203,6 +217,7 @@ pub fn list_wsl(w: &tw_adopt::wsl::WslHome, gw: &Gateway) -> Vec Result { // 什么时候生效按装着的版本说(opencode v2 不用重启) let c = find(id, home)?.here(home); + not_theirs(&c, home, backups)?; let p = plan_for(&c, home, target, around).map_err(|e| e.msg())?; still_as_reviewed(expect, &plan_fingerprint(&p), c.name)?; let a = plan::apply(&c, &p, backups).map_err(|e| e.msg())?; @@ -672,6 +688,7 @@ pub fn restore( expect: Option<&str>, ) -> Result { let c = find(id, home)?.here(home); + not_theirs(&c, home, backups)?; let p = plan::plan_restore(&c, home).map_err(|e| e.msg())?; still_as_reviewed(expect, &plan_fingerprint(&p), c.name)?; let a = plan::apply_restore(&c, &p, backups).map_err(|e| e.msg())?; @@ -711,11 +728,52 @@ fn findings(f: Vec) -> Vec { .collect() } -/// 此刻接管着的客户端。 -pub fn adopted(home: &Path) -> Vec { +/// 这一份接管着它:接管着,而且接管时那份全文备份在这一份的备份目录 `backups` 里 +/// ([`tw_adopt::foreign::is_ours`])。 +pub fn ours(d: &detect::Detected, backups: &Path) -> bool { + d.adopted_at_ms.is_some() + && d.backup + .as_deref() + .is_none_or(|b| tw_adopt::foreign::is_ours(backups, b)) +} + +/// 接管着它的是另一个 ThinkWatch Lite(安装版和绿色版各有一份数据目录)。**这一份 +/// 不还原它、也不改它**:备份在那一份那里,接管记录也归那一份管 +pub fn theirs(d: &detect::Detected, backups: &Path) -> bool { + d.adopted_at_ms.is_some() && !ours(d, backups) +} + +/// 另一个 ThinkWatch Lite 接管着它时,动它之前说的那一句 +pub fn other_instance(client: &str) -> Msg { + msg!( + "adopt.other_instance", client = client => + "{client} is connected by another ThinkWatch Lite. Restore it from the ThinkWatch Lite \ + that connected it." + ) +} + +/// 动之前先看一眼:另一个 ThinkWatch Lite 接管着的不动 +fn not_theirs(c: &Client, home: &Path, backups: &Path) -> Result<(), Msg> { + if theirs(&detect::detect_one(c, home), backups) { + return Err(other_instance(c.name)); + } + Ok(()) +} + +/// 此刻**这一份**接管着的客户端。 +pub fn adopted(home: &Path, backups: &Path) -> Vec { + all(home) + .into_iter() + .filter(|c| ours(&detect::detect_one(c, home), backups)) + .collect() +} + +/// 此刻由另一个 ThinkWatch Lite 接管着的客户端([`theirs`])。「全部还原」跳过它们, +/// 结果里列出来 +pub fn adopted_elsewhere(home: &Path, backups: &Path) -> Vec { all(home) .into_iter() - .filter(|c| detect::detect_one(c, home).adopted_at_ms.is_some()) + .filter(|c| theirs(&detect::detect_one(c, home), backups)) .collect() } @@ -724,27 +782,38 @@ pub fn adopted(home: &Path) -> Vec { /// 连着远程 core 时,它们的请求落到一个已经停了的网关上:客户端页把它们单独标出来, /// 切换确认里说有几个,「改为指向服务器」改的也正是这几个。指着别处的(已经改过去 /// 了、或者用户自己指到了别的机器)不在里面。 -pub fn adopted_on_this_machine(home: &Path) -> Vec<(Client, String)> { - pointing_here(home, all(home)) +pub fn adopted_on_this_machine(home: &Path, backups: &Path) -> Vec<(Client, String)> { + pointing_here(home, backups, all(home)) } /// [`adopted_on_this_machine`],WSL 里的那一份:只看第一批的那几个 /// (`tw_adopt::wsl::CLIENTS`)。本机时 WSL 里的客户端写的也是 `127.0.0.1`, /// 指的是 Windows 上的网关 -pub fn adopted_on_this_machine_wsl(w: &tw_adopt::wsl::WslHome) -> Vec<(Client, String)> { +pub fn adopted_on_this_machine_wsl( + w: &tw_adopt::wsl::WslHome, + backups: &Path, +) -> Vec<(Client, String)> { pointing_here( &w.home, + backups, all(&w.home) .into_iter() .filter(|c| tw_adopt::wsl::CLIENTS.contains(&c.id)), ) } -fn pointing_here(home: &Path, cs: impl IntoIterator) -> Vec<(Client, String)> { +/// 另一个 ThinkWatch Lite 接管的不算:改为指向服务器也不该改它们 +fn pointing_here( + home: &Path, + backups: &Path, + cs: impl IntoIterator, +) -> Vec<(Client, String)> { cs.into_iter() .filter_map(|c| { let d = detect::detect_one(&c, home); - d.adopted_at_ms?; + if !ours(&d, backups) { + return None; + } let endpoint = d.endpoint?; is_loopback(&endpoint).then_some((c, endpoint)) }) @@ -778,11 +847,19 @@ pub fn host_port(endpoint: &str) -> Option<&str> { /// 这把密钥是为某个客户端生成的,而那个客户端此刻正被接管着吗。返回那个客户端。 /// /// 接管状态在对方配置旁边的记录里,读它要走文件系统 —— 所以这件事只有这台机器 -/// 答得上来,core 答不上 -pub fn adopted_owner(home: &Path, keys: &[ClientView], key: &str) -> Option { +/// 答得上来,core 答不上。 +/// +/// 只算**这一份**接管着的:另一个 ThinkWatch Lite 接管的,配置里写的是那一份的密钥, +/// 删、换这一份的密钥都碍不着它 +pub fn adopted_owner( + home: &Path, + backups: &Path, + keys: &[ClientView], + key: &str, +) -> Option { let id = keys.iter().find(|k| k.name == key)?.client.as_deref()?; let c = find(id, home).ok()?; - detect::detect_one(&c, home).adopted_at_ms.map(|_| c) + ours(&detect::detect_one(&c, home), backups).then_some(c) } /// 接管着的这个客户端里还写着这把密钥:先还原它,才能删这把密钥。和 core 以前 @@ -810,6 +887,13 @@ pub fn repoint( around: &Around, ) -> Result { let c = &c.clone().here(home); + if let Err(error) = not_theirs(c, home, backups) { + return Err(wire::KeySyncFailed { + client: c.id.to_string(), + name: c.name.to_string(), + error, + }); + } let target = clients::Gateway { base: base.to_string(), key: Some(key.to_string()), @@ -875,6 +959,7 @@ pub(crate) mod tests { let home = home_with_claude(); let r = list( home.path(), + &backups(&home), &gw(vec![key("default", "tw-d", None, true)]), &BTreeMap::new(), ); @@ -894,6 +979,7 @@ pub(crate) mod tests { mine.last_seen_ms = Some(42); let r = list( home.path(), + &backups(&home), &gw(vec![key("default", "tw-d", None, true), mine]), &BTreeMap::new(), ); @@ -911,6 +997,7 @@ pub(crate) mod tests { let home = tempfile::tempdir().unwrap(); let r = list( home.path(), + &backups(&home), &gw(vec![key("default", "tw-d", None, true)]), &BTreeMap::new(), ); @@ -1208,7 +1295,11 @@ pub(crate) mod tests { assert_eq!(a.takes_effect, wire::TakesEffect::Immediately); let written = std::fs::read_to_string(&settings).unwrap(); assert!(written.contains("tw-new") && written.contains("127.0.0.1:8788")); - assert!(adopted(home.path()).iter().any(|c| c.id == "claude-code")); + assert!( + adopted(home.path(), &backups(&home)) + .iter() + .any(|c| c.id == "claude-code") + ); let keys = vec![key("claude-code", "tw-new", Some("claude-code"), false)]; let p = plan_restore(home.path(), "claude-code", &keys).unwrap(); @@ -1227,7 +1318,7 @@ pub(crate) mod tests { serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); let want: serde_json::Value = serde_json::from_str(original).unwrap(); assert_eq!(back, want); - assert!(adopted(home.path()).is_empty()); + assert!(adopted(home.path(), &backups(&home)).is_empty()); } /// opencode 的模型清单跟网关对不上了才提示更新;顺序不算,没接管的不提示 @@ -1243,7 +1334,7 @@ pub(crate) mod tests { )]) }; let stale = |models: &BTreeMap>| { - list(home.path(), &gw(keys.clone()), models) + list(home.path(), &backups(&home), &gw(keys.clone()), models) .clients .into_iter() .find(|c| c.id == "opencode") @@ -1270,7 +1361,12 @@ pub(crate) mod tests { assert!(!stale(&BTreeMap::new()), "问不到网关就不说"); // 手动配置那一栏照网关此刻答的写 - let r = list(home.path(), &gw(keys.clone()), &now(&["m1"])); + let r = list( + home.path(), + &backups(&home), + &gw(keys.clone()), + &now(&["m1"]), + ); let oc = r.clients.iter().find(|c| c.id == "opencode").unwrap(); assert!( oc.manual @@ -1296,7 +1392,7 @@ pub(crate) mod tests { )]) }; let stale = |models: &BTreeMap>| { - list(home.path(), &gw(keys.clone()), models) + list(home.path(), &backups(&home), &gw(keys.clone()), models) .clients .into_iter() .find(|c| c.id == id) @@ -1357,7 +1453,7 @@ pub(crate) mod tests { key("default", "tw-d", None, true), key("claude-code", "tw-c", Some("claude-code"), false), ]; - assert!(adopted_owner(home.path(), &keys, "claude-code").is_none()); + assert!(adopted_owner(home.path(), &backups(&home), &keys, "claude-code").is_none()); adopt( home.path(), &backups(&home), @@ -1367,10 +1463,10 @@ pub(crate) mod tests { &Around::default(), ) .unwrap(); - let owner = adopted_owner(home.path(), &keys, "claude-code").unwrap(); + let owner = adopted_owner(home.path(), &backups(&home), &keys, "claude-code").unwrap(); assert_eq!(owner.id, "claude-code"); assert_eq!(key_used_by(owner.name).code, "control.key_used_by_client"); - assert!(adopted_owner(home.path(), &keys, "default").is_none()); + assert!(adopted_owner(home.path(), &backups(&home), &keys, "default").is_none()); } #[test] @@ -1420,7 +1516,7 @@ pub(crate) mod tests { &Around::default(), ) .unwrap(); - let here: Vec<_> = adopted_on_this_machine(home.path()) + let here: Vec<_> = adopted_on_this_machine(home.path(), &backups(&home)) .into_iter() .map(|(c, e)| (c.id, e)) .collect(); @@ -1459,7 +1555,8 @@ pub(crate) mod tests { /// 这一份发的那把,不是这台电脑上同一个客户端的那把 #[test] fn a_wsl_distro_lists_its_own_copies_with_their_own_keys() { - let (_d, w) = wsl_home(); + let (d, w) = wsl_home(); + let b = d.path().join("backups"); let mut mine = key( "claude-code-wsl-ubuntu", "tw-w", @@ -1475,7 +1572,7 @@ pub(crate) mod tests { mine, ], }; - let r = list_wsl(&w, &gw); + let r = list_wsl(&w, &b, &gw); let ids: Vec<_> = r.iter().map(|c| c.id.as_str()).collect(); assert_eq!(ids, ["claude-code", "codex"]); let cc = &r[0]; @@ -1535,6 +1632,7 @@ pub(crate) mod tests { .unwrap(); let listed = list_wsl( &w, + &b, &Gateway { base: "http://127.0.0.1:8788".into(), keys: Vec::new(), @@ -1552,7 +1650,7 @@ pub(crate) mod tests { serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap(); let want: serde_json::Value = serde_json::from_str(original).unwrap(); assert_eq!(back, want); - assert!(adopted(&w.home).is_empty()); + assert!(adopted(&w.home, &b).is_empty()); } /// 按 NAT 的做法接管过的(指着 WSL 虚拟网卡的地址)不迁移:列出来的就是它 @@ -1574,14 +1672,14 @@ pub(crate) mod tests { base: "http://127.0.0.1:8788".into(), keys: Vec::new(), }; - let cc = list_wsl(&w, &gw) + let cc = list_wsl(&w, &b, &gw) .into_iter() .find(|c| c.id == "claude-code") .unwrap(); assert!(cc.adopted_at_ms.is_some()); assert_eq!(cc.endpoint.as_deref(), Some("http://172.27.96.1:8788")); restore(&w.home, &b, "claude-code", None).unwrap(); - assert!(adopted(&w.home).is_empty()); + assert!(adopted(&w.home, &b).is_empty()); } /// 换了密钥之后重新指一次:新值写进它的配置 @@ -1710,7 +1808,7 @@ pub(crate) mod tests { let e = adopt_now(&shown.digest).unwrap_err(); assert_eq!(e.code, "adopt.plan.stale"); assert_eq!(std::fs::read_to_string(&settings).unwrap(), changed); - assert!(adopted(home.path()).is_empty()); + assert!(adopted(home.path(), &backups(&home)).is_empty()); // 重新算一份给人看,照那一份就写得进去 let again = plan_adopt( @@ -1723,7 +1821,7 @@ pub(crate) mod tests { .unwrap(); assert_ne!(again.digest, shown.digest); adopt_now(&again.digest).unwrap(); - assert_eq!(adopted(home.path()).len(), 1); + assert_eq!(adopted(home.path(), &backups(&home)).len(), 1); // 还原也一样 let shown = plan_restore(home.path(), "claude-code", &[]).unwrap(); @@ -1748,6 +1846,6 @@ pub(crate) mod tests { Some(&again.digest), ) .unwrap(); - assert!(adopted(home.path()).is_empty()); + assert!(adopted(home.path(), &backups(&home)).is_empty()); } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 25346d3f..c061c1fc 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -20,6 +20,8 @@ pub fn app_info(app: tauri::AppHandle) -> serde_json::Value { "version": app.package_info().version.to_string(), "identifier": app.config().identifier, "data_dir": data_dir().display().to_string(), + // 绿色版(见 `portable`):卸载一节按它说最后一步是删文件夹、还是走系统卸载 + "portable": crate::portable::is_portable(), // core 二进制的实际位置。找不到的时候把错误原样给出来 —— // 那条错误里列着找过哪些位置,正是这时候要看的东西。 "core_bin": match locate_core(&app) { diff --git a/src-tauri/src/uninstall.rs b/src-tauri/src/uninstall.rs index 4a690758..85ff8ea2 100644 --- a/src-tauri/src/uninstall.rs +++ b/src-tauri/src/uninstall.rs @@ -1,14 +1,31 @@ //! 还原全部接管,以及卸载。 +//! +//! 卸载有两个入口:应用里的「卸载」([`uninstall`]),和 Windows 上系统的卸载程序 +//! (`cleanup`:`--uninstall-cleanup`,不起界面)。**两边走同一套步骤** —— +//! 还原客户端([`restore_everywhere`]、[`restore_steps`])、删数据目录([`drop_data`])、 +//! 最后那一句([`last_line`])—— 只在「core 怎么停」「开机自启怎么注销」上各走各的: +//! 应用里有守护和插件可用,卸载程序那边应用已经被关掉了。 -use tauri::Manager; +use std::path::Path; +use std::time::Duration; -use crate::{autostart, data_dir, error::Out, wire::UninstallStep}; +#[cfg(not(windows))] +use crate::autostart; +use crate::{data_dir, error::Out, wire::UninstallStep}; /// 把所有接管过的客户端一次性还原(第二层的第二个入口)。 /// /// **这个按钮要一直看得见。**用户敢按下「接管」的前提,就是看得见退路 /// —— 藏起来的退路等于没有退路,他会在心里给接管打上「不可逆」的标签。 /// +/// 整个放在阻塞线程上:每个发行版要唤醒好几秒,逐个读写它们的文件 +#[tauri::command] +pub async fn restore_all() -> Out> { + crate::clients::blocking(|| restore_everywhere(&tw_adopt::foreign::backup_root())).await +} + +/// 还原这一份接管着的每一个客户端:这台电脑上的,和每个 WSL 发行版里的。阻塞。 +/// /// **一家失败不影响别家。**逐个还原、逐个记结果:五个客户端里有一个的 /// 文件被改坏了,不该让另外四个也留在接管状态。**也不问 core**:退路不该 /// 依赖网关还在不在。 @@ -16,132 +33,129 @@ use crate::{autostart, data_dir, error::Out, wire::UninstallStep}; /// **WSL 里的也还原。**每个发行版都要读一遍(会把它们唤醒),读不到的那一个记一条 /// 失败:它里面有没有接管着的客户端,这时说不上来,不能当成「没有」。 /// -/// 整个放在阻塞线程上:每个发行版要唤醒好几秒,逐个读写它们的文件 -#[tauri::command] -pub async fn restore_all() -> Out> { - crate::clients::blocking(|| { - let backups = tw_adopt::foreign::backup_root(); - let mut out = restore_all_in(&crate::clients::home_dir(), &backups, |n| n.to_string()); - for d in crate::clients::wsl::distros() { - let name = d.name.clone(); - match crate::clients::wsl::open(d) { - Ok(w) => out.extend(restore_all_in(&w.home, &backups, |n| { - crate::clients::wsl::display_name(n, &w) - })), - Err(e) => out.push(RestoreOutcome { - client: crate::clients::wsl::place_name(&name), - ok: false, - detail: crate::core_text::text(&e), - }), - } +/// **另一个 ThinkWatch Lite 接管的跳过**(`clients::ops::theirs`),结果里列出来、 +/// 标成跳过:它的备份在那一份的数据目录里,该由那一份还原。 +/// +/// `backups` 是这一份的备份目录(`tw_adopt::foreign::backup_root`),测试里换成临时的 +pub fn restore_everywhere(backups: &Path) -> Vec { + let mut out = restore_all_in(&crate::clients::home_dir(), backups, |n| n.to_string()); + for d in crate::clients::wsl::distros() { + let name = d.name.clone(); + match crate::clients::wsl::open(d) { + Ok(w) => out.extend(restore_all_in(&w.home, backups, |n| { + crate::clients::wsl::display_name(n, &w) + })), + Err(e) => out.push(RestoreOutcome { + client: crate::clients::wsl::place_name(&name), + ok: false, + skipped: false, + detail: crate::core_text::text(&e), + }), } - out - }) - .await + } + out } fn restore_all_in( - home: &std::path::Path, - backups: &std::path::Path, + home: &Path, + backups: &Path, name: impl Fn(&str) -> String, ) -> Vec { use crate::clients::ops; - ops::adopted(home) + let restored = ops::adopted(home, backups).into_iter().map(|c| { + let r = ops::restore(home, backups, c.id, None); + RestoreOutcome { + client: name(c.name), + ok: r.is_ok(), + skipped: false, + detail: match r { + Ok(_) => tr!("已还原", "Restored").to_string(), + Err(e) => crate::core_text::text(&e), + }, + } + }); + let skipped = ops::adopted_elsewhere(home, backups) .into_iter() - .map(|c| { - let r = ops::restore(home, backups, c.id, None); - RestoreOutcome { - client: name(c.name), - ok: r.is_ok(), - detail: match r { - Ok(_) => tr!("已还原", "Restored").to_string(), - Err(e) => crate::core_text::text(&e), - }, - } - }) - .collect() + .map(|c| RestoreOutcome { + client: name(c.name), + ok: true, + skipped: true, + detail: tr!( + "由另一个 ThinkWatch Lite 接管,需在接管它的 ThinkWatch Lite 中还原", + "Connected by another ThinkWatch Lite. Restore it from the ThinkWatch Lite that connected it" + ) + .to_string(), + }); + restored.chain(skipped).collect() } -#[derive(serde::Serialize)] +#[derive(Debug, serde::Serialize)] pub struct RestoreOutcome { client: String, ok: bool, + /// 另一个 ThinkWatch Lite 接管的,没动它。**不算失败**:这一份没有它的备份, + /// 也就谈不上还原不了 + skipped: bool, detail: String, } -/// 完全卸载(第二层的第三个入口)。 -/// -/// 顺序是**先还原、再注销自启、最后才提删数据** —— 反过来的话,中途 -/// 失败会留下一个「客户端还指着一个已经不在的端口」的状态,而那正是 -/// 这一整节要防的事。 -/// -/// **我们不删自己。**macOS 上应用删除没有钩子,也不该由应用自己动手 —— -/// 最后一句话是「可以把应用拖进废纸篓了」,那一下由用户来。 -/// -/// 每一步交回做成了没有(`UninstallStep`),一步没做成不影响后面的步骤。 -/// -/// **`.wslconfig` 不改回。**客户端页上改成 mirrored 的那一项是 WSL 自己的网络设置, -/// 改的时候是为了接管,改完之后别的东西也可能用上了它;卸载确认框里说了这件事 -/// (`clients::wsl::kept`),修改前的全文备份留在备份目录里。 -#[tauri::command] -pub async fn uninstall(app: tauri::AppHandle, drop_data: bool) -> Out> { - let mut log = Vec::new(); - let restored = restore_all().await?; - let restored_all = restored.iter().all(|r| r.ok); - for r in restored { - log.push(UninstallStep { +/// 还原的结果写成卸载的步骤,一个客户端一行。第二个值:这一份接管的是不是全部 +/// 还原了(跳过的不算没还原,见 [`RestoreOutcome::skipped`]) +pub fn restore_steps(restored: Vec) -> (Vec, bool) { + let all = restored.iter().all(|r| r.ok); + let log = restored + .into_iter() + .map(|r| UninstallStep { ok: r.ok, text: tr!( format!("{}:{}", r.client, r.detail), format!("{}: {}", r.client, r.detail) ), - }); - } - // 注销 LaunchAgent。**失败只记一句**:它不该挡住卸载,而留下一个 - // 开机自启项的后果,用户在系统设置里看得见、也删得掉 - let launcher = autostart::launcher(&app); - match launcher.disable() { - Ok(_) => log.push(UninstallStep::done(tr!( - "已取消开机启动", - "Launch at login turned off" - ))), - // 退路按平台说:自启项在哪儿、用户去哪儿关,三处各不相同 - #[cfg(target_os = "macos")] - Err(e) => log.push(UninstallStep::failed(tr!( - format!( - "未能取消开机启动({e})。请在「系统设置 › 通用 › 登录项」中关闭 ThinkWatch Lite。" - ), - format!( - "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in System Settings › General › Login Items." - ) - ))), - #[cfg(windows)] - Err(e) => log.push(UninstallStep::failed(tr!( - format!("未能取消开机启动({e})。请在「设置 › 应用 › 启动」中关闭 ThinkWatch Lite。"), - format!( - "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in Settings › Apps › Startup." - ) - ))), - // 各家桌面的「开机启动的应用」设置不在同一个地方,文件在哪儿却是确定的 - #[cfg(target_os = "linux")] - Err(e) => { - let file = launcher.file().display(); - log.push(UninstallStep::failed(tr!( - format!("未能取消开机启动({e})。请删除 {file}。"), - format!("Launch at login could not be turned off ({e}). Delete {file}.") - ))) - } + }) + .collect(); + (log, all) +} + +/// 删数据目录要试几次、隔多久。 +/// +/// **core 退出要几秒。**系统卸载程序先关掉应用,core 靠 `--parent` 守望发现父进程没了 +/// 才退(两秒看一次);在那之前它开着数据库,Windows 上删到一半就会失败。应用里卸载 +/// 先停了 core,只是刚退出的进程放手文件还要一小会儿 +#[derive(Debug, Clone, Copy)] +pub struct Retry { + pub tries: u32, + pub pause: Duration, +} + +/// 应用里卸载:core 已经停了,只等文件放手 +pub const IN_APP: Retry = Retry { + tries: 5, + pause: Duration::from_millis(200), +}; + +/// 删数据目录(`drop_data` 为真时)。 +/// +/// **有客户端没还原成,数据目录就不删**(`restored_all` 为假)。它还指着本机的网关, +/// 而它的全文备份就在这个目录里:这时删掉,它剩下的只是一份指着不存在的端口的配置、 +/// 再没有退路 —— 正是「先还原、最后才删数据」这个顺序要防的那种状态。 +/// +/// `keep`:留下不删的一个子目录(绿色版在应用里卸载时,正开着的界面占着的 +/// `data\webview`;之后删整个文件夹时它跟着走)。 +pub fn drop_data( + dir: &Path, + drop_data: bool, + restored_all: bool, + keep: Option<&Path>, + retry: Retry, +) -> UninstallStep { + if !drop_data { + return UninstallStep::done(tr!( + format!("数据目录已保留:{}", dir.display()), + format!("Data directory kept: {}", dir.display()) + )); } - // AppImage 每次启动写的菜单条目和图标(见 `desktop_entry`)。删不掉的 - // 说出是哪个文件;这之后应用还开着,重新启动会再写一份 - #[cfg(target_os = "linux")] - log.extend(crate::desktop_entry::remove(&app)); - if drop_data && !restored_all { - // **有客户端没还原成,数据目录就不删。**它还指着本机的网关,而它的全文备份就在 - // 这个目录里:这时删掉,它剩下的只是一份指着不存在的端口的配置、再没有退路 —— - // 正是「先还原、最后才删数据」这个顺序要防的那种状态 - let dir = data_dir(); - log.push(UninstallStep::failed(tr!( + if !restored_all { + return UninstallStep::failed(tr!( format!( "数据目录已保留:{}。上面有客户端没能还原,它们的备份在这个目录里;处理好之后再删除它", dir.display() @@ -150,59 +164,175 @@ pub async fn uninstall(app: tauri::AppHandle, drop_data: bool) -> Out() { - st.supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; + )); + } + match remove_retrying(dir, keep, retry) { + Ok(()) => UninstallStep::done(tr!( + format!("数据目录已删除:{}", dir.display()), + format!("Data directory deleted: {}", dir.display()) + )), + Err(e) => UninstallStep::failed(tr!( + format!("未能删除数据目录:{}({e})", dir.display()), + format!( + "The data directory could not be deleted: {} ({e})", + dir.display() + ) + )), + } +} + +/// 删 `dir`(留下 `keep`),删不掉就隔一会儿再试,至多 `retry.tries` 次。本来就没有算删掉了 +fn remove_retrying(dir: &Path, keep: Option<&Path>, retry: Retry) -> std::io::Result<()> { + let mut left = retry.tries.max(1); + loop { + left -= 1; + match remove(dir, keep) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) if left > 0 => std::thread::sleep(retry.pause), + r => return r, } - let dir = data_dir(); - match std::fs::remove_dir_all(&dir) { - Ok(_) => log.push(UninstallStep::done(tr!( - format!("数据目录已删除:{}", dir.display()), - format!("Data directory deleted: {}", dir.display()) - ))), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => log.push(UninstallStep::failed(tr!( - format!("未能删除数据目录:{}({e})", dir.display()), - format!( - "The data directory could not be deleted: {} ({e})", - dir.display() - ) - ))), + } +} + +fn remove(dir: &Path, keep: Option<&Path>) -> std::io::Result<()> { + let Some(keep) = keep.filter(|k| k.parent() == Some(dir)) else { + return std::fs::remove_dir_all(dir); + }; + // 一项一项删,删不掉的记下第一个原因、接着删别的 + let mut first = None; + for e in std::fs::read_dir(dir)? { + let p = e?.path(); + if p == keep { + continue; + } + let r = if p.is_dir() && !p.is_symlink() { + std::fs::remove_dir_all(&p) + } else { + std::fs::remove_file(&p) + }; + if let Err(e) = r { + first.get_or_insert(e); } - } else { - log.push(UninstallStep::done(tr!( - format!("数据目录已保留:{}", data_dir().display()), - format!("Data directory kept: {}", data_dir().display()) - ))); } - // 「废纸篓」只是 macOS 的说法;Windows 上走系统的卸载,Linux 上就是删掉 - // 那个 AppImage 文件 + first.map_or(Ok(()), Err) +} + +/// 最后一句:现在可以把应用本身删掉了。**我们不删自己** —— macOS 上应用删除没有 +/// 钩子,也不该由应用自己动手;那一下由用户来。 +/// +/// 「废纸篓」只是 macOS 的说法;Windows 上安装版走系统的卸载,绿色版删掉整个文件夹 +/// (数据也在里面),Linux 上就是删掉那个 AppImage 文件 +pub fn last_line() -> String { #[cfg(target_os = "macos")] - let last: String = tr!( + let last = tr!( "现可将应用移到废纸篓。", "The app can now be moved to the Trash." ) .into(); #[cfg(windows)] - let last: String = tr!( - "现可卸载或删除应用。", - "The app can now be uninstalled or deleted." - ) - .into(); + let last = if crate::portable::is_portable() { + tr!( + "卸载步骤已完成,现可删除整个文件夹。", + "Uninstall steps are complete. The whole folder can now be deleted." + ) + .into() + } else { + tr!( + "现可卸载或删除应用。", + "The app can now be uninstalled or deleted." + ) + .into() + }; #[cfg(target_os = "linux")] - let last: String = match crate::desktop_entry::appimage() { + let last = match crate::desktop_entry::appimage() { Some(file) => tr!( format!("现可删除 AppImage 文件:{}", file.display()), format!("The AppImage file can now be deleted: {}", file.display()) ), None => tr!("现可删除应用。", "The app can now be deleted.").into(), }; - log.push(UninstallStep::done(last)); + last +} + +/// 完全卸载(第二层的第三个入口)。 +/// +/// 顺序是**先还原、再注销自启、最后才提删数据** —— 反过来的话,中途 +/// 失败会留下一个「客户端还指着一个已经不在的端口」的状态,而那正是 +/// 这一整节要防的事。 +/// +/// 每一步交回做成了没有(`UninstallStep`),一步没做成不影响后面的步骤。 +/// +/// **`.wslconfig` 不改回。**客户端页上改成 mirrored 的那一项是 WSL 自己的网络设置, +/// 改的时候是为了接管,改完之后别的东西也可能用上了它;卸载确认框里说了这件事 +/// (`clients::wsl::kept`),修改前的全文备份留在备份目录里。 +#[tauri::command] +pub async fn uninstall(app: tauri::AppHandle, drop_data: bool) -> Out> { + use tauri::Manager; + let (mut log, restored_all) = restore_steps(restore_all().await?); + // 注销 LaunchAgent。**失败只记一句**:它不该挡住卸载,而留下一个 + // 开机自启项的后果,用户在系统设置里看得见、也删得掉 + #[cfg(not(windows))] + { + let launcher = autostart::launcher(&app); + match launcher.disable() { + Ok(_) => log.push(UninstallStep::done(tr!( + "已取消开机启动", + "Launch at login turned off" + ))), + // 退路按平台说:自启项在哪儿、用户去哪儿关,各不相同 + #[cfg(target_os = "macos")] + Err(e) => log.push(UninstallStep::failed(tr!( + format!( + "未能取消开机启动({e})。请在「系统设置 › 通用 › 登录项」中关闭 ThinkWatch Lite。" + ), + format!( + "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in System Settings › General › Login Items." + ) + ))), + // 各家桌面的「开机启动的应用」设置不在同一个地方,文件在哪儿却是确定的 + #[cfg(target_os = "linux")] + Err(e) => { + let file = launcher.file().display(); + log.push(UninstallStep::failed(tr!( + format!("未能取消开机启动({e})。请删除 {file}。"), + format!("Launch at login could not be turned off ({e}). Delete {file}.") + ))) + } + } + } + // Windows 上开机自启、`thinkwatch://` 链接、通知登记都在 HKCU 里,指着正在运行的 + // 这个 exe(见 `winreg`):和系统卸载程序走同一步,删指着自己的那几项、各记一行 + #[cfg(windows)] + match std::env::current_exe() { + Ok(exe) => log.extend(crate::winreg::release_all(&exe)), + Err(e) => log.push(UninstallStep::failed(tr!( + format!("未能确定程序位置,注册项未清理({e})"), + format!("The program location could not be determined, so its registry entries were left in place ({e})") + ))), + } + // AppImage 每次启动写的菜单条目和图标(见 `desktop_entry`)。删不掉的 + // 说出是哪个文件;这之后应用还开着,重新启动会再写一份 + #[cfg(target_os = "linux")] + log.extend(crate::desktop_entry::remove(&app)); + if drop_data && restored_all { + // 先停掉本机的 core:它开着数据库和日志,Windows 上删到一半就会失败,而且留下 + // 一个没有数据目录还在跑的网关。连着远程时它本来就停着,这一步什么都不做 + if let Some(st) = app.try_state::() { + st.supervisor + .stop_and_wait(std::time::Duration::from_secs(5)) + .await; + } + } + // 绿色版的界面缓存在数据目录里(`data\webview`),开着的窗口正占着它 + let keep = crate::portable::webview_data_dir(); + let dir = data_dir(); + let step = tokio::task::spawn_blocking(move || { + self::drop_data(&dir, drop_data, restored_all, keep.as_deref(), IN_APP) + }) + .await + .map_err(|e| crate::error::CmdError::plain(e.to_string()))?; + log.push(step); + log.push(UninstallStep::done(last_line())); Ok(log) } @@ -231,6 +361,127 @@ mod tests { assert_eq!(out.len(), 1); assert_eq!(out[0].client, "Claude Code"); assert!(out[0].ok, "{}", out[0].detail); - assert!(ops::adopted(home.path()).is_empty()); + assert!(!out[0].skipped); + assert!(ops::adopted(home.path(), &backups).is_empty()); + } + + /// 另一个 ThinkWatch Lite(备份在它自己的数据目录里)接管的:不动它,结果里列出来、 + /// 标成跳过,**不算失败** —— 卸载照样能删这一份的数据目录 + #[test] + fn a_client_taken_over_by_another_instance_is_skipped_and_named() { + use crate::clients::ops; + let home = tempfile::tempdir().unwrap(); + let (mine, theirs) = (home.path().join("a/backups"), home.path().join("b/backups")); + std::fs::create_dir_all(home.path().join(".claude")).unwrap(); + std::fs::create_dir_all(home.path().join(".codex")).unwrap(); + let gw = + |key: &str| tw_adopt::clients::Gateway::keyed("http://127.0.0.1:8788", key, Vec::new()); + let around = tw_adopt::cloud::Around::default(); + ops::adopt( + home.path(), + &theirs, + "claude-code", + &gw("tw-b"), + None, + &around, + ) + .unwrap(); + ops::adopt(home.path(), &mine, "codex", &gw("tw-a"), None, &around).unwrap(); + let settings = home.path().join(".claude/settings.json"); + let before = std::fs::read_to_string(&settings).unwrap(); + + let out = restore_all_in(home.path(), &mine, |n| n.to_string()); + let got: Vec<_> = out + .iter() + .map(|r| (r.client.as_str(), r.ok, r.skipped)) + .collect(); + assert_eq!(got, [("Codex", true, false), ("Claude Code", true, true)]); + assert_eq!( + std::fs::read_to_string(&settings).unwrap(), + before, + "别人接管的没被动过" + ); + // 那一份照样能还原它 + assert_eq!(ops::adopted(home.path(), &theirs).len(), 1); + // 跳过的不挡删数据:这一份没有它的备份 + let (log, all) = restore_steps(out); + assert!(all); + assert!(log[1].text.contains("Claude Code"), "{}", log[1].text); + // 从这一份直接还原、接管它,都拦下来 + let e = ops::restore(home.path(), &mine, "claude-code", None).unwrap_err(); + assert_eq!(e.code, "adopt.other_instance"); + let e = ops::adopt( + home.path(), + &mine, + "claude-code", + &gw("tw-a"), + None, + &around, + ) + .unwrap_err(); + assert_eq!(e.code, "adopt.other_instance"); + assert_eq!(std::fs::read_to_string(&settings).unwrap(), before); + } + + /// 有一个没还原成就算没全部还原:数据目录要留着 + #[test] + fn one_failed_restore_means_not_all_restored() { + let r = |ok, skipped| RestoreOutcome { + client: "X".into(), + ok, + skipped, + detail: String::new(), + }; + assert!(restore_steps(vec![r(true, false), r(true, true)]).1); + let (log, all) = restore_steps(vec![r(true, false), r(false, false)]); + assert!(!all); + assert_eq!(log.iter().filter(|s| !s.ok).count(), 1); + } + + const ONCE: Retry = Retry { + tries: 1, + pause: Duration::ZERO, + }; + + /// 数据目录:没勾就留着;勾了但有客户端没还原成也留着(它们的备份在里面); + /// 都还原了才删。本来就没有的算删掉了 + #[test] + fn the_data_directory_goes_only_when_asked_and_every_client_is_back() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("ThinkWatch"); + std::fs::create_dir_all(dir.join("backups")).unwrap(); + std::fs::write(dir.join("config.yaml"), "x").unwrap(); + + let kept = drop_data(&dir, false, true, None, ONCE); + assert!(kept.ok && dir.exists(), "{}", kept.text); + let held = drop_data(&dir, true, false, None, ONCE); + assert!(!held.ok && dir.exists(), "{}", held.text); + let gone = drop_data(&dir, true, true, None, ONCE); + assert!(gone.ok && !dir.exists(), "{}", gone.text); + assert!(drop_data(&dir, true, true, None, ONCE).ok, "已经没有了"); + } + + /// 绿色版在应用里卸载:开着的界面占着的 `webview` 留下,别的都删 + #[test] + fn the_folder_in_use_is_left_and_everything_else_goes() { + let d = tempfile::tempdir().unwrap(); + let dir = d.path().join("data"); + let webview = dir.join("webview"); + std::fs::create_dir_all(&webview).unwrap(); + std::fs::create_dir_all(dir.join("backups/1-0000")).unwrap(); + std::fs::write(dir.join("config.yaml"), "x").unwrap(); + std::fs::write(webview.join("Local State"), "x").unwrap(); + + let step = drop_data(&dir, true, true, Some(&webview), ONCE); + assert!(step.ok, "{}", step.text); + let left: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(left, ["webview"]); + // 不在数据目录里的 `keep` 不算数:整个删 + let other = d.path().join("elsewhere"); + let step = drop_data(&dir, true, true, Some(&other), ONCE); + assert!(step.ok && !dir.exists(), "{}", step.text); } } diff --git a/src-tauri/src/webview2.rs b/src-tauri/src/webview2.rs index 4863aae0..b9c6b0c4 100644 --- a/src-tauri/src/webview2.rs +++ b/src-tauri/src/webview2.rs @@ -2,6 +2,70 @@ //! //! 没有它就开不了任何窗口。安装程序会替用户装上,绿色版不会;系统里被卸掉的 //! 情况两边都可能有。缺的时候用系统对话框说明,并给出微软的下载页。 +//! +//! **问的是框架自己用的那一个函数**(`tauri::webview_version`,Windows 上就是 WebView2 +//! 加载器的 `GetAvailableCoreWebView2BrowserVersionString`):建窗口时找运行时走的是 +//! 同一条路,它说有,窗口就开得出来。 + +/// 微软的 WebView2 运行时下载页,安装程序缺运行时时下载的也是它 +#[cfg(windows)] +const DOWNLOAD: &str = "https://go.microsoft.com/fwlink/p/?LinkId=2124703"; /// 缺 WebView2 时弹系统对话框并退出进程。不缺、或者不是 Windows,什么都不做。 -pub fn ensure() {} +pub fn ensure() { + #[cfg(windows)] + if missing(tauri::webview_version()) { + refuse(); + } +} + +/// 问到的版本说明没有运行时:报错,或者答了一个空的版本号(找不到运行时的时候, +/// 有的加载器版本不报错、只是什么都不填) +#[cfg_attr(not(windows), allow(dead_code))] +fn missing(version: Result) -> bool { + !matches!(version, Ok(v) if !v.trim().is_empty()) +} + +/// 说明缺什么、给下载页,然后退出。「前往下载」用默认浏览器打开微软的下载页 +#[cfg(windows)] +fn refuse() -> ! { + let picked = crate::dialog::show( + "ThinkWatch Lite", + tr!( + "缺少 Microsoft Edge WebView2 运行时", + "The Microsoft Edge WebView2 Runtime is missing" + ), + tr!( + "ThinkWatch Lite 无法启动。", + "ThinkWatch Lite cannot start." + ), + &[tr!("前往下载", "Download"), tr!("退出", "Quit")], + ); + if picked == Some(0) + && let Err(e) = tauri_plugin_opener::open_url(DOWNLOAD, None::<&str>) + { + tracing::warn!("打不开 WebView2 的下载页:{e}"); + } + std::process::exit(1); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_error_or_an_empty_version_means_the_runtime_is_missing() { + assert!(missing::<()>(Err(()))); + assert!(missing::<()>(Ok(String::new()))); + assert!(missing::<()>(Ok(" ".into()))); + assert!(!missing::<()>(Ok("131.0.2903.70".into()))); + } + + /// CI 的 Windows 机器上装着 WebView2:问得到版本号,`ensure` 放行 + #[cfg(windows)] + #[test] + fn the_runtime_on_this_machine_is_found() { + let v = tauri::webview_version(); + assert!(!missing(tauri::webview_version()), "{v:?}"); + } +} diff --git a/src-tauri/src/wire.rs b/src-tauri/src/wire.rs index 7730bf6e..376dd4b0 100644 --- a/src-tauri/src/wire.rs +++ b/src-tauri/src/wire.rs @@ -72,7 +72,11 @@ pub struct DetectedClient { pub real: String, pub installed: bool, pub has_config: bool, + /// **这一份**接管它的时间。另一个 ThinkWatch Lite 接管着的没有(见 `other_instance`) pub adopted_at_ms: Option, + /// 接管着它的是另一个 ThinkWatch Lite:安装版和绿色版各有各的数据目录,接管时的 + /// 备份不在这一份的数据目录里。这时不给还原、也不给接管,要在接管它的那一份里还原 + pub other_instance: bool, /// 配置里此刻的端点。**读出来的**,不是拿我们自己的记录充数 pub endpoint: Option, pub shadows: Vec, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index ac8c881d..fe13c147 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -11,7 +11,12 @@ "installMode": "perMachine", "installerIcon": "icons/icon.ico", "displayLanguageSelector": false, - "languages": ["English", "SimpChinese"] + "languages": ["English", "SimpChinese"], + "customLanguageFiles": { + "English": "windows/English.nsh", + "SimpChinese": "windows/SimpChinese.nsh" + }, + "installerHooks": "windows/hooks.nsh" }, "webviewInstallMode": { "type": "downloadBootstrapper" diff --git a/src-tauri/windows/English.nsh b/src-tauri/windows/English.nsh new file mode 100644 index 00000000..8e77e6bf --- /dev/null +++ b/src-tauri/windows/English.nsh @@ -0,0 +1,30 @@ +; 从 Tauri CLI 2.11.4 自带的同名语言文件原样复制(crates/tauri-bundler/src/bundle/windows/nsis/languages), +; 只改了 deleteAppData 那一句:卸载程序里那个勾选框删的是什么要说清楚(见 hooks.nsh)。 +; 升级 Tauri CLI 时和新版的对一遍:新版加的句子这里也要补上。 +LangString addOrReinstall ${LANG_ENGLISH} "Add/Reinstall components" +LangString alreadyInstalled ${LANG_ENGLISH} "Already Installed" +LangString alreadyInstalledLong ${LANG_ENGLISH} "${PRODUCTNAME} ${VERSION} is already installed. Select the operation you want to perform and click Next to continue." +LangString appRunning ${LANG_ENGLISH} "{{product_name}} is running! Please close it first then try again." +LangString appRunningOkKill ${LANG_ENGLISH} "{{product_name}} is running!$\nClick OK to kill it" +LangString chooseMaintenanceOption ${LANG_ENGLISH} "Choose the maintenance option to perform." +LangString choowHowToInstall ${LANG_ENGLISH} "Choose how you want to install ${PRODUCTNAME}." +LangString createDesktop ${LANG_ENGLISH} "Create desktop shortcut" +LangString dontUninstall ${LANG_ENGLISH} "Do not uninstall" +LangString dontUninstallDowngrade ${LANG_ENGLISH} "Do not uninstall (Downgrading without uninstall is disabled for this installer)" +LangString failedToKillApp ${LANG_ENGLISH} "Failed to kill {{product_name}}. Please close it first then try again" +LangString installingWebview2 ${LANG_ENGLISH} "Installing WebView2..." +LangString newerVersionInstalled ${LANG_ENGLISH} "A newer version of ${PRODUCTNAME} is already installed! It is not recommended that you install an older version. If you really want to install this older version, it's better to uninstall the current version first. Select the operation you want to perform and click Next to continue." +LangString older ${LANG_ENGLISH} "older" +LangString olderOrUnknownVersionInstalled ${LANG_ENGLISH} "An $R4 version of ${PRODUCTNAME} is installed on your system. It's recommended that you uninstall the current version before installing. Select the operation you want to perform and click Next to continue." +LangString silentDowngrades ${LANG_ENGLISH} "Downgrades are disabled for this installer, can't proceed with the silent installer, please use the graphical interface installer instead.$\n" +LangString unableToUninstall ${LANG_ENGLISH} "Unable to uninstall!" +LangString uninstallApp ${LANG_ENGLISH} "Uninstall ${PRODUCTNAME}" +LangString uninstallBeforeInstalling ${LANG_ENGLISH} "Uninstall before installing" +LangString unknown ${LANG_ENGLISH} "unknown" +LangString webview2AbortError ${LANG_ENGLISH} "Failed to install WebView2! The app can't run without it. Try restarting the installer." +LangString webview2DownloadError ${LANG_ENGLISH} "Error: Downloading WebView2 Failed - $0" +LangString webview2DownloadSuccess ${LANG_ENGLISH} "WebView2 bootstrapper downloaded successfully" +LangString webview2Downloading ${LANG_ENGLISH} "Downloading WebView2 bootstrapper..." +LangString webview2InstallError ${LANG_ENGLISH} "Error: Installing WebView2 failed with exit code $1" +LangString webview2InstallSuccess ${LANG_ENGLISH} "WebView2 installed successfully" +LangString deleteAppData ${LANG_ENGLISH} "Also delete data (configuration, API keys, request history)" diff --git a/src-tauri/windows/SimpChinese.nsh b/src-tauri/windows/SimpChinese.nsh new file mode 100644 index 00000000..a79527b1 --- /dev/null +++ b/src-tauri/windows/SimpChinese.nsh @@ -0,0 +1,30 @@ +; 从 Tauri CLI 2.11.4 自带的同名语言文件原样复制(crates/tauri-bundler/src/bundle/windows/nsis/languages), +; 只改了 deleteAppData 那一句:卸载程序里那个勾选框删的是什么要说清楚(见 hooks.nsh)。 +; 升级 Tauri CLI 时和新版的对一遍:新版加的句子这里也要补上。 +LangString addOrReinstall ${LANG_SIMPCHINESE} "添加/重新安装组件" +LangString alreadyInstalled ${LANG_SIMPCHINESE} "已安装" +LangString alreadyInstalledLong ${LANG_SIMPCHINESE} "${PRODUCTNAME} ${VERSION} 已经安装了。选择你想要执行的操作后点击下一步以继续。" +LangString appRunning ${LANG_SIMPCHINESE} "{{product_name}} 正在运行!请关闭后再试。" +LangString appRunningOkKill ${LANG_SIMPCHINESE} "{{product_name}} 正在运行!$\n点击确定以终止运行。" +LangString chooseMaintenanceOption ${LANG_SIMPCHINESE} "选择要执行的维护操作。" +LangString choowHowToInstall ${LANG_SIMPCHINESE} "选择你想要安装 ${PRODUCTNAME} 的方式。" +LangString createDesktop ${LANG_SIMPCHINESE} "创建桌面快捷方式" +LangString dontUninstall ${LANG_SIMPCHINESE} "请勿卸载" +LangString dontUninstallDowngrade ${LANG_SIMPCHINESE} "请勿卸载(此安装程序禁止未卸载就进行版本降级的操作)" +LangString failedToKillApp ${LANG_SIMPCHINESE} "无法终止 {{product_name}}。请关闭后再试。" +LangString installingWebview2 ${LANG_SIMPCHINESE} "正在安装 WebView2..." +LangString newerVersionInstalled ${LANG_SIMPCHINESE} "有一个更新版本的 ${PRODUCTNAME} 已经安装了!不推荐你安装旧的版本。如果你真的想要安装这个旧的版本,推荐先卸载当前版本。选择你想要执行的操作后点击下一步以继续。" +LangString older ${LANG_SIMPCHINESE} "旧的" +LangString olderOrUnknownVersionInstalled ${LANG_SIMPCHINESE} "系统中已存在版本为 $R4 的 ${PRODUCTNAME}。推荐先卸载当前版本后再进行安装。选择你想要执行的操作后点击下一步以继续。" +LangString silentDowngrades ${LANG_SIMPCHINESE} "降级操作在此安装程序上已禁用,无法进行安静安装,请使用图形操作界面。$\n" +LangString unableToUninstall ${LANG_SIMPCHINESE} "无法卸载!" +LangString uninstallApp ${LANG_SIMPCHINESE} "卸载 ${PRODUCTNAME}" +LangString uninstallBeforeInstalling ${LANG_SIMPCHINESE} "安装前卸载" +LangString unknown ${LANG_SIMPCHINESE} "未知" +LangString webview2AbortError ${LANG_SIMPCHINESE} "无法安装 WebView2!没有它,此应用就无法运行。尝试重启安装程序。" +LangString webview2DownloadError ${LANG_SIMPCHINESE} "错误:无法下载 WebView2 - $0" +LangString webview2DownloadSuccess ${LANG_SIMPCHINESE} "WebView2 引导程序下载成功" +LangString webview2Downloading ${LANG_SIMPCHINESE} "正在下载 WebView2 引导程序..." +LangString webview2InstallError ${LANG_SIMPCHINESE} "错误:安装 WebView2 时失败,错误代码:$1" +LangString webview2InstallSuccess ${LANG_SIMPCHINESE} "成功安装 WebView2" +LangString deleteAppData ${LANG_SIMPCHINESE} "同时删除数据(配置、API 密钥、请求记录)" diff --git a/src-tauri/windows/hooks.nsh b/src-tauri/windows/hooks.nsh new file mode 100644 index 00000000..459f105f --- /dev/null +++ b/src-tauri/windows/hooks.nsh @@ -0,0 +1,26 @@ +; 安装版的卸载程序钩子。Tauri 的 NSIS 模板(以 Tauri CLI 2.11.4 的 installer.nsi 为准) +; 在 `Section Uninstall` 的第一行插入 NSIS_HOOK_PREUNINSTALL,那时还一个文件都没删。 +; +; 卸载之前先做应用内「卸载」那几步:还原接管过的客户端(含 WSL 里的)、删掉指向这个 +; 程序的注册项,勾了「同时删除数据」再删数据目录。这些事只有应用自己知道怎么做,交给 +; 同一个 exe 的无界面模式 `--uninstall-cleanup`(src-tauri/src/cleanup.rs),等它做完。 +; +; 用到的几样都是模板里的:`$UpdateMode`、`$PassiveMode`(.onInit / un.onInit 里按 /UPDATE、 +; /P 设好)、`$DeleteAppDataCheckboxState`(确认页那个勾选框,离开确认页时读)、 +; `CheckIfAppIsRunning`(utils.nsh)、`MAINBINARYNAME` / `PRODUCTNAME`。宏体在插入的地方才 +; 展开,那时它们都已经声明过了。 + +!macro NSIS_HOOK_PREUNINSTALL + ; 自更新也走一遍卸载(/UPDATE):接管、注册项、数据都要留给新装上的那一版 + ${If} $UpdateMode <> 1 + ; 先关掉正在运行的程序:开着的时候它的 core 占着数据目录,它自己也会在启动时把 + ; 注册项改回指向自己。用户在提示框里取消就整个不卸载。模板紧接着会再查一次, + ; 那时已经没有在运行的了(两次插入的标签带着各自的行号,不会重名) + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + ${If} $DeleteAppDataCheckboxState = 1 + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-cleanup --delete-data' + ${Else} + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-cleanup' + ${EndIf} + ${EndIf} +!macroend diff --git a/src/clients/ClientStatus.tsx b/src/clients/ClientStatus.tsx index 24186c6c..3c4f72dc 100644 --- a/src/clients/ClientStatus.tsx +++ b/src/clients/ClientStatus.tsx @@ -12,6 +12,8 @@ const TONE: Record = { waiting: "warn", broken: "error", idle: "idle", + // 这一份的网关不认它配置里的那把密钥:要留意,但不是这一份能修好的 + other: "warn", absent: "idle", }; @@ -30,6 +32,7 @@ export function ClientStatus({ status, manual, live }: { status: Status; manual? waiting: t.waiting, broken: t.broken, idle: manual ? t.notSet : t.idle, + other: t.otherInstance, absent: t.absent, }[status.state]; return ( @@ -57,5 +60,7 @@ export function reasonText(reason: Reason | undefined, t: typeof clientsText.zh) return t.unreachable; case "elsewhere": return t.pointsTo(hostOf(reason.endpoint)); + case "other": + return t.otherInstanceHint; } } diff --git a/src/clients/ClientsPage.tsx b/src/clients/ClientsPage.tsx index 1ba1f345..296d41b4 100644 --- a/src/clients/ClientsPage.tsx +++ b/src/clients/ClientsPage.tsx @@ -193,9 +193,12 @@ export default function ClientsPage({ void restoreAllRun(async () => { const rs = await api.restoreAll(); const bad = rs.filter((r) => !r.ok); + // 另一个 ThinkWatch Lite 接管的跳过了:说是哪几个、该去哪儿还原 + const skipped = rs.filter((r) => r.skipped).map((r) => r.client); // **一个失败不影响其余的**,所以逐条报,不能只说「失败了」 - if (bad.length === 0) notify.success(t.restoredAll(rs.length)); + if (bad.length === 0) notify.success(t.restoredAll(rs.length - skipped.length)); else notify.error(t.restoreFailed(bad)); + if (skipped.length > 0) notify.info(t.restoreSkipped(skipped)); setDialog(null); await Promise.all([clients.reload(), groups.length > 0 ? wsl.reload() : undefined]); }); @@ -733,7 +736,7 @@ function Summary({ }) { const t = useText(clientsText); const counts = useMemo(() => { - const n: Record = { in_use: 0, waiting: 0, broken: 0, idle: 0, absent: 0 }; + const n: Record = { in_use: 0, waiting: 0, broken: 0, idle: 0, other: 0, absent: 0 }; if (!data) return n; const now = Date.now(); for (const c of data.clients) n[statusOf(c, data.gateway_base, now, remote).state] += 1; @@ -749,6 +752,10 @@ function Summary({ }, [data, groups, remote]); if (!data) return loading ? : null; const connected = counts.in_use + counts.waiting + counts.broken; + /** 另一个 ThinkWatch Lite 接管的:不算这一份接上的,两种情况下都单说 */ + const other = counts.other > 0 && ( + } value={} label={t.nOther} /> + ); if (connected === 0) { const detected = data.clients.filter((c) => c.installed).length + @@ -761,6 +768,7 @@ function Summary({ {t.nDetected(detected, )} )} + {other} ); } @@ -775,6 +783,7 @@ function Summary({ {counts.broken > 0 && ( } value={} label={t.nBroken} /> )} + {other} {counts.idle > 0 && ( } value={} label={t.nIdle} /> )} diff --git a/src/clients/ClientsTable.tsx b/src/clients/ClientsTable.tsx index cf24f06d..604b24a4 100644 --- a/src/clients/ClientsTable.tsx +++ b/src/clients/ClientsTable.tsx @@ -187,7 +187,8 @@ function DetectedRow({ c, ctx, className }: { c: DetectedClient; ctx: RowContext
- {!(c.managed && !adopted && !absent) && (adopted || reachable) && ( + {/* 另一个 ThinkWatch Lite 接管的:还原、接管都不在这里做 */} + {status.state !== "other" && !(c.managed && !adopted && !absent) && (adopted || reachable) && (