diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b2a5c1..74b8187c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,13 @@ on: pull_request: branches: [main, dev] +# **同一个分支、同一个 PR 上新的一次推送,把还在跑的旧一次取消掉。**结果只看最新的那次, +# 旧的跑完也没人看,只会占着并发名额(免费组织同时最多 20 个任务、其中 5 个 macOS)。 +# main 和 dev 上的不取消:那里每一笔都要有自己的结果。 +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }} + env: CARGO_TERM_COLOR: always @@ -138,6 +145,61 @@ jobs: - name: Test run: cargo test --manifest-path src-tauri/Cargo.toml + # **绿色版会被放在 U 盘和网络共享上。**它的自更新靠「正在运行的 exe 能改名、 + # 腾出来的名字上能写新的」(`portable::swap_in`),上一步只在 NTFS 上证明过。 + # 这里建两块小虚拟盘、格式化成 FAT32 和 exFAT,再开一个本机的 SMB 共享,把 + # portable 那一组测试(含改名正在运行的 exe 那一条)在这三处各跑一遍: + # `TW_PORTABLE_TEST_DIR` 指定测试文件夹建在哪。 + # + # diskpart、New-SmbShare 要管理员身份。GitHub 的 Windows 机器上步骤本来就以 + # 管理员身份运行(UAC 关着),先核实一遍,不是的话直接报错,不让它悄悄跳过。 + # 测试程序上一步已经编好,这里不重编 + - name: Portable tests on FAT32, exFAT and an SMB share + shell: pwsh + run: | + $me = [Security.Principal.WindowsIdentity]::GetCurrent() + if (-not ([Security.Principal.WindowsPrincipal]$me).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This step needs an elevated shell" + } + + $dirs = @() + foreach ($v in @(@{ fs = 'fat32'; letter = 'R' }, @{ fs = 'exfat'; letter = 'S' })) { + $vhd = Join-Path $env:RUNNER_TEMP "tw-$($v.fs).vhdx" + $script = Join-Path $env:RUNNER_TEMP "tw-$($v.fs).diskpart" + @( + "create vdisk file=""$vhd"" maximum=2048 type=expandable" + "select vdisk file=""$vhd""" + "attach vdisk" + "convert mbr" + "create partition primary" + "format fs=$($v.fs) quick label=TW$($v.fs.ToUpper())" + "assign letter=$($v.letter)" + ) | Set-Content -Encoding ascii $script + diskpart /s $script + if ($LASTEXITCODE -ne 0) { throw "diskpart could not make the $($v.fs) volume" } + $vol = Get-Volume -DriveLetter $v.letter + if ($vol.FileSystem -ne $v.fs) { throw "$($v.letter): is $($vol.FileSystem), not $($v.fs)" } + $dirs += "$($v.letter):\" + } + + $share = Join-Path $env:RUNNER_TEMP 'tw-share' + New-Item -ItemType Directory $share | Out-Null + New-SmbShare -Name tw-share -Path $share -FullAccess $me.Name | Out-Null + $dirs += '\\localhost\tw-share' + + Get-Volume -DriveLetter R, S | Format-Table DriveLetter, FileSystem, FileSystemLabel, Size + Get-SmbShare -Name tw-share | Format-Table Name, Path + + $failed = @() + foreach ($dir in $dirs) { + Write-Host "::group::portable tests in $dir" + $env:TW_PORTABLE_TEST_DIR = $dir + cargo test --manifest-path src-tauri/Cargo.toml --lib -- portable::tests --nocapture + if ($LASTEXITCODE -ne 0) { $failed += $dir } + Write-Host "::endgroup::" + } + if ($failed) { throw "portable tests failed in: $($failed -join ', ')" } + # 理由同 Windows 那一档:一个 `#[cfg(target_os = "linux")]` 分支「检查通过」 # 和「根本没人编过它」长得一模一样。跟随系统的深浅色(portal)、登录 shell # 的环境、系统通知,这些只在这里编、只在这里测。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa1ac621..ebb6ddd7 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`,就是安装 +# 程序里装的那两个(应用本体换成了产品名,理由见 `The portable package` 那一步)。 # # 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 要么缺平台,要么指向 @@ -27,12 +37,19 @@ # # 这条流水线不重跑 `ci.yml` 那几道门:tag 是从 main 上打的,main 上 # 每个 commit 都过过。这里只做 CI 做不了的那件事 —— 打出安装包并且 -# 在发出去之前拆开看一眼。 +# 在发出去之前拆开看一眼;Windows 的还要装上跑一遍(`windows-e2e`)。 # # **推到 `rehearse/` 开头的分支是演练**:全部照做,只是不发布 —— 各个安装 # 包留在这次运行的产物里,可以下载下来装到真机上看。改了这条流水线,先演练 # 一遍,不要拿一个 tag 去试。 # +# **`rehearse/windows/` 开头的只演练 Windows**:两个 Windows 构建、`windows-e2e` +# 和发布页正文照做,macOS 和 Linux 的构建跳过(各自 job 上的 `if`)。只改了 Windows +# 那一块时用它,省下另外三台机器;发版之前仍要在别的 `rehearse/` 分支上完整演练一遍。 +# +# 同一个分支再推一次,还在跑的那次演练就取消(`concurrency`)。**tag 不取消**: +# 发版的那次运行跑到一半被打断,可能只挂上了一部分文件。 +# # 不用手工触发(`workflow_dispatch`):那只认默认分支上的这份文件,而改动 # 要先进 dev、过很久才到 main —— 等于改完的流水线没法在合并之前演练。 name: Release @@ -45,6 +62,10 @@ on: permissions: contents: write +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: ${{ github.ref_type != 'tag' }} + env: CARGO_TERM_COLOR: always # 签 `.app` 用的证书。**自签名,不是 Developer ID** —— Gatekeeper 照样 @@ -65,6 +86,8 @@ env: jobs: app: name: ThinkWatch Lite (aarch64-apple-darwin) + # 只演练 Windows 的分支不打它,见文件开头 + if: ${{ !startsWith(github.ref_name, 'rehearse/windows/') }} # 只发 Apple Silicon。**不是「暂时」** —— 覆盖不到的那部分机器 # 拿到的是一个能下载但打不开的文件,那比没有更糟。 runs-on: macos-latest @@ -185,7 +208,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 +219,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 +233,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 +254,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: @@ -295,7 +318,14 @@ jobs: # `--target` 写出来,产物路径才是定死的。取哪一份 twcore 也跟着它走: # Tauri 把它交给 `beforeBuildCommand`,见 `fetch-core.sh`。 + # + # `TW_OFFICIAL_BUILD`:**这是发出去的那一份。**旁边没有卸载程序的 release 构建, + # 带着它才算绿色版(`update::official_build`);开发者自己编的不带,不会把数据 + # 写进 `target\release\data\`、把链接和开机自启改指向 `target\…`。安装版用的是 + # 同一个 exe,旁边有卸载程序,照样是安装版 - name: Build + env: + TW_OFFICIAL_BUILD: "1" run: pnpm tauri build --target ${{ matrix.target }} # **拆开看一眼再发。**和 macOS 那边同一个理由:一个缺了网关、或者 @@ -315,7 +345,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 +371,116 @@ 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`)。 + # + # **应用本体改叫 `ThinkWatch Lite.exe`**(`portable::APP_EXE`)。安装程序和卸载程序 + # 关运行中的程序时按文件名 `thinkwatch-lite.exe` 找进程、一律结束(Tauri 的 NSIS + # 模板),同名的话装、卸安装版会把运行中的绿色版也关掉。网关不改名 + # + # 用 .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 { + # 安装程序里的名字 → zip 里的名字 + foreach ($pair in @(@("thinkwatch-lite.exe", "ThinkWatch Lite.exe"), @("twcore.exe", "twcore.exe"))) { + $f = Get-ChildItem -Recurse "$x\$($pair[0])" | Select-Object -First 1 + if (-not $f) { throw "安装程序里没有 $($pair[0])" } + [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile( + $archive, $f.FullName, $pair[1], [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 -LiteralPath "$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,10 +489,60 @@ 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 + # **装到一台真的 Windows 上用一遍再发。**上面只拆开看了包里有什么;这里把 x64 的 + # 绿色版和安装程序当成用户那样用:解压启动、再开一份(切换、取消、同一个程序)、 + # 没有写入权限的文件夹、静默安装、静默卸载,核对数据目录、注册表、进程和系统对话框。 + # 每一条查什么写在 `scripts/windows-e2e.ps1` 的开头。 + # + # 用 Windows 自带的 PowerShell 5.1(`shell: powershell`):用户机器上一定有的就是它。 + # 截图在这次运行的 `e2e-shots` 里,应用自己的日志在 `e2e-logs` 里。`publish` 下载 + # 产物时这两份也会落进 dist/,文件名都以 `e2e-` 开头,不会被当成要发的文件挂上去。 + # + # **它不过,`publish` 不跑**:没在 Windows 上真的跑起来过的版本不发。 + windows-e2e: + name: Windows end to end (x64) + needs: windows + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: actions/download-artifact@v4 + with: + name: windows-x64 + path: e2e-input + + - name: Portable and installed copies, end to end + shell: powershell + run: | + $setup = @(Get-ChildItem e2e-input\ThinkWatch-Lite-*-windows-x64-setup.exe) + $zip = @(Get-ChildItem e2e-input\ThinkWatch-Lite-*-windows-x64-portable.zip) + if ($setup.Count -ne 1 -or $zip.Count -ne 1) { throw "expected one setup.exe and one portable.zip in e2e-input" } + & .\scripts\windows-e2e.ps1 -Setup $setup[0].FullName -Zip $zip[0].FullName -Out "$env:RUNNER_TEMP\e2e" + + - if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-shots + path: ${{ runner.temp }}/e2e/shots + if-no-files-found: warn + + - if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-logs + path: ${{ runner.temp }}/e2e/logs + if-no-files-found: warn + linux: name: ThinkWatch Lite (${{ matrix.target }}) + # 只演练 Windows 的分支不打它,见文件开头 + if: ${{ !startsWith(github.ref_name, 'rehearse/windows/') }} strategy: fail-fast: false matrix: @@ -443,8 +620,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" # **拆开看一眼再发。**和另外两个平台同一个理由:缺了网关、网关是别的 # 架构、或者更新器认不出这是哪种包,从文件列表上都看不出来。 @@ -556,7 +733,7 @@ jobs: publish: name: Publish - needs: [app, windows, linux, release-body] + needs: [app, windows, windows-e2e, linux, release-body] # 演练到上面为止 if: github.ref_type == 'tag' runs-on: macos-latest @@ -606,11 +783,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 +821,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/README.md b/README.md index 733f953d..8258680d 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ before the client runs them. | Platform | Install | |---|---| -| macOS 12 or later, Apple silicon | `brew install --cask thinkwatchproject/tap/thinkwatch-lite`, or [`ThinkWatch-Lite--arm64.dmg`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Windows 10 21H2 or later, x64 | [`ThinkWatch-Lite--x64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Windows 10 21H2 or later, ARM64 | [`ThinkWatch-Lite--arm64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Linux, x86_64 or aarch64 | `curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh \| sh`, or [`ThinkWatch-Lite--.AppImage`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| macOS 12 or later, Apple silicon | `brew install --cask thinkwatchproject/tap/thinkwatch-lite`, or [`ThinkWatch-Lite--darwin-arm64.dmg`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Windows 10 21H2 or later, x64 | Installer [`ThinkWatch-Lite--windows-x64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest), or portable [`ThinkWatch-Lite--windows-x64-portable.zip`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Windows 10 21H2 or later, ARM64 | Installer [`ThinkWatch-Lite--windows-arm64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest), or portable [`ThinkWatch-Lite--windows-arm64-portable.zip`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Linux, x86_64 or aarch64 | `curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh \| sh`, or [`ThinkWatch-Lite--linux-.AppImage`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | The gateway, [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core), ships inside the app. The app is not signed by Apple or Microsoft, so the @@ -97,6 +97,37 @@ covers it, along with how updates arrive. The interface is in English and Simplified Chinese, and the app updates itself (a Homebrew installation updates through Homebrew). +### Windows portable + +Unzip and run `ThinkWatch Lite.exe`: no installation and no administrator +rights. Configuration, keys and request history stay in the `data\` folder next +to the program, apart from the installed copy's data, so the two keep separate +settings. The folder has to be writable. + +Only one of the installed and portable copies runs at a time; opening the other +one offers to stop the running one and start it instead. `thinkwatch://` links +and launch at login always point to the copy that is running. + +The portable copy has no system uninstaller. Uninstall it in Settings › Full +uninstall first, which restores connected clients and removes its launch at +login and other registry entries, then delete the whole folder. + +### Uninstall + +Uninstalling in Settings › Full uninstall restores connected clients, turns off +launch at login and can delete the data directory as well. Afterwards, move the +app to the Trash on macOS, or delete the AppImage file on Linux. + +The installed copy on Windows can also be uninstalled through the system: the +uninstaller closes ThinkWatch Lite and does the same restoring and cleanup +first. With "Also delete data (configuration, API keys, request history)" +ticked it deletes `%APPDATA%\ThinkWatch` too, except when a client could not be +restored, in which case the data directory, with that client's backup, is kept. +The uninstaller runs as an administrator: when a standard account uninstalls +with an administrator's password, it restores the administrator's clients and +deletes the administrator's data instead, so uninstall in the app first in that +case. + ## Documentation - [Features](https://thinkwat.ch/docs/lite/features): every page, in detail diff --git a/README.zh-CN.md b/README.zh-CN.md index fef149d7..c218db7c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -49,13 +49,27 @@ Claude Code、Codex 等 AI 客户端的本地网关,支持 macOS、Windows 与 | 平台 | 安装 | |---|---| -| macOS 12 及以上,Apple 芯片 | `brew install --cask thinkwatchproject/tap/thinkwatch-lite`,或 [`ThinkWatch-Lite-<版本>-arm64.dmg`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Windows 10 21H2 及以上,x64 | [`ThinkWatch-Lite-<版本>-x64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Windows 10 21H2 及以上,ARM64 | [`ThinkWatch-Lite-<版本>-arm64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | -| Linux,x86_64 或 aarch64 | `curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh \| sh`,或 [`ThinkWatch-Lite-<版本>-<架构>.AppImage`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| macOS 12 及以上,Apple 芯片 | `brew install --cask thinkwatchproject/tap/thinkwatch-lite`,或 [`ThinkWatch-Lite-<版本>-darwin-arm64.dmg`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Windows 10 21H2 及以上,x64 | 安装版 [`ThinkWatch-Lite-<版本>-windows-x64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest),或绿色版 [`ThinkWatch-Lite-<版本>-windows-x64-portable.zip`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Windows 10 21H2 及以上,ARM64 | 安装版 [`ThinkWatch-Lite-<版本>-windows-arm64-setup.exe`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest),或绿色版 [`ThinkWatch-Lite-<版本>-windows-arm64-portable.zip`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | +| Linux,x86_64 或 aarch64 | `curl -fsSL https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest/download/install.sh \| sh`,或 [`ThinkWatch-Lite-<版本>-linux-<架构>.AppImage`](https://github.com/ThinkWatchProject/ThinkWatch-Lite/releases/latest) | 网关 [ThinkWatch Core](https://github.com/ThinkWatchProject/ThinkWatch-Core) 随应用一同安装。应用未经 Apple 与 Microsoft 签名,首次打开需要多一步操作,见[安装与更新](https://thinkwat.ch/zh-CN/docs/lite/install),其中也说明了各种安装方式如何更新。界面提供英文与简体中文,应用自动更新(通过 Homebrew 安装的随 Homebrew 更新)。 +### Windows 绿色版 + +解压后运行其中的 `ThinkWatch Lite.exe` 即可,无需安装,也不需要管理员权限。配置、密钥与请求记录保存在程序旁边的 `data\` 文件夹中,与安装版的数据互不相干,是两套独立的设置。程序所在的文件夹需要可以写入。 + +安装版与绿色版同一时间只运行一个,打开另一个时可以停止正在运行的那一个、改为启动它。`thinkwatch://` 链接与开机启动始终指向正在运行的那一个。 + +绿色版不经过系统卸载:先在应用的「设置 › 完全卸载」中完成卸载,还原已接管的客户端、移除开机启动等注册项,再删除整个文件夹。 + +### 卸载 + +在应用的「设置 › 完全卸载」中卸载,会还原已接管的客户端、取消开机启动,并可同时删除数据目录。之后在 macOS 上将应用移到废纸篓,在 Linux 上删除 AppImage 文件。 + +Windows 安装版也可以直接通过系统卸载:卸载程序先关闭 ThinkWatch Lite,完成同样的还原与清理。勾选「同时删除数据(配置、API 密钥、请求记录)」时一并删除 `%APPDATA%\ThinkWatch`;有客户端未能还原时数据目录保留,它的备份仍在其中。卸载程序以管理员身份运行,标准账户输入管理员密码卸载时,还原与删除的是管理员账户的客户端和数据,这种情况应先在应用内卸载。 + ## 文档 - [功能详解](https://thinkwat.ch/zh-CN/docs/lite/features):逐页说明 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..86dff1cc 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..f17eb9ca 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,22 @@ 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) + # zip 里的应用本体叫产品名,和安装版的 thinkwatch-lite.exe 不同名(portable::APP_EXE) + self.assertIn("start `ThinkWatch Lite.exe`", body) + self.assertNotIn("thinkwatch-lite.exe", 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 +113,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 +130,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 +216,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/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/scripts/windows-e2e.ps1 b/scripts/windows-e2e.ps1 new file mode 100644 index 00000000..1c0fd33a --- /dev/null +++ b/scripts/windows-e2e.ps1 @@ -0,0 +1,1099 @@ +<# +Windows 上的端到端检查:把 x64 的绿色版和安装程序当成用户那样用一遍。 + +发版流水线的 `windows-e2e` 跑它(见 .github/workflows/release.yml),用的是 Windows +自带的 PowerShell 5.1。没有真机测试,这里就是绿色版、单实例、安装版和卸载在 Windows +上真的能用的证据。每一条查什么: + + 1. 绿色版启动:解压到 P1 启动,数据(config.yaml、control.port)在 `P1\data`, + `%APPDATA%\ThinkWatch` 不出现;网关是 P1 里的那个 twcore.exe;thinkwatch:// 链接 + 指向 P1,系统通知登记了 `app.thinkwatch.lite.portable`(名字、PNG 图标)。 + 2. 第二份、切换:P1 运行时启动 P2,P2 弹系统对话框说明 P1 在哪;按「停止运行中的 + 程序并启动此程序」后 P1 和它的 twcore 退出,P2 用它自己的 `data` 起来,链接改指 P2。 + 3. 第二份、取消:P2 运行时再开 P1,按「取消」:P1 退出,P2 照常运行,链接还指 P2。 + 4. 同一个 exe 再开一次:不弹框,第二个进程很快退出,P2 照常运行。 + 5. 没有写入权限:P3 对当前用户拒绝写入,启动后弹「This folder is not writable」, + 按「OK」后退出码是 1,没有建出 `data`。 + 6. 安装版:P2 正常退出后静默安装(/S),启动装好的那一份:数据在 `%APPDATA%\ThinkWatch`, + 链接指向安装目录里的 exe,登记了 `app.thinkwatch.lite`。 + 6b. 安装、卸载不碰运行中的绿色版:安装版装着、P2 在运行,再静默安装一次、静默卸载一次, + P2 和它的网关都还在(同一个进程),链接仍指向 P2。NSIS 关运行中的程序时按文件名找 + 进程,绿色版因此叫 `ThinkWatch Lite.exe`(portable::APP_EXE)。之后重新装上、启动一次 + 安装版,给第 7 条用。 + 7. 卸载:开机自启指向 P2,关掉安装版、静默卸载:指向安装版 + 的链接和通知登记删掉了,指向 P2 的开机自启还在,`%APPDATA%\ThinkWatch` 还在(静默 + 卸载不勾「同时删除数据」)。再对 P2 跑 `--uninstall-cleanup --delete-data`:退出码 0, + `P2\data` 没了,HKCU 里没有指向 P2 的项。 + 8. WebView2:runner 上没有 WebView2 运行时、或者应用启动时弹了「缺少 WebView2」, + 明确报出来,而不是在后面某一步莫名其妙地超时。 + +界面是英文的(runner 的系统语言),所以对的是 `tr!` 里的英文句子:single.rs、 +portable.rs、webview2.rs。文件名照 tw-api 的 `control::CONFIG_FILE` / `PORT_FILE`。 +绿色版的 exe 叫 `ThinkWatch Lite.exe`、安装版的叫 `thinkwatch-lite.exe`,路径里都有空格 +(P2 放在 `portable two` 里),对话框、注册表、命令行都要经得起。 + +每一次等待都有上限;失败时打印找到了什么(窗口、进程、注册表、数据目录、应用日志), +截一张全屏图。结束时(不论成败)关掉留下的进程。应用的标准输出(tracing 日志) +写在 `\logs` 里,截图在 `\shots` 里。 + +**这个文件要存成带 BOM 的 UTF-8**:PowerShell 5.1 读不带 BOM 的文件按 ANSI 解,中文 +注释和提示会乱码,乱码里的弯引号还会被它当成字符串的引号。 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] [string] $Setup, + [Parameter(Mandatory = $true)] [string] $Zip, + [Parameter(Mandatory = $true)] [string] $Out +) + +$ErrorActionPreference = 'Stop' +# 日志里的中文:runner 按 UTF-8 读这个进程的输出 +try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false } catch {} + +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, System.Drawing, System.Windows.Forms +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class TwE2E { + [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam); + [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); + [DllImport("user32.dll")] static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); + + // 鼠标移过去、左键按下再抬起,和人点一下一样 + public static void Click(int x, int y) { + SetCursorPos(x, y); + mouse_event(0x0002, 0, 0, 0, UIntPtr.Zero); + mouse_event(0x0004, 0, 0, 0, UIntPtr.Zero); + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateFileW(string name, uint access, uint share, IntPtr sa, uint disposition, uint flags, IntPtr template); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern uint GetFinalPathNameByHandleW(IntPtr h, StringBuilder buf, uint len, uint flags); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); + + // 和应用里的 std::fs::canonicalize + plain_path 一样:展开链接、短文件名,去掉 \\?\ + public static string FinalPath(string path) { + IntPtr h = CreateFileW(path, 0, 7, IntPtr.Zero, 3, 0x02000000, IntPtr.Zero); + if (h == new IntPtr(-1)) return path; + try { + StringBuilder sb = new StringBuilder(32768); + uint n = GetFinalPathNameByHandleW(h, sb, (uint)sb.Capacity, 0); + if (n == 0 || n >= sb.Capacity) return path; + string s = sb.ToString(); + if (s.StartsWith(@"\\?\UNC\")) return @"\\" + s.Substring(8); + if (s.StartsWith(@"\\?\")) return s.Substring(4); + return s; + } finally { + CloseHandle(h); + } + } +} +'@ +# 截图按真实像素截,不被系统缩放虚拟化 +[void][TwE2E]::SetProcessDPIAware() + +$AE = [System.Windows.Automation.AutomationElement] +$TS = [System.Windows.Automation.TreeScope] + +# ---- 应用里的名字和句子(改了那边,这里跟着改) ---- + +$Identifier = 'app.thinkwatch.lite' # tauri.conf.json 的 identifier +$Product = 'ThinkWatch Lite' # productName:开机自启的值名、通知上的名字 +$ExeName = 'thinkwatch-lite.exe' # 安装版的应用本体 +$PortableExeName = 'ThinkWatch Lite.exe' # 绿色版 zip 里的应用本体(portable::APP_EXE) +$CoreName = 'twcore.exe' +$ConfigFile = 'config.yaml' # tw_api::control::CONFIG_FILE +$PortFile = 'control.port' # tw_api::control::PORT_FILE +$IconFile = 'notification-icon.png' # winreg::NOTIFICATION_ICON +$Handoff = "Local\$Identifier-handoff" # single::handoff_name +$AumidInstalled = $Identifier # notices::windows::aumid(false) +$AumidPortable = "$Identifier.portable" # notices::windows::aumid(true) +$LinkKey = 'HKCU:\Software\Classes\thinkwatch' +$LinkCommandKey = "$LinkKey\shell\open\command" +$AumidRoot = 'HKCU:\Software\Classes\AppUserModelId' +$RunKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' +$InstalledData = Join-Path $env:APPDATA 'ThinkWatch' + +$Text = @{ + Running = 'ThinkWatch Lite is already running' + RunningAt = 'The running program is at {0}. Only one ThinkWatch Lite can run at a time.' + SwitchOver = 'Stop the running program and start this one' + Cancel = 'Cancel' + # 后面是一个省略号(U+2026),只比前面这一段 + Waiting = 'Waiting for requests in progress to finish' + NotWritable = 'This folder is not writable' + NotWritableBody = 'Extract ThinkWatch Lite to a writable location and run it again.' + OK = 'OK' + WebView2 = 'WebView2' +} + +# 对话框按钮的编号:dialog.rs 的 FIRST_BUTTON 起。鼠标点不下去时,退回给对话框 +# 发 TDM_CLICK_BUTTON +$FirstButton = 100 +$TDM_CLICK_BUTTON = 0x0466 # WM_USER + 102 + +# ---- 目录 ---- + +$Shots = Join-Path $Out 'shots' +$Logs = Join-Path $Out 'logs' +New-Item -ItemType Directory -Force -Path $Shots, $Logs | Out-Null +$Base = if ($env:RUNNER_TEMP) { $env:RUNNER_TEMP } else { [IO.Path]::GetTempPath() } +$Root = Join-Path $Base 'tw-e2e' +$P1 = Join-Path $Root 'P1' +# 第二份的路径里带空格:对话框里的路径、注册表里的引号都要经得起 +$P2 = Join-Path $Root 'portable two' +$P3 = Join-Path $Root 'P3' +$P1exe = Join-Path $P1 $PortableExeName +$P2exe = Join-Path $P2 $PortableExeName +$P3exe = Join-Path $P3 $PortableExeName + +# ---- 记录 ---- + +$script:Started = Get-Date +$script:Check = 'setup' +$script:Results = New-Object System.Collections.Generic.List[object] +$script:Warnings = New-Object System.Collections.Generic.List[string] +$script:AppLogs = New-Object System.Collections.Generic.List[string] +$script:ShotCount = 0 +$script:DenySid = $null + +function Say([string] $message) { + Write-Host ('[{0,6:N1}s] {1}' -f ((Get-Date) - $script:Started).TotalSeconds, $message) +} + +function Pass([string] $what) { + $script:Results.Add([pscustomobject]@{ Check = $script:Check; Result = 'pass'; What = $what }) + Say ('PASS [{0}] {1}' -f $script:Check, $what) +} + +# 不算失败、但要让人看见的事 +function Warn([string] $what) { + $line = '[{0}] {1}' -f $script:Check, $what + $script:Warnings.Add($line) + Say ('WARN ' + $line) +} + +function Fail([string] $message) { + throw (New-Object System.Exception $message) +} + +function Start-Check([string] $name) { + $script:Check = $name + Say '' + Say ('==== {0} ====' -f $name) +} + +# 全屏截图。截不到(没有交互式桌面)只记一句,不让检查因此失败 +function Shot([string] $name) { + $script:ShotCount++ + $path = Join-Path $Shots ('e2e-{0:D2}-{1}.png' -f $script:ShotCount, $name) + try { + $b = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height + $g = [System.Drawing.Graphics]::FromImage($bmp) + try { + $g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size) + $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) + } finally { + $g.Dispose() + $bmp.Dispose() + } + Say ('截图:{0}' -f (Split-Path $path -Leaf)) + } catch { + Say ('截图 {0} 没截成:{1}' -f $name, $_.Exception.Message) + } +} + +# 在 `seconds` 秒内反复问 `condition`,答出真值就返回它;到点了返回 $null +function Wait-For([scriptblock] $condition, [double] $seconds, [int] $everyMs = 250) { + $__until = (Get-Date).AddSeconds($seconds) + while ($true) { + $__got = & $condition + if ($__got) { return $__got } + if ((Get-Date) -ge $__until) { return $null } + Start-Sleep -Milliseconds $everyMs + } +} + +# ---- 进程 ---- + +# 启动应用,标准输出和标准错误(tracing 日志)写进 logs。工作目录是 exe 所在的文件夹, +# 和在资源管理器里双击一样 +function Start-App([string] $exe, [string] $tag, [string[]] $arguments = @()) { + $outLog = Join-Path $Logs ('e2e-{0}.out.log' -f $tag) + $errLog = Join-Path $Logs ('e2e-{0}.err.log' -f $tag) + $p = @{ + FilePath = $exe + WorkingDirectory = (Split-Path $exe) + PassThru = $true + RedirectStandardOutput = $outLog + RedirectStandardError = $errLog + } + if ($arguments.Count -gt 0) { $p.ArgumentList = $arguments } + $proc = Start-Process @p + # 先把句柄取出来:PowerShell 5.1 里不这样做,进程退出之后 ExitCode 是空的 + $null = $proc.Handle + $script:AppLogs.Add($outLog) + $script:AppLogs.Add($errLog) + Say ('启动 {0}(pid {1}):{2} {3}' -f $tag, $proc.Id, $exe, ($arguments -join ' ')) + return $proc +} + +# 跑的是 `exe` 这个文件的那些进程 +function Get-ProcsAt([string] $exe) { + $leaf = Split-Path $exe -Leaf + return @(Get-CimInstance Win32_Process -Filter ("Name='{0}'" -f $leaf) | + Where-Object { $_.ExecutablePath -and (Same $_.ExecutablePath $exe) }) +} + +function Wait-Gone([string] $exe, [string] $label, [double] $seconds = 30) { + $gone = Wait-For { @(Get-ProcsAt $exe).Count -eq 0 } $seconds + if (-not $gone) { + Fail ('{0} 在 {1} 秒内没有退出:{2}' -f $label, $seconds, (Get-ProcsAt $exe | ForEach-Object { 'pid ' + $_.ProcessId } | Out-String).Trim()) + } +} + +# 像另一份那样请运行中的程序退出(single.rs 的切换事件):它等手上的请求结束、停掉 core、 +# 退出。打不开事件、或者到点没退,就强制结束 +function Stop-Gracefully($proc, [string] $label) { + $sent = $false + try { + $ev = [System.Threading.EventWaitHandle]::OpenExisting($Handoff) + try { $sent = $ev.Set() } finally { $ev.Dispose() } + } catch { + Say ('打不开 {0}:{1}' -f $Handoff, $_.Exception.Message) + } + if ($sent -and $proc.WaitForExit(120000)) { + Say ('{0} 已正常退出(退出码 {1})' -f $label, $proc.ExitCode) + if ($proc.ExitCode -ne 0) { Warn ('{0} 正常退出时的退出码是 {1}' -f $label, $proc.ExitCode) } + return + } + Warn ('{0} 没能正常退出,强制结束' -f $label) + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + [void]$proc.WaitForExit(30000) +} + +# ---- 路径、注册表、文件 ---- + +function Same([string] $a, [string] $b) { + if (-not $a -or -not $b) { return $false } + $na = $a.Trim().Replace('/', '\') + $nb = $b.Trim().Replace('/', '\') + if ($na.StartsWith('\\?\')) { $na = $na.Substring(4) } + if ($nb.StartsWith('\\?\')) { $nb = $nb.Substring(4) } + return [string]::Equals($na, $nb, [StringComparison]::OrdinalIgnoreCase) +} + +# 一条命令行启动的是哪个程序(带引号取引号里那段,不带取到第一个空白) +function Command-Exe([string] $cmd) { + if (-not $cmd) { return $null } + $c = $cmd.Trim() + if ($c.StartsWith('"')) { + $end = $c.IndexOf('"', 1) + if ($end -lt 0) { return $c.Substring(1) } + return $c.Substring(1, $end - 1) + } + return ($c -split '\s+')[0] +} + +# 注册表里的一个值(默认值用 '');键或值不在是 $null。REG_EXPAND_SZ 读出来是展开过的 +function Get-Reg([string] $key, [string] $name) { + try { $k = Get-Item -LiteralPath $key -ErrorAction Stop } catch { return $null } + try { return $k.GetValue($name) } finally { $k.Close() } +} + +function Get-RegKind([string] $key, [string] $name) { + try { $k = Get-Item -LiteralPath $key -ErrorAction Stop } catch { return $null } + try { return $k.GetValueKind($name) } catch { return $null } finally { $k.Close() } +} + +function Link-Exe { return (Command-Exe (Get-Reg $LinkCommandKey '')) } + +function Is-Png([string] $path) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { return $false } + $buf = New-Object byte[] 8 + $fs = [IO.File]::OpenRead($path) + try { $n = $fs.Read($buf, 0, 8) } finally { $fs.Dispose() } + return ($n -eq 8 -and (($buf -join ',') -eq '137,80,78,71,13,10,26,10')) +} + +function Test-Port([int] $port) { + $c = New-Object System.Net.Sockets.TcpClient + try { + $ar = $c.BeginConnect('127.0.0.1', $port, $null, $null) + if (-not $ar.AsyncWaitHandle.WaitOne(2000)) { return $false } + $c.EndConnect($ar) + return $true + } catch { + return $false + } finally { + $c.Close() + } +} + +function List-Dir([string] $dir) { + if (-not (Test-Path -LiteralPath $dir)) { return '(不存在)' } + $items = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue | ForEach-Object { $_.Name }) + if ($items.Count -eq 0) { return '(空)' } + return ($items -join ', ') +} + +# 应用的日志,读的时候它可能还开着 +function Read-Log([string] $path) { + try { + $fs = New-Object IO.FileStream($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]'ReadWrite, Delete') + $sr = New-Object IO.StreamReader($fs, [Text.Encoding]::UTF8) + try { $all = $sr.ReadToEnd() } finally { $sr.Dispose() } + # tracing 带着终端颜色 + return @(($all -replace "\x1b\[[0-9;]*m", '') -split "`r?`n" | Where-Object { $_ }) + } catch { + return @() + } +} + +# ---- 窗口 ---- + +function Get-Windows([int] $procId) { + $cond = New-Object System.Windows.Automation.PropertyCondition($AE::ProcessIdProperty, $procId) + try { return @($AE::RootElement.FindAll($TS::Children, $cond)) } catch { return @() } +} + +# 这个进程的系统对话框(TaskDialog、MessageBox 都是 #32770) +function Get-Dialog([int] $procId) { + foreach ($w in (Get-Windows $procId)) { + try { if ($w.Current.ClassName -eq '#32770') { return $w } } catch {} + } + return $null +} + +# 一个窗口里所有元素的名字:TaskDialog 的标题、主句、正文、按钮都在里面 +function Get-Texts($element) { + $names = New-Object System.Collections.Generic.List[string] + try { if ($element.Current.Name) { $names.Add($element.Current.Name) } } catch {} + try { + foreach ($d in $element.FindAll($TS::Descendants, [System.Windows.Automation.Condition]::TrueCondition)) { + try { if ($d.Current.Name) { $names.Add($d.Current.Name) } } catch {} + } + } catch {} + return $names.ToArray() +} + +# 窗口里所有元素:(控件类型、类名、名字、元素)。按名字找按钮时不限控件类型 —— +# TaskDialog 里的按钮在 UI 自动化里不一定报成 Button +function Get-Parts($element) { + $parts = New-Object System.Collections.Generic.List[object] + try { + foreach ($d in $element.FindAll($TS::Descendants, [System.Windows.Automation.Condition]::TrueCondition)) { + try { + $c = $d.Current + $parts.Add([pscustomobject]@{ Type = $c.ControlType.ProgrammaticName; Class = $c.ClassName; Name = $c.Name; Element = $d }) + } catch {} + } + } catch {} + return $parts.ToArray() +} + +function Describe($element) { + return ((Get-Parts $element | ForEach-Object { '{0} [{1}] "{2}"' -f $_.Type, $_.Class, $_.Name }) -join '; ') +} + +# 放到最上层再截图:从后台进程拉起来的对话框不一定在前面 +function Raise($element) { + try { + $h = [IntPtr]$element.Current.NativeWindowHandle + # HWND_TOPMOST;SWP_NOSIZE | SWP_NOMOVE | SWP_SHOWWINDOW + [void][TwE2E]::SetWindowPos($h, [IntPtr](-1), 0, 0, 0, 0, 0x43) + Start-Sleep -Milliseconds 400 + } catch {} +} + +# 等这个进程弹出系统对话框(等到按钮也画出来)。它先退出了、或者到点没弹,算失败 +function Wait-Dialog($proc, [string] $label, [double] $seconds = 60) { + $until = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $until) { + $d = Get-Dialog $proc.Id + if ($d) { + # 窗口先出来、里面的字和按钮后画:等到有几样东西了再往下 + [void](Wait-For { @(Get-Parts $d | Where-Object { $_.Name }).Count -ge 3 } 5 100) + Say ('{0} 的对话框:{1}' -f $label, ((Get-Texts $d) -join ' | ')) + Say (' UI 自动化里的样子:{0}' -f (Describe $d)) + return $d + } + if ($proc.HasExited) { Fail ('{0} 没有弹出对话框就退出了(退出码 {1})' -f $label, $proc.ExitCode) } + Start-Sleep -Milliseconds 250 + } + Fail ('{0} 在 {1} 秒内没有弹出对话框' -f $label, $seconds) +} + +function Assert-Contains([string[]] $texts, [string] $needle, [string] $what) { + $all = $texts -join "`n" + if ($all.IndexOf($needle, [StringComparison]::OrdinalIgnoreCase) -lt 0) { + Fail ('{0}:对话框里没有「{1}」。对话框里的文字:{2}' -f $what, $needle, ($texts -join ' | ')) + } +} + +# 按下对话框里写着 `label` 的按钮,确认对话框随之关掉。 +# +# TaskDialog 的按钮在 UI 自动化里是 `Pane [CCPushButton]`,没有 Invoke:那就照它的位置 +# 用鼠标点一下,和人点的一样。点了对话框没关,才退回给它发 TDM_CLICK_BUTTON(按编号, +# `index` 是按钮在 dialog::show 里的下标)并记一条提醒;那样也没关,算失败 +function Press($dialog, [string] $label, [int] $index) { + $named = @(Get-Parts $dialog | Where-Object { $_.Name -and $_.Name.Trim() -eq $label }) + if ($named.Count -eq 0) { + Fail ('对话框里没有「{0}」按钮。对话框里有:{1}' -f $label, (Describe $dialog)) + } + $hwnd = [IntPtr]$dialog.Current.NativeWindowHandle + # 同名的几个里挑按钮:能 Invoke 的,其次是 CCPushButton / Button(正文里可能有同样的字) + $button = $null + $invoke = $false + foreach ($n in $named) { + $pattern = $null + if ($n.Element.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)) { $button = $n; $invoke = $true; break } + } + if (-not $button) { + $button = @($named | Where-Object { $_.Class -eq 'CCPushButton' -or $_.Type -eq 'ControlType.Button' }) + $named | Select-Object -First 1 + } + $how = '' + try { + if ($invoke) { + $button.Element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern).Invoke() + $how = 'Invoke' + } else { + $r = $button.Element.Current.BoundingRectangle + if ($r.IsEmpty -or $r.Width -le 0) { throw '按钮没有位置' } + Raise $dialog + [TwE2E]::Click([int]($r.X + $r.Width / 2), [int]($r.Y + $r.Height / 2)) + $how = '鼠标' + } + } catch { + $how = '失败:' + $_.Exception.Message + } + if (Wait-For { -not [TwE2E]::IsWindow($hwnd) } 5 100) { + Say ('按下了「{0}」({1},{2})' -f $label, $button.Type, $how) + return + } + Warn ('「{0}」按下去对话框没关({1}),改发 TDM_CLICK_BUTTON' -f $label, $how) + [void][TwE2E]::PostMessage($hwnd, $TDM_CLICK_BUTTON, [IntPtr]($FirstButton + $index), [IntPtr]::Zero) + if (-not (Wait-For { -not [TwE2E]::IsWindow($hwnd) } 5 100)) { + Fail ('「{0}」怎么按对话框都不关' -f $label) + } +} + +# 等主窗口出来再截图(尽量,不强求) +function Shot-Main($proc, [string] $name) { + $w = Wait-For { + foreach ($x in (Get-Windows $proc.Id)) { + try { if ($x.Current.ClassName -ne '#32770' -and -not $x.Current.IsOffscreen) { return $x } } catch {} + } + } 20 500 + if ($w) { Start-Sleep -Seconds 3 } else { Say '没等到主窗口,照样截图' } + Shot $name +} + +# ---- 应用起来了 ---- + +# 等这一份真的起来:数据目录里有了配置和控制面端口,端口连得上,网关是这个文件夹里的 +# twcore.exe。等的时候它先退出了、或者弹了对话框(比如缺 WebView2),立刻失败 +function Wait-Ready($proc, [string] $data, [string] $folder, [string] $label, [double] $seconds = 120) { + $cfg = Join-Path $data $ConfigFile + $portPath = Join-Path $data $PortFile + $until = (Get-Date).AddSeconds($seconds) + $port = 0 + while ($true) { + if ($proc.HasExited) { + Fail ('{0} 在就绪之前退出了(退出码 {1})。数据目录 {2}:{3}' -f $label, $proc.ExitCode, $data, (List-Dir $data)) + } + $d = Get-Dialog $proc.Id + if ($d) { + $texts = (Get-Texts $d) -join ' | ' + Raise $d + Shot ('{0}-unexpected-dialog' -f $label) + if ($texts -like ('*{0}*' -f $Text.WebView2)) { + Fail ('{0} 没有启动,弹了缺少 WebView2 运行时的对话框:{1}' -f $label, $texts) + } + Fail ('{0} 启动时弹了意外的对话框:{1}' -f $label, $texts) + } + if ((Test-Path -LiteralPath $cfg) -and (Test-Path -LiteralPath $portPath)) { + $raw = '' + try { $raw = ([IO.File]::ReadAllText($portPath)).Trim() } catch {} + if ($raw -match '^\d+$' -and (Test-Port ([int]$raw))) { $port = [int]$raw; break } + } + if ((Get-Date) -ge $until) { + Fail ('{0} 在 {1} 秒内没有就绪。数据目录 {2}:{3}' -f $label, $seconds, $data, (List-Dir $data)) + } + Start-Sleep -Milliseconds 500 + } + $core = Join-Path $folder $CoreName + $cores = @(Get-ProcsAt $core) + if ($cores.Count -lt 1) { + Fail ('{0} 的网关不是 {1}。在跑的 twcore:{2}' -f $label, $core, ((Get-ProcsAt-Any $CoreName) -join ' | ')) + } + Pass ('{0} 起来了:{1} 里有 {2}、{3}(端口 {4} 连得上),网关是 {5}(pid {6})' -f $label, $data, $ConfigFile, $PortFile, $port, $core, $cores[0].ProcessId) +} + +function Get-ProcsAt-Any([string] $name) { + return @(Get-CimInstance Win32_Process -Filter ("Name='{0}'" -f $name) | ForEach-Object { '{0} (pid {1})' -f $_.ExecutablePath, $_.ProcessId }) +} + +function Assert-Link([string] $exe, [string] $label, [double] $seconds = 30) { + $ok = Wait-For { Same (Link-Exe) $exe } $seconds + if (-not $ok) { + Fail ('thinkwatch:// 链接没有指向 {0}:{1} = {2}' -f $label, $LinkCommandKey, (Get-Reg $LinkCommandKey '')) + } + Pass ('thinkwatch:// 链接指向 {0}:{1}' -f $label, (Get-Reg $LinkCommandKey '')) +} + +# 系统通知的登记:名字是产品名,图标是数据目录里的一张 PNG +function Assert-Aumid([string] $aumid, [string] $data, [double] $seconds = 30) { + $key = "$AumidRoot\$aumid" + $icon = Join-Path $data $IconFile + $ok = Wait-For { ((Get-Reg $key 'DisplayName') -eq $Product) -and (Same (Get-Reg $key 'IconUri') $icon) } $seconds + if (-not $ok) { + Fail ('{0} 的登记不对:DisplayName = {1},IconUri = {2};应为 {3}、{4}' -f $key, (Get-Reg $key 'DisplayName'), (Get-Reg $key 'IconUri'), $Product, $icon) + } + if (-not (Is-Png $icon)) { Fail ('IconUri 指向的 {0} 不是一张 PNG(或者不存在)' -f $icon) } + foreach ($v in 'DisplayName', 'IconUri') { + $kind = Get-RegKind $key $v + if ("$kind" -ne 'ExpandString') { Warn ('{0} 的 {1} 是 {2},约定是 REG_EXPAND_SZ' -f $key, $v, $kind) } + } + Pass ('登记了 {0}:DisplayName = {1},IconUri = {2}(PNG)' -f $aumid, $Product, $icon) +} + +# HKCU 里这几处所有指向 `folder` 的值 +function Find-RegRefs([string] $folder) { + $hits = New-Object System.Collections.Generic.List[string] + $keys = @() + if (Test-Path -LiteralPath $LinkKey) { + $keys += Get-Item -LiteralPath $LinkKey + $keys += @(Get-ChildItem -LiteralPath $LinkKey -Recurse) + } + if (Test-Path -LiteralPath $AumidRoot) { + foreach ($k in @(Get-ChildItem -LiteralPath $AumidRoot | Where-Object { $_.PSChildName -like "$Identifier*" })) { + $keys += $k + $keys += @(Get-ChildItem -LiteralPath $k.PSPath -Recurse) + } + } + if (Test-Path -LiteralPath $RunKey) { $keys += Get-Item -LiteralPath $RunKey } + foreach ($k in $keys) { + foreach ($n in $k.GetValueNames()) { + $v = "$($k.GetValue($n))" + if ($v.IndexOf($folder, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + $hits.Add(('{0} [{1}] = {2}' -f $k.Name, $n, $v)) + } + } + } + return $hits.ToArray() +} + +# ---- 失败时看现场 ---- + +function Dump-State { + Say '' + Say '---- 进程 ----' + Get-CimInstance Win32_Process | + Where-Object { $_.Name -in @($ExeName, $PortableExeName, 'twcore.exe', 'uninstall.exe', 'msedgewebview2.exe') -or $_.Name -like 'Un_*.exe' -or $_.Name -like 'Au_*.exe' } | + ForEach-Object { Say (' pid {0}(父 {1}){2} | {3}' -f $_.ProcessId, $_.ParentProcessId, $_.ExecutablePath, $_.CommandLine) } + Say '---- 顶层窗口 ----' + try { + foreach ($w in @($AE::RootElement.FindAll($TS::Children, [System.Windows.Automation.Condition]::TrueCondition))) { + try { + $c = $w.Current + Say (' pid {0} [{1}] {2}' -f $c.ProcessId, $c.ClassName, $c.Name) + if ($c.ClassName -eq '#32770') { Say (' ' + ((Get-Texts $w) -join ' | ')) } + } catch {} + } + } catch { Say (' 列不出窗口:' + $_.Exception.Message) } + Say '---- 注册表 ----' + Say (' {0} = {1}' -f $LinkCommandKey, (Get-Reg $LinkCommandKey '')) + Say (' {0} [{1}] = {2}' -f $RunKey, $Product, (Get-Reg $RunKey $Product)) + if (Test-Path -LiteralPath $AumidRoot) { + foreach ($k in @(Get-ChildItem -LiteralPath $AumidRoot | Where-Object { $_.PSChildName -like "$Identifier*" })) { + Say (' {0}: DisplayName = {1}, IconUri = {2}' -f $k.PSChildName, $k.GetValue('DisplayName'), $k.GetValue('IconUri')) + } + } + Say '---- 数据目录 ----' + foreach ($d in (Join-Path $P1 'data'), (Join-Path $P2 'data'), (Join-Path $P3 'data'), $InstalledData) { + Say (' {0}: {1}' -f $d, (List-Dir $d)) + } + Say '---- 应用日志(各取最后 40 行) ----' + foreach ($log in $script:AppLogs) { + $lines = Read-Log $log + if ($lines.Count -eq 0) { continue } + Say (' == ' + (Split-Path $log -Leaf)) + $lines | Select-Object -Last 40 | ForEach-Object { Write-Host (' ' + $_) } + } +} + +# ---- 收尾 ---- + +function Stop-Leftovers { + foreach ($name in $ExeName, $PortableExeName, 'twcore.exe') { + foreach ($p in @(Get-CimInstance Win32_Process -Filter ("Name='{0}'" -f $name))) { + Say ('结束留下的进程:pid {0} {1}' -f $p.ProcessId, $p.ExecutablePath) + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue + } + } + # 应用的 WebView2 子进程(用户数据目录在这几份的 data 下,或者安装版默认的那个位置) + foreach ($p in @(Get-CimInstance Win32_Process -Filter "Name='msedgewebview2.exe'")) { + if ("$($p.CommandLine)" -match [regex]::Escape($Root) -or "$($p.CommandLine)" -match [regex]::Escape($Identifier)) { + Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue + } + } + if ($script:DenySid) { + & icacls.exe $P3 /remove:d ('*' + $script:DenySid) | Out-Null + $script:DenySid = $null + } +} + +# 应用自己报的警告和错误:检查都过了也列出来,可能是值得看的问题 +function Show-AppWarnings { + $lines = New-Object System.Collections.Generic.List[string] + foreach ($log in $script:AppLogs) { + foreach ($l in (Read-Log $log)) { + if ($l -match '\b(WARN|ERROR)\b' -or $l -match 'panicked') { + $lines.Add(('{0}: {1}' -f (Split-Path $log -Leaf), $l.Trim())) + } + } + } + Say '' + Say ('---- 应用日志里的 WARN / ERROR:{0} 行 ----' -f $lines.Count) + $lines | ForEach-Object { Write-Host (' ' + $_) } + return $lines.ToArray() +} + +function Write-Summary([string] $failure, [string[]] $appWarnings) { + if (-not $env:GITHUB_STEP_SUMMARY) { return } + $md = New-Object System.Text.StringBuilder + [void]$md.AppendLine('## Windows end-to-end (x64)') + [void]$md.AppendLine() + if ($failure) { + [void]$md.AppendLine(('**FAILED** in `{0}`: {1}' -f $script:Check, $failure)) + [void]$md.AppendLine() + } + [void]$md.AppendLine('| Check | Result | What |') + [void]$md.AppendLine('|---|---|---|') + foreach ($r in $script:Results) { + [void]$md.AppendLine(('| {0} | {1} | {2} |' -f $r.Check, $r.Result, ($r.What -replace '\|', '\|'))) + } + if ($script:Warnings.Count -gt 0) { + [void]$md.AppendLine() + [void]$md.AppendLine('### Warnings') + foreach ($w in $script:Warnings) { [void]$md.AppendLine('- ' + $w) } + } + if ($appWarnings.Count -gt 0) { + [void]$md.AppendLine() + [void]$md.AppendLine('### WARN / ERROR lines in the app logs') + [void]$md.AppendLine('```') + foreach ($l in $appWarnings) { [void]$md.AppendLine($l) } + [void]$md.AppendLine('```') + } + [void]$md.AppendLine() + [void]$md.AppendLine('Screenshots: artifact `e2e-shots`; app logs: artifact `e2e-logs`.') + [IO.File]::AppendAllText($env:GITHUB_STEP_SUMMARY, $md.ToString(), (New-Object System.Text.UTF8Encoding $false)) +} + +# ==== 各条检查 ==== + +function Check-Prerequisites { + Start-Check '0 prerequisites' + foreach ($f in $Setup, $Zip) { if (-not (Test-Path -LiteralPath $f)) { Fail ('没有 {0}' -f $f) } } + Say ('安装程序:{0}' -f $Setup) + Say ('绿色版:{0}' -f $Zip) + $os = Get-CimInstance Win32_OperatingSystem + Say ('系统:{0} {1},界面语言 {2}' -f $os.Caption, $os.Version, (Get-UICulture).Name) + $screen = [System.Windows.Forms.SystemInformation]::VirtualScreen + Say ('屏幕:{0}x{1}' -f $screen.Width, $screen.Height) + + # 8. WebView2:没有它应用开不了窗口,只会弹「缺少 WebView2」—— 先在这里说清楚 + $guid = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}' + $wv = $null + foreach ($k in "HKLM:\SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\$guid", + "HKLM:\SOFTWARE\Microsoft\EdgeUpdate\Clients\$guid", + "HKCU:\Software\Microsoft\EdgeUpdate\Clients\$guid") { + $v = Get-Reg $k 'pv' + if ($v -and $v -ne '0.0.0.0') { $wv = $v; break } + } + if (-not $wv) { + Fail '这台 runner 上没有 Microsoft Edge WebView2 运行时(EdgeUpdate 的注册表里查不到版本号):应用只会弹「缺少 WebView2」的对话框而不会启动,后面的检查无从做起' + } + Pass ('WebView2 运行时 {0}' -f $wv) + + # 干净的起点:runner 是一次性的,有旧东西就清掉并说一声 + foreach ($n in $ExeName, $PortableExeName, $CoreName) { + if (@(Get-CimInstance Win32_Process -Filter ("Name='{0}'" -f $n)).Count -gt 0) { Fail ('开始之前已经有 {0} 在运行' -f $n) } + } + if (Test-Path -LiteralPath $InstalledData) { + Warn ('开始之前就有 {0}({1}),删掉' -f $InstalledData, (List-Dir $InstalledData)) + Remove-Item -LiteralPath $InstalledData -Recurse -Force + } + foreach ($k in $LinkKey, "$AumidRoot\$AumidInstalled", "$AumidRoot\$AumidPortable") { + if (Test-Path -LiteralPath $k) { Warn ('开始之前就有 {0},删掉' -f $k); Remove-Item -LiteralPath $k -Recurse -Force } + } + if ($null -ne (Get-Reg $RunKey $Product)) { + Warn ('开始之前 Run 里就有 {0},删掉' -f $Product) + Remove-ItemProperty -LiteralPath $RunKey -Name $Product + } + if (Test-Path -LiteralPath $Root) { Remove-Item -LiteralPath $Root -Recurse -Force } + foreach ($dir in $P1, $P2) { + Expand-Archive -LiteralPath $Zip -DestinationPath $dir -Force + Say ('解压到 {0}:{1}' -f $dir, (List-Dir $dir)) + } + $inside = @(Get-ChildItem -LiteralPath $P1 -Force | ForEach-Object { $_.Name } | Sort-Object) + if (($inside -join ',') -ne ('{0},{1}' -f $PortableExeName, $CoreName)) { + Fail ('绿色版的 zip 里应当恰好是 {0} 和 {1},实际是:{2}' -f $PortableExeName, $CoreName, ($inside -join ', ')) + } + Pass ('zip 里是 {0} 和 {1}' -f $PortableExeName, $CoreName) + # 绿色版在文件夹之外留没留下东西:先记下这几处原来在不在 + $script:Outside = @{} + foreach ($d in (Join-Path $env:LOCALAPPDATA $Identifier), (Join-Path $env:APPDATA $Identifier)) { + $script:Outside[$d] = Test-Path -LiteralPath $d + } +} + +function Check-PortableStart { + Start-Check '1 portable start' + $script:proc1 = Start-App $P1exe 'p1' + Wait-Ready $script:proc1 (Join-Path $P1 'data') $P1 'P1' + if (Test-Path -LiteralPath $InstalledData) { + Fail ('绿色版建出了 {0}:{1}' -f $InstalledData, (List-Dir $InstalledData)) + } + Pass ('{0} 没有出现' -f $InstalledData) + Assert-Link $P1exe 'P1' + Assert-Aumid $AumidPortable (Join-Path $P1 'data') + $webview = Join-Path $P1 'data\webview' + if (Test-Path -LiteralPath $webview) { Pass ('WebView2 的数据在 {0}' -f $webview) } + else { Warn ('没有 {0}:WebView2 的数据不在绿色版的文件夹里?' -f $webview) } + Shot-Main $script:proc1 'p1-running' +} + +function Check-Switch { + Start-Check '2 second copy: switch' + $P1core = Join-Path $P1 $CoreName + $script:proc2 = Start-App $P2exe 'p2' + $dialog = Wait-Dialog $script:proc2 'P2' + Raise $dialog + Shot 'p2-asks-to-switch' + $texts = Get-Texts $dialog + Assert-Contains $texts $Text.Running '主句' + # 对话框里的路径是规范化过的(canonicalize),两种写法都认 + $shown = $texts -join "`n" + $want = @(($Text.RunningAt -f [TwE2E]::FinalPath($P1exe)), ($Text.RunningAt -f $P1exe)) + if (-not ($want | Where-Object { $shown.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 })) { + Fail ('正文没有说 P1 在哪。应为「{0}」,对话框里的文字:{1}' -f $want[0], ($texts -join ' | ')) + } + Pass ('P2 弹了对话框,说明运行中的程序位于 {0}' -f $P1exe) + Press $dialog $Text.SwitchOver 0 + + # 等待对话框只在 P1 收尾的那一两秒里出现:赶上了就截一张,赶不上不算失败 + $caught = $false + $until = (Get-Date).AddSeconds(20) + while ((Get-Date) -lt $until -and -not $script:proc1.HasExited) { + $d = Get-Dialog $script:proc2.Id + if ($d) { + $t = Get-Texts $d + if (($t -join ' ') -like ('*{0}*' -f $Text.Waiting)) { + Raise $d + Shot 'p2-waits-for-p1' + Say ('等待对话框:{0}' -f ($t -join ' | ')) + $caught = $true + break + } + } + Start-Sleep -Milliseconds 100 + } + if (-not $caught) { Say '没赶上等待对话框(P1 退得快),不影响结果' } + + if (-not $script:proc1.WaitForExit(120000)) { Fail 'P1 在 120 秒内没有退出' } + Pass ('P1 退出了(退出码 {0})' -f $script:proc1.ExitCode) + if ($script:proc1.ExitCode -ne 0) { Warn ('P1 让位时的退出码是 {0}' -f $script:proc1.ExitCode) } + Wait-Gone $P1core 'P1 的 twcore.exe' + Pass 'P1 的 twcore.exe 退出了' + Wait-Ready $script:proc2 (Join-Path $P2 'data') $P2 'P2' + Assert-Link $P2exe 'P2' + Assert-Aumid $AumidPortable (Join-Path $P2 'data') + if ($script:proc2.HasExited) { Fail 'P2 起来之后又退出了' } + Shot-Main $script:proc2 'p2-running' +} + +function Check-Cancel { + Start-Check '3 second copy: cancel' + $again = Start-App $P1exe 'p1-again' + $dialog = Wait-Dialog $again 'P1(再开一次)' + Raise $dialog + Shot 'p1-asks-again' + $texts = Get-Texts $dialog + $shown = $texts -join "`n" + $want = @(($Text.RunningAt -f [TwE2E]::FinalPath($P2exe)), ($Text.RunningAt -f $P2exe)) + if (-not ($want | Where-Object { $shown.IndexOf($_, [StringComparison]::OrdinalIgnoreCase) -ge 0 })) { + Fail ('正文没有说 P2 在哪。应为「{0}」,对话框里的文字:{1}' -f $want[0], ($texts -join ' | ')) + } + Press $dialog $Text.Cancel 1 + if (-not $again.WaitForExit(30000)) { Fail '按了取消,P1 在 30 秒内没有退出' } + Pass ('按了取消,P1 退出了(退出码 {0})' -f $again.ExitCode) + Start-Sleep -Seconds 2 + if ($script:proc2.HasExited) { Fail ('P2 不该退出,退出码 {0}' -f $script:proc2.ExitCode) } + if (@(Get-ProcsAt (Join-Path $P2 $CoreName)).Count -lt 1) { Fail 'P2 的 twcore.exe 不在了' } + if (-not (Same (Link-Exe) $P2exe)) { Fail ('取消之后链接不该改,现在是 {0}' -f (Get-Reg $LinkCommandKey '')) } + Pass 'P2 和它的网关照常运行,链接仍指向 P2' +} + +function Check-SameExe { + Start-Check '4 same exe twice' + $again = Start-App $P2exe 'p2-again' + $sw = [Diagnostics.Stopwatch]::StartNew() + while (-not $again.HasExited -and $sw.Elapsed.TotalSeconds -lt 30) { + $d = Get-Dialog $again.Id + if ($d) { + $t = (Get-Texts $d) -join ' | ' + Raise $d + Shot 'p2-again-unexpected-dialog' + Fail ('同一个程序再开一次不该弹对话框:{0}' -f $t) + } + Start-Sleep -Milliseconds 200 + } + if (-not $again.HasExited) { Fail '第二个进程 30 秒内没有退出' } + Pass ('没有弹框,第二个进程 {0:N1} 秒后退出(退出码 {1})' -f $sw.Elapsed.TotalSeconds, $again.ExitCode) + Start-Sleep -Seconds 1 + if ($script:proc2.HasExited) { Fail ('P2 不该退出,退出码 {0}' -f $script:proc2.ExitCode) } + Pass 'P2 照常运行' + Shot 'p2-brought-to-front' +} + +function Check-NotWritable { + Start-Check '5 folder not writable' + Expand-Archive -LiteralPath $Zip -DestinationPath $P3 -Force + $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value + # 只拒绝「建文件、建子目录、写属性」这几项。简写的 (W) 里还带着 SYNCHRONIZE 和 + # READ_CONTROL,拒绝它们连 exe 都打不开 + & icacls.exe $P3 /deny ('*{0}:(OI)(CI)(WD,AD,WEA,WA)' -f $sid) | Out-Null + if ($LASTEXITCODE -ne 0) { Fail ('icacls 没能拒绝写入(退出码 {0})' -f $LASTEXITCODE) } + $script:DenySid = $sid + # 确认真的写不进:管理员身份不该绕过一条拒绝 + $probe = Join-Path $P3 'probe' + $denied = $false + try { [IO.File]::WriteAllText($probe, 'x') } catch { $denied = $true } + if (-not $denied) { Remove-Item -LiteralPath $probe -Force; Fail '拒绝写入之后这里照样写得进,模拟不了没有写入权限的文件夹' } + Say ('已拒绝 {0} 写入 {1}' -f $sid, $P3) + + $proc3 = Start-App $P3exe 'p3' + $dialog = Wait-Dialog $proc3 'P3' + Raise $dialog + Shot 'p3-not-writable' + $texts = Get-Texts $dialog + Assert-Contains $texts $Text.NotWritable '主句' + Assert-Contains $texts $Text.NotWritableBody '正文' + Pass '弹了「This folder is not writable」' + Press $dialog $Text.OK 0 + if (-not $proc3.WaitForExit(30000)) { Fail '按了 OK,P3 在 30 秒内没有退出' } + if ($proc3.ExitCode -ne 1) { Fail ('退出码应为 1,实际是 {0}' -f $proc3.ExitCode) } + if (Test-Path -LiteralPath (Join-Path $P3 'data')) { Fail ('不该建出 {0}' -f (Join-Path $P3 'data')) } + Pass '退出码 1,没有建出 data' + if ($script:proc2.HasExited) { Fail 'P2 不该退出' } + & icacls.exe $P3 /remove:d ('*' + $sid) | Out-Null + $script:DenySid = $null +} + +function Check-Installed { + Start-Check '6 installed copy' + Stop-Gracefully $script:proc2 'P2' + Wait-Gone (Join-Path $P2 $CoreName) 'P2 的 twcore.exe' + Pass 'P2 和它的网关退出了' + + # 绿色版这几份都跑过了:文件夹之外不该留下数据 + if (Test-Path -LiteralPath $InstalledData) { + Fail ('绿色版建出了 {0}:{1}' -f $InstalledData, (List-Dir $InstalledData)) + } + foreach ($d in $script:Outside.Keys) { + if (-not $script:Outside[$d] -and (Test-Path -LiteralPath $d)) { + Warn ('绿色版跑过之后多了 {0}:{1}' -f $d, (List-Dir $d)) + } + } + Pass ('绿色版跑完,{0} 仍不存在' -f $InstalledData) + + Install-Silently + $script:InstDir = Find-InstallDir + if (-not $script:InstDir) { Fail '装完找不到安装目录(卸载项里没有 ThinkWatch Lite)' } + foreach ($f in $ExeName, $CoreName, 'uninstall.exe') { + if (-not (Test-Path -LiteralPath (Join-Path $script:InstDir $f))) { + Fail ('安装目录 {0} 里没有 {1}:{2}' -f $script:InstDir, $f, (List-Dir $script:InstDir)) + } + } + Pass ('静默安装到了 {0}:{1}' -f $script:InstDir, (List-Dir $script:InstDir)) + + $script:InstExe = Join-Path $script:InstDir $ExeName + $script:procInst = Start-App $script:InstExe 'installed' + Wait-Ready $script:procInst $InstalledData $script:InstDir 'installed' + Assert-Link $script:InstExe '安装版' + Assert-Aumid $AumidInstalled $InstalledData + if (Test-Path -LiteralPath (Join-Path $script:InstDir 'data')) { + Fail ('安装版不该在安装目录里建 data:{0}' -f (Join-Path $script:InstDir 'data')) + } + Pass '安装版没有把自己当成绿色版' + Shot-Main $script:procInst 'installed-running' +} + +# 静默安装(/S),等它结束,退出码要是 0 +function Install-Silently { + $installer = Start-Process -FilePath $Setup -ArgumentList '/S' -PassThru + $null = $installer.Handle + if (-not $installer.WaitForExit(600000)) { Fail '静默安装 10 分钟没有结束' } + if ($installer.ExitCode -ne 0) { Fail ('静默安装的退出码是 {0}' -f $installer.ExitCode) } +} + +# 静默卸载,等它结束,退出码要是 0。 +# +# `_?=` 让卸载程序就地运行、等得到它结束(不带的话它把自己拷到 %TEMP% 再起一份, +# 这个进程马上就退出了)。代价是它删不掉自己和安装目录,那不在检查之内 +function Uninstall-Silently { + $uninstaller = Join-Path $script:InstDir 'uninstall.exe' + $u = Start-Process -FilePath $uninstaller -ArgumentList ('/S _?={0}' -f $script:InstDir) -PassThru + $null = $u.Handle + if (-not $u.WaitForExit(300000)) { Fail '静默卸载 5 分钟没有结束' } + if ($u.ExitCode -ne 0) { Fail ('静默卸载的退出码是 {0}' -f $u.ExitCode) } + if (Test-Path -LiteralPath $script:InstExe) { Fail ('卸载之后 {0} 还在' -f $script:InstExe) } +} + +function Find-InstallDir { + foreach ($r in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall') { + foreach ($k in @(Get-ChildItem -LiteralPath $r -ErrorAction SilentlyContinue)) { + if ($k.GetValue('DisplayName') -eq $Product) { + $u = Command-Exe ($k.GetValue('UninstallString')) + if ($u) { return (Split-Path $u) } + } + } + } + return $null +} + +# 安装程序、卸载程序关运行中的程序时按文件名找进程、一律结束(Tauri 的 NSIS 模板里的 +# CheckIfAppIsRunning)。绿色版换了名字,它们就不该碰到它 +function Check-InstallerSparesPortable { + Start-Check '6b setup and uninstall leave a running portable copy alone' + Stop-Gracefully $script:procInst 'installed' + Wait-Gone (Join-Path $script:InstDir $CoreName) '安装版的 twcore.exe' + $P2core = Join-Path $P2 $CoreName + $script:proc2 = Start-App $P2exe 'p2-during-setup' + Wait-Ready $script:proc2 (Join-Path $P2 'data') $P2 'P2' + Assert-Link $P2exe 'P2' + $corePid = @(Get-ProcsAt $P2core)[0].ProcessId + + foreach ($step in 'install', 'uninstall') { + if ($step -eq 'install') { Install-Silently } else { Uninstall-Silently } + Start-Sleep -Seconds 2 + if ($script:proc2.HasExited) { + Fail ('静默{0}把运行中的绿色版关掉了(退出码 {1})' -f @{ install = '安装'; uninstall = '卸载' }[$step], $script:proc2.ExitCode) + } + $cores = @(Get-ProcsAt $P2core) + if ($cores.Count -ne 1 -or $cores[0].ProcessId -ne $corePid) { + Fail ('静默{0}之后 P2 的网关不是原来那个(pid {1}):{2}' -f @{ install = '安装'; uninstall = '卸载' }[$step], $corePid, (($cores | ForEach-Object { $_.ProcessId }) -join ', ')) + } + Pass ('静默{0}之后 P2(pid {1})和它的网关(pid {2})都还在运行' -f @{ install = '安装'; uninstall = '卸载' }[$step], $script:proc2.Id, $corePid) + } + if (-not (Same (Link-Exe) $P2exe)) { + Fail ('卸载安装版不该动运行中的绿色版的链接,现在是 {0}' -f (Get-Reg $LinkCommandKey '')) + } + Pass '卸载安装版之后链接仍指向 P2' + Shot 'p2-survived-setup' + + # 第 7 条要一个装着、跑过的安装版:P2 退出,重新装上,启动一次 + Stop-Gracefully $script:proc2 'P2' + Wait-Gone $P2core 'P2 的 twcore.exe' + Install-Silently + $script:procInst = Start-App $script:InstExe 'installed-again' + Wait-Ready $script:procInst $InstalledData $script:InstDir 'installed' + Assert-Link $script:InstExe '安装版' + Assert-Aumid $AumidInstalled $InstalledData +} + +function Check-Uninstall { + Start-Check '7 uninstall' + Stop-Gracefully $script:procInst '安装版' + Wait-Gone (Join-Path $script:InstDir $CoreName) '安装版的 twcore.exe' + Pass '安装版和它的网关退出了' + + # 一份绿色版开着开机自启的样子(winreg::autostart_command) + $run = '"{0}" --autostart' -f $P2exe + # 新机器上 Run 这个键可能还没有。只在没有时建:对已有的键 `New-Item -Force` 会清空它的值 + if (-not (Test-Path -LiteralPath $RunKey)) { New-Item -Path $RunKey | Out-Null } + Set-ItemProperty -LiteralPath $RunKey -Name $Product -Value $run + Say ('开机自启指向 P2:{0}' -f $run) + + Uninstall-Silently + Pass ('静默卸载结束(退出码 0),安装目录里剩下:{0}' -f (List-Dir $script:InstDir)) + + $link = Get-Reg $LinkCommandKey '' + if (Same (Command-Exe $link) $script:InstExe) { Fail ('指向安装版的链接还在:{0}' -f $link) } + if ($link) { Warn ('卸载之后 HKCU 里还有链接:{0}' -f $link) } + Pass '指向安装版的 thinkwatch:// 链接删掉了' + if (Test-Path -LiteralPath "$AumidRoot\$AumidInstalled") { + Fail ('{0} 的登记还在:IconUri = {1}' -f $AumidInstalled, (Get-Reg "$AumidRoot\$AumidInstalled" 'IconUri')) + } + Pass ('{0} 的通知登记删掉了' -f $AumidInstalled) + $now = Get-Reg $RunKey $Product + if ($now -ne $run) { Fail ('指向 P2 的开机自启不该动:现在是 {0}' -f $now) } + Pass ('指向 P2 的开机自启原样留着:{0}' -f $now) + if (-not (Test-Path -LiteralPath $InstalledData)) { Fail ('静默卸载不该删 {0}' -f $InstalledData) } + Pass ('{0} 还在:{1}' -f $InstalledData, (List-Dir $InstalledData)) + + # 和用户退出程序之后再清理一样:P2 的 WebView2 子进程也走了。还占着 `data\webview` 的话 + # 清理会把它留下(见 uninstall::drop_data) + $p2webview = Join-Path $P2 'data\webview' + $left = Wait-For { @(Get-CimInstance Win32_Process -Filter "Name='msedgewebview2.exe'" | Where-Object { "$($_.CommandLine)".IndexOf($p2webview, [StringComparison]::OrdinalIgnoreCase) -ge 0 }).Count -eq 0 } 30 + if (-not $left) { Warn 'P2 的 WebView2 子进程 30 秒后还在' } + + $cleanup = Start-App $P2exe 'p2-cleanup' @('--uninstall-cleanup', '--delete-data') + if (-not $cleanup.WaitForExit(120000)) { Fail '--uninstall-cleanup 在 120 秒内没有结束' } + foreach ($l in (Read-Log (Join-Path $Logs 'e2e-p2-cleanup.out.log'))) { Say (' ' + $l) } + if ($cleanup.ExitCode -ne 0) { Fail ('--uninstall-cleanup --delete-data 的退出码是 {0}' -f $cleanup.ExitCode) } + Pass '--uninstall-cleanup --delete-data 退出码 0' + $P2data = Join-Path $P2 'data' + if (Test-Path -LiteralPath $P2data) { Fail ('{0} 还在:{1}' -f $P2data, (List-Dir $P2data)) } + Pass ('{0} 删掉了' -f $P2data) + $refs = Find-RegRefs $P2 + if ($refs.Count -gt 0) { Fail ('HKCU 里还有指向 P2 的项:{0}' -f ($refs -join ' | ')) } + Pass 'HKCU 里没有指向 P2 的项了(链接、开机自启、通知登记)' +} + +# ==== 跑 ==== + +$failure = $null +try { + Check-Prerequisites + Check-PortableStart + Check-Switch + Check-Cancel + Check-SameExe + Check-NotWritable + Check-Installed + Check-InstallerSparesPortable + Check-Uninstall +} catch { + $failure = $_.Exception.Message + $script:Results.Add([pscustomobject]@{ Check = $script:Check; Result = 'FAIL'; What = $failure }) + Say '' + Say ('FAIL [{0}] {1}' -f $script:Check, $failure) + Say $_.ScriptStackTrace + Shot 'failure' + Dump-State +} finally { + Stop-Leftovers +} + +$appWarnings = Show-AppWarnings +Write-Summary $failure $appWarnings +Say '' +Say ('{0} 项通过,{1} 条提醒' -f @($script:Results | Where-Object { $_.Result -eq 'pass' }).Count, $script:Warnings.Count) +if ($failure) { + $one = ($failure -replace '%', '%25' -replace "`r", '%0D' -replace "`n", '%0A') + Write-Host ('::error title=Windows end-to-end::[{0}] {1}' -f $script:Check, $one) + exit 1 +} +exit 0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b17bbf8c..639986c7 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4802,8 +4802,10 @@ dependencies = [ "tw-types", "url", "windows", + "windows-registry 0.6.1", "windows-sys 0.61.2", "zbus", + "zip", ] [[package]] @@ -6640,6 +6642,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 4ff7f9aa..2f84a093 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" @@ -177,7 +181,16 @@ windows-sys = { version = "0.61", features = [ # 醒来的那个常量在后一个里 "Win32_System_Power", "Win32_UI_WindowsAndMessaging", + # 应用起来之前的系统对话框(`dialog`):TaskDialog 的结构和常量在前一个里,函数本身 + # 用的时候现从 comctl32 里取,所以还要后一个 + "Win32_UI_Controls", + "Win32_System_LibraryLoader", + # 卸载程序删文件之前,等这一份的 core 退出:按进程表找它(`cleanup`) + "Win32_System_Diagnostics_ToolHelp", ] } +# HKCU 下的链接、开机自启、通知登记(`winreg`)。版本是 reqwest 的系统代理在 Windows 上 +# 已经拉进来的那一份,不多编一套 +windows-registry = "0.6" # 系统通知走 WinRT 的 toast:能原地更新、撤回、点开落到对应页面(见 # `notices/windows/`)。版本跟着 Tauri 在 Windows 上已经拉进来的那一份,不多编一套 windows = { version = "0.61", features = [ @@ -190,3 +203,7 @@ windows = { version = "0.61", features = [ tokio = { version = "1", features = ["test-util", "macros", "rt-multi-thread"] } # 接管、扫描的测试在一台假机器(临时目录)上做 tempfile = "3.20" + +# 测程序清单系统认不认(`dialog` 的测试):照它建一个激活上下文 +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { version = "0.61", features = ["Win32_System_ApplicationInstallationAndServicing"] } diff --git a/src-tauri/app.manifest b/src-tauri/app.manifest new file mode 100644 index 00000000..da3a9c10 --- /dev/null +++ b/src-tauri/app.manifest @@ -0,0 +1,21 @@ + + + + + + + + + + true/pm + PerMonitorV2, PerMonitor + + + diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 50114ef8..6538dfab 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,6 +1,24 @@ fn main() { core_tag(); - tauri_build::build() + // 发版流水线打的 Windows 构建带着它(见 `update::official_build`):没有它,旁边没有卸载 + // 程序的 release 构建不算绿色版。代码里用 `option_env!` 读,变了要重编 + println!("cargo:rerun-if-env-changed=TW_OFFICIAL_BUILD"); + // **Windows 的程序清单自己给**(`app.manifest`),比 tauri-build 默认的那份多一项: + // 按每个显示器的 DPI 绘制。 + // + // 默认那份只有 comctl32 第 6 版的依赖,进程的 DPI 设置要等 tao 建事件循环时用 + // `SetProcessDpiAwarenessContext` 设。而微软的文档要求这个设置**在进程出现任何界面 + // 之前**设好、之后再设会失败:Tauri 起来之前弹过系统对话框的那一次(切换实例、等它 + // 退出),tao 那一下就可能落空,整个应用按不感知 DPI 画、在缩放过的屏幕上发糊。写在清单里,进程 + // 一启动就是这一档,那几个对话框也跟着清楚。comctl32 的依赖原样保留: + // `TaskDialogIndirect` 只在第 6 版里有(见 `dialog`) + println!("cargo:rerun-if-changed=app.manifest"); + let windows = tauri_build::WindowsAttributes::new().app_manifest(include_str!("app.manifest")); + if let Err(e) = + tauri_build::try_build(tauri_build::Attributes::new().windows_attributes(windows)) + { + panic!("tauri-build: {e:#}"); + } } /// 这一版应用配的是哪一版 core:`Cargo.lock` 里锁到的 tw-api 的版本号。 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/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/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/atomic_file.rs b/src-tauri/src/atomic_file.rs index e44de9b3..f1fd3e70 100644 --- a/src-tauri/src/atomic_file.rs +++ b/src-tauri/src/atomic_file.rs @@ -54,14 +54,15 @@ fn beside(path: &Path, dir: &Path) -> PathBuf { mod tests { use super::*; - fn tmp(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!( - "tw-atomic-{}-{name}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&d); - d + /// 一个还不存在的目录(`write` 要自己把它建出来),放在一个测试结束就删掉的临时 + /// 目录里:过了、没过都删 + fn tmp(name: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::Builder::new() + .prefix(&format!("tw-atomic-{name}-")) + .tempdir() + .unwrap(); + let d = root.path().join("d"); + (root, d) } fn names(dir: &Path) -> Vec { @@ -75,25 +76,23 @@ mod tests { #[test] fn the_new_content_replaces_the_old_and_nothing_else_is_left_behind() { - let dir = tmp("replace"); + let (_root, dir) = tmp("replace"); let file = dir.join("app.json"); // 目录不在就建出来 write(&file, b"{\"a\":1}").unwrap(); write(&file, b"{\"b\":2}").unwrap(); assert_eq!(std::fs::read(&file).unwrap(), b"{\"b\":2}"); assert_eq!(names(&dir), ["app.json"]); - std::fs::remove_dir_all(&dir).unwrap(); } /// 换不上去(这里是目标位置上有一个目录):报错,临时文件不留下 #[test] fn a_failed_replace_leaves_no_temp_file_behind() { - let dir = tmp("failed"); + let (_root, dir) = tmp("failed"); std::fs::create_dir_all(dir.join("notices.json")).unwrap(); assert!(write(&dir.join("notices.json"), b"[]").is_err()); assert_eq!(names(&dir), ["notices.json"]); assert!(dir.join("notices.json").is_dir()); - std::fs::remove_dir_all(&dir).unwrap(); } /// 换上去的是一个新文件,不是在原处重写:**在那之前打开它的,读到的还是完整的 @@ -102,7 +101,7 @@ mod tests { #[test] fn a_reader_that_opened_the_old_file_still_reads_all_of_it() { use std::io::Read; - let dir = tmp("reader"); + let (_root, dir) = tmp("reader"); let file = dir.join("notices.json"); write(&file, b"[\"old\"]").unwrap(); let mut before = std::fs::File::open(&file).unwrap(); @@ -111,6 +110,5 @@ mod tests { before.read_to_string(&mut text).unwrap(); assert_eq!(text, "[\"old\"]"); assert_eq!(std::fs::read(&file).unwrap(), b"[\"new\",\"longer\"]"); - std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src-tauri/src/autostart.rs b/src-tauri/src/autostart.rs index a998c06c..7d917fd2 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 调。 @@ -76,56 +77,37 @@ pub fn plist_path(bundle_id: &str) -> Option { }) } -/// 开发构建里禁用自启。 +/// 开发构建里禁用自启(Windows 上连同链接、通知的登记,见 `winreg`)。 /// /// `cargo tauri dev` 期间调 `enable()` 会把 `target/debug/…` 写进 plist, /// 然后每次开机 launchd 都会去启动一个可能已经被 `cargo clean` 掉的 /// 二进制。**这是个只在开发者自己机器上发作的坑**,所以更容易被忽略。 -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() +/// Windows 上自己编的 release 构建也算开发构建(见 `update::windows_kind`):否则在 +/// 开发机上跑一次 `target\release\…`,装好的那一份的链接和开机自启就改指向了它。 +pub fn allowed_in_this_build() -> bool { + #[cfg(windows)] + { + !cfg!(debug_assertions) && crate::update::kind() != crate::update::Install::Dev } - 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; + #[cfg(not(windows))] + { + !cfg!(debug_assertions) } - 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` 里那串字节说的是「开着」还是「被关掉了」。 /// /// 十二个字节,**只有第一个有意义**。已知的取值:`0x02` 开、`0x03` 关、 @@ -145,7 +127,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) } @@ -197,13 +179,19 @@ mod tests { fn dev_builds_refuse_to_register() { // debug 构建注册的话,plist 里会写 target/debug/…,然后每次开机 // launchd 去启动一个可能已经 cargo clean 掉的二进制。 + if cfg!(debug_assertions) { + assert!(!allowed_in_this_build()); + } + // Windows 上测试程序(`target\…\deps` 里,旁边没有卸载程序、不是发版构建) + // 怎么编都不算 + #[cfg(windows)] + assert!(!allowed_in_this_build()); + #[cfg(not(windows))] assert_eq!(allowed_in_this_build(), !cfg!(debug_assertions)); } /// **只在 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/autostart/linux.rs b/src-tauri/src/autostart/linux.rs index 1bff1ca0..1f4fb684 100644 --- a/src-tauri/src/autostart/linux.rs +++ b/src-tauri/src/autostart/linux.rs @@ -638,7 +638,7 @@ mod tests { #[cfg(unix)] #[test] fn enable_disable_and_repair_work_on_a_real_file() { - let dir = tempdir(); + let (_tmp, dir) = tempdir(); let file = dir.join("autostart/app.thinkwatch.lite.desktop"); let at = |program: &str| Autostart { file: file.clone(), @@ -689,7 +689,6 @@ mod tests { a.disable().unwrap(); assert!(!file.exists()); - let _ = std::fs::remove_dir_all(dir); } /// What desktop-file-utils thinks of the entry, for every nasty path. Skipped @@ -697,7 +696,7 @@ mod tests { #[cfg(unix)] #[test] fn desktop_file_validate_accepts_the_entry() { - let dir = tempdir(); + let (_tmp, dir) = tempdir(); std::fs::create_dir_all(&dir).unwrap(); for (i, p) in NASTY.iter().filter(|p| !p.contains('%')).enumerate() { let file = dir.join(format!("e{i}.desktop")); @@ -728,20 +727,17 @@ mod tests { String::from_utf8_lossy(&out.stderr) ); } - let _ = std::fs::remove_dir_all(dir); } + /// A scratch directory, removed when the test ends (passed or failed). Keep the + /// guard alive for the whole test #[cfg(unix)] - fn tempdir() -> PathBuf { - let d = std::env::temp_dir().join(format!( - "tw-autostart-test-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir_all(&d).unwrap(); - d + fn tempdir() -> (tempfile::TempDir, PathBuf) { + let d = tempfile::Builder::new() + .prefix("tw-autostart-test-") + .tempdir() + .unwrap(); + let p = d.path().to_path_buf(); + (d, p) } } diff --git a/src-tauri/src/cleanup.rs b/src-tauri/src/cleanup.rs new file mode 100644 index 00000000..838675e7 --- /dev/null +++ b/src-tauri/src/cleanup.rs @@ -0,0 +1,272 @@ +//! 系统卸载程序调用的无界面清理:`--uninstall-cleanup`(可带 `--delete-data`)。 +//! +//! 和应用内「卸载」走同一套步骤(见 `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() { + 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/connection/launch.rs b/src-tauri/src/connection/launch.rs index f0b73f87..a9617a70 100644 --- a/src-tauri/src/connection/launch.rs +++ b/src-tauri/src/connection/launch.rs @@ -75,17 +75,20 @@ pub fn option_held() -> bool { mod tests { use super::*; - fn tmp(name: &str) -> std::path::PathBuf { - let p = std::env::temp_dir().join(format!("tw-launch-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p + /// 一个空目录,测试结束(过了、没过)就删掉。拿着返回的第一项到测试结束 + fn tmp(name: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let d = tempfile::Builder::new() + .prefix(&format!("tw-launch-{name}-")) + .tempdir() + .unwrap(); + let p = d.path().to_path_buf(); + (d, p) } /// 连着两次没走到就绪,第三次先让人选;走到过一次就从头数 #[test] fn two_unfinished_launches_make_the_third_one_ask() { - let dir = tmp("guard"); + let (_tmp, dir) = tmp("guard"); assert_eq!(why(begin(&dir), false), None); assert_eq!(why(begin(&dir), false), None); assert_eq!(why(begin(&dir), false), Some(Why::Unfinished)); 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/connection/secrets.rs b/src-tauri/src/connection/secrets.rs index 613e39f6..eea30b36 100644 --- a/src-tauri/src/connection/secrets.rs +++ b/src-tauri/src/connection/secrets.rs @@ -86,10 +86,15 @@ pub fn remove(dir: &Path, id: &str) -> anyhow::Result<()> { mod tests { use super::*; - fn tmp(name: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!("tw-secrets-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - p + /// 一个还不存在的目录(由被测代码建),放在测试结束(过了、没过)就删掉的临时 + /// 目录里。拿着返回的第一项到测试结束 + fn tmp(name: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::Builder::new() + .prefix(&format!("tw-secrets-{name}-")) + .tempdir() + .unwrap(); + let p = root.path().join("d"); + (root, p) } #[test] @@ -103,7 +108,7 @@ mod tests { #[test] fn keys_are_stored_per_connection_and_removed() { - let dir = tmp("trip"); + let (_tmp, dir) = tmp("trip"); store(&dir, "a1", &"a".repeat(64)).unwrap(); store(&dir, "b2", &"b".repeat(64)).unwrap(); store(&dir, "a1", &"c".repeat(64)).unwrap(); @@ -120,7 +125,8 @@ mod tests { #[test] fn the_file_is_private_from_the_start() { use std::os::unix::fs::PermissionsExt; - let dir = tmp("mode").join("data"); + let (_tmp, dir) = tmp("mode"); + let dir = dir.join("data"); store(&dir, "a1", &"a".repeat(64)).unwrap(); store(&dir, "a2", &"b".repeat(64)).unwrap(); let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; @@ -132,7 +138,7 @@ mod tests { /// 连接列表那份文件里没有密钥:两份分开放 #[test] fn the_list_file_never_sees_a_key() { - let dir = tmp("apart"); + let (_tmp, dir) = tmp("apart"); store(&dir, "a1", &"e".repeat(64)).unwrap(); crate::connection::store::save(&dir, &crate::connection::store::Connections::default()) .unwrap(); diff --git a/src-tauri/src/connection/store.rs b/src-tauri/src/connection/store.rs index 42c75c40..077deca9 100644 --- a/src-tauri/src/connection/store.rs +++ b/src-tauri/src/connection/store.rs @@ -143,11 +143,14 @@ pub fn new_id() -> String { mod tests { use super::*; - fn tmp(name: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!("tw-conns-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p + /// 一个空目录,测试结束(过了、没过)就删掉。拿着返回的第一项到测试结束 + fn tmp(name: &str) -> (tempfile::TempDir, PathBuf) { + let d = tempfile::Builder::new() + .prefix(&format!("tw-conns-{name}-")) + .tempdir() + .unwrap(); + let p = d.path().to_path_buf(); + (d, p) } fn remote(id: &str, name: &str) -> Remote { @@ -162,7 +165,7 @@ mod tests { #[test] fn with_no_file_there_is_only_this_mac() { - let dir = tmp("none"); + let (_tmp, dir) = tmp("none"); let c = load(&dir); assert!(c.remotes.is_empty()); assert_eq!(c.startup_target(), LOCAL); @@ -170,14 +173,14 @@ mod tests { #[test] fn a_corrupt_file_falls_back_to_this_mac() { - let dir = tmp("corrupt"); + let (_tmp, dir) = tmp("corrupt"); std::fs::write(path(&dir), b"{ nope").unwrap(); assert_eq!(load(&dir), Connections::default()); } #[test] fn the_list_survives_a_round_trip() { - let dir = tmp("trip"); + let (_tmp, dir) = tmp("trip"); let want = Connections { remotes: vec![remote("a1", "home-server")], last_used: "a1".into(), diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index aafa049e..89be9193 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -554,23 +554,32 @@ mod tests { assert_eq!(ops.len(), 6); } - /// 一份写着钥匙的配置。**钥匙是在的**:下面几条要测的是建连那一步,不是读钥匙 - fn key_file(name: &str) -> PathBuf { - let p = std::env::temp_dir().join(format!("tw-key-{}-{name}.yaml", std::process::id())); + /// 一份写着钥匙的配置。**钥匙是在的**:下面几条要测的是建连那一步,不是读钥匙。 + /// + /// 放在一个测试结束(过了、没过)就删掉的临时目录里,拿着返回的第一项到测试结束 + fn key_file(name: &str) -> (tempfile::TempDir, PathBuf) { + let d = tempfile::Builder::new() + .prefix(&format!("tw-key-{name}-")) + .tempdir() + .unwrap(); + let p = d.path().join("config.yaml"); std::fs::write( &p, format!("listen:\n control:\n key: \"{}\"\n", "ab".repeat(32)), ) .unwrap(); - p + (d, p) } /// 一个一定连不上的客户端:这个平台默认的那种传输,指向一个不存在的地方。 - fn unreachable() -> ControlClient { - ControlClient::new( + /// 第二项是钥匙所在的临时目录,拿着它到测试结束 + fn unreachable() -> (ControlClient, tempfile::TempDir) { + let (dir, key) = key_file("unreachable"); + let c = ControlClient::new( tw_api::control::Address::in_dir(Path::new("/tmp/tw-definitely-not-there-xyz")), - key_file("unreachable"), - ) + key, + ); + (c, dir) } /// 配置还没写出来(或者还没写进钥匙):**和 socket 不在同一句话** —— core 起来时 @@ -585,7 +594,11 @@ mod tests { assert!(msg.contains("core"), "{msg}"); assert!(!msg.contains("tw-no-such-config"), "{msg}"); - let p = std::env::temp_dir().join(format!("tw-key-{}-nokey.yaml", std::process::id())); + let dir = tempfile::Builder::new() + .prefix("tw-key-nokey-") + .tempdir() + .unwrap(); + let p = dir.path().join("config.yaml"); std::fs::write(&p, "listen:\n gateway:\n port: 1\n").unwrap(); let no_key = ControlClient::new( tw_api::control::Address::in_dir(Path::new("/tmp/tw-definitely-not-there-xyz")), @@ -593,14 +606,13 @@ mod tests { ); let msg = format!("{:#}", no_key.status().await.unwrap_err()); assert!(msg.contains("core"), "{msg}"); - let _ = std::fs::remove_file(p); } /// 连不上时说 core 不在,**不带地址和系统原话**:几乎每个命令在 /// core 不在的时候回给界面的都是这一句 #[tokio::test] async fn connecting_to_a_missing_socket_says_core_is_not_there() { - let c = unreachable(); + let (c, _key) = unreachable(); let msg = format!("{:#}", c.status().await.unwrap_err()); assert!(msg.contains("core"), "{msg}"); assert!(!msg.contains("os error"), "{msg}"); @@ -614,11 +626,12 @@ mod tests { /// 先要把端口读出来,而「文件还没写」正是刚把 core 拉起来那一小段的常态。 #[tokio::test] async fn a_missing_port_file_also_says_core_is_not_there() { + let (_key, key) = key_file("noport"); let c = ControlClient::new( tw_api::control::Address::Loopback { port_file: PathBuf::from("/tmp/tw-no-such-port-file-xyz"), }, - key_file("noport"), + key, ); let msg = format!("{:#}", c.status().await.unwrap_err()); assert!(msg.contains("core"), "{msg}"); @@ -629,13 +642,14 @@ mod tests { /// 写了一半的端口文件也算「还没好」,不是一个要报给用户的错。 #[tokio::test] async fn a_half_written_port_file_counts_as_not_ready() { - let d = std::env::temp_dir().join(format!("tw-port-{}", std::process::id())); + let (dir, key) = key_file("halfport"); + let d = dir.path().join("control.port"); std::fs::write(&d, "12").unwrap(); let c = ControlClient::new( tw_api::control::Address::Loopback { port_file: d.clone(), }, - key_file("halfport"), + key, ); // 12 是个能解析的端口号,但没人听 —— 连接失败,同一句话 let msg = format!("{:#}", c.status().await.unwrap_err()); @@ -648,14 +662,13 @@ mod tests { !msg.contains("invalid digit"), "别把 parse 的原话给用户:{msg}" ); - let _ = std::fs::remove_file(&d); } /// 同一句话按界面语言说 #[test] fn the_unreachable_message_follows_the_interface_language() { use crate::i18n::{Lang, with_lang}; - let c = unreachable(); + let (c, _key) = unreachable(); // 跑在当前线程上:`with_lang` 只改这一个线程看到的语言 let rt = tokio::runtime::Builder::new_current_thread() .enable_all() diff --git a/src-tauri/src/desktop_entry.rs b/src-tauri/src/desktop_entry.rs index 82fdcce7..2116802a 100644 --- a/src-tauri/src/desktop_entry.rs +++ b/src-tauri/src/desktop_entry.rs @@ -317,8 +317,11 @@ mod tests { #[test] fn uninstall_removes_both_files_and_says_so_once() { - let dir = std::env::temp_dir().join(format!("tw-desktop-rm-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); + let tmp = tempfile::Builder::new() + .prefix("tw-desktop-rm-") + .tempdir() + .unwrap(); + let dir = tmp.path().join("share"); let f = Files::under(&dir, "app.thinkwatch.lite"); // Nothing there (a development build): nothing to say assert!(f.remove().is_empty()); @@ -328,18 +331,18 @@ mod tests { assert_eq!(out.len(), 1); assert!(out[0].ok, "{}", out[0].text); assert!(!f.desktop.exists() && !f.icon.exists()); - std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn an_unchanged_file_is_not_rewritten() { - let dir = std::env::temp_dir().join(format!("tw-desktop-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let f = dir.join("a/b.desktop"); + let tmp = tempfile::Builder::new() + .prefix("tw-desktop-") + .tempdir() + .unwrap(); + let f = tmp.path().join("a/b.desktop"); assert!(write_if_changed(&f, b"one").unwrap()); assert!(!write_if_changed(&f, b"one").unwrap()); assert!(write_if_changed(&f, b"two").unwrap()); assert_eq!(std::fs::read(&f).unwrap(), b"two"); - std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index f72205f9..0280abf4 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -69,10 +69,11 @@ mod tests { /// 诊断包从建出来那一刻就只有属主能读;同一秒再写一次也照样是 0600、内容是新的 #[test] fn the_diagnostics_file_is_private_from_the_start_and_can_be_rewritten() { - let dir = - std::env::temp_dir().join(format!("tw-diag-{}", crate::connection::store::new_id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join("diagnostics-1.md"); + let dir = tempfile::Builder::new() + .prefix("tw-diag-") + .tempdir() + .unwrap(); + let path = dir.path().join("diagnostics-1.md"); write_private(&path, b"first").unwrap(); write_private(&path, b"second").unwrap(); assert_eq!(std::fs::read(&path).unwrap(), b"second"); @@ -82,6 +83,5 @@ mod tests { let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); } - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src-tauri/src/dialog.rs b/src-tauri/src/dialog.rs new file mode 100644 index 00000000..131773a6 --- /dev/null +++ b/src-tauri/src/dialog.rs @@ -0,0 +1,402 @@ +//! 应用起来之前的系统原生对话框。 +//! +//! 绿色版的文件夹写不进、缺 WebView2、已经有一个实例在运行,这几件事都发生在 +//! Tauri 起来之前,没有网页可用,只能用系统自己的对话框。只有 Windows 上有; +//! 其他平台上什么都不做。 +//! +//! Windows 上是 `TaskDialogIndirect`:按钮上的字由这里定(`MessageBoxW` 只有系统给的 +//! 「确定」「取消」,而且跟着系统语言,不跟应用设置的语言),主句加大、正文另起。它只在 +//! comctl32 的第 6 版里有,而那一版要靠程序清单里的依赖才拿得到 —— tauri-build 给 exe +//! 嵌的默认清单里有(`common-controls-v6`),**测试程序没有**。所以不静态链接它,用的时候 +//! 现取:取不到(没有清单的进程)就退回 `MessageBoxW`。静态链接的话,测试程序会因为 +//! 缺这个入口点根本起不来。 +//! +//! 清单里同时声明了按每个显示器的 DPI 绘制(见 `build.rs`),这几个对话框在缩放过的 +//! 屏幕上因此不发糊 —— 它们出现的时候 Tauri 还没起来,没人替进程设这一项。 + +/// 弹一个对话框,返回按下的按钮的下标;关掉对话框、或者这个平台不支持时是 `None`。 +/// +/// `instruction` 是加大显示的那一句主句,`content` 是它下面的正文(可以为空)。 +/// `buttons` 从左到右排,第一个是默认按钮,字原样显示。没有父窗口:调用的时候 +/// Tauri 还没起来,进程里没有别的窗口。 +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 + } +} + +/// 一边在另一个线程上跑 `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() + } +} + +/// 退路对话框(`MessageBoxW`)的正文:主句、正文,有两个按钮时再加一句问句 —— +/// 第一个按钮的字加上问号,对应「是」;「否」是第二个 +#[cfg_attr(not(windows), allow(dead_code))] +fn fallback_text(instruction: &str, content: &str, buttons: &[&str]) -> String { + let mut parts = vec![instruction.to_string()]; + if !content.is_empty() { + parts.push(content.to_string()); + } + if buttons.len() >= 2 { + parts.push(format!("{}{}", buttons[0], tr!("?", "?"))); + } + parts.join("\n\n") +} + +#[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::{ + 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::{ + IDNO, IDOK, IDYES, MB_ICONINFORMATION, MB_OK, MB_YESNO, 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() + } + + /// 现取 `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) + }) + } + + pub fn show(title: &str, instruction: &str, content: &str, buttons: &[&str]) -> Option { + let Some(dialog) = task_dialog() else { + return message_box(title, instruction, content, buttons); + }; + 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 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`、或者它没弹出来时的退路:`MessageBoxW`,按钮上的字由 + /// 系统定。两个按钮的时候用「是」「否」,把第一个按钮的字接在正文后面当问句(见 + /// [`super::fallback_text`])—— 只给「确定」「取消」的话,「确定」到底是哪个选择谁也 + /// 看不出来 + fn message_box( + title: &str, + instruction: &str, + content: &str, + buttons: &[&str], + ) -> Option { + let text = wide(&super::fallback_text(instruction, content, buttons)); + let caption = wide(title); + let kind = if buttons.len() >= 2 { MB_YESNO } 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 | IDYES if !buttons.is_empty() => Some(0), + IDNO if buttons.len() >= 2 => Some(1), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::i18n::{Lang, with_lang}; + + /// 退路对话框只有「是」「否」:选「是」就是第一个按钮,所以问的正是它 + #[test] + fn the_fallback_asks_the_first_button_as_a_question() { + let zh = with_lang(Lang::Zh, || { + fallback_text( + "缺少 Microsoft Edge WebView2 运行时", + "ThinkWatch Lite 无法启动。", + &["前往下载", "退出"], + ) + }); + assert_eq!( + zh, + "缺少 Microsoft Edge WebView2 运行时\n\nThinkWatch Lite 无法启动。\n\n前往下载?" + ); + let en = with_lang(Lang::En, || { + fallback_text( + "ThinkWatch Lite is already running", + "", + &["Stop the running program and start this one", "Cancel"], + ) + }); + assert_eq!( + en, + "ThinkWatch Lite is already running\n\nStop the running program and start this one?" + ); + // 一个按钮(「确定」)不问 + assert_eq!(fallback_text("甲", "乙", &["确定"]), "甲\n\n乙"); + } + + /// 程序清单(`app.manifest`,见 `build.rs`):comctl32 第 6 版的依赖一定在 + /// (`TaskDialogIndirect` 靠它),DPI 那一项也在 + #[test] + fn the_manifest_keeps_common_controls_and_declares_dpi() { + let manifest = include_str!("../app.manifest"); + assert!(manifest.contains(r#"name="Microsoft.Windows.Common-Controls""#)); + assert!(manifest.contains(r#"version="6.0.0.0""#)); + assert!(manifest.contains(">PerMonitorV2, PerMonitor")); + assert!(manifest.contains(">true/pm")); + } + + /// **清单写坏了,应用根本起不来**(「并行配置不正确」),而测试程序不带清单,别处 + /// 看不出来。这里让系统照它建一个激活上下文:格式不对、依赖的程序集找不到都会失败 + #[cfg(windows)] + #[test] + fn windows_accepts_the_manifest() { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::ApplicationInstallationAndServicing::{ + ACTCTXW, CreateActCtxW, ReleaseActCtx, + }; + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("app.manifest"); + std::fs::write(&file, include_str!("../app.manifest")).unwrap(); + let path: Vec = file + .to_string_lossy() + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let ctx = ACTCTXW { + cbSize: std::mem::size_of::() as u32, + lpSource: path.as_ptr(), + ..Default::default() + }; + // SAFETY: 结构里的路径以 0 结尾、在调用期间活着 + let handle = unsafe { CreateActCtxW(&ctx) }; + assert_ne!( + handle, + INVALID_HANDLE_VALUE, + "{}", + std::io::Error::last_os_error() + ); + // SAFETY: 上面建出来的 + unsafe { ReleaseActCtx(handle) }; + } +} 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/lib.rs b/src-tauri/src/lib.rs index 7c43a07a..02c16528 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); @@ -324,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())) @@ -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/notices/linux.rs b/src-tauri/src/notices/linux.rs index dce7dc37..45b71c3e 100644 --- a/src-tauri/src/notices/linux.rs +++ b/src-tauri/src/notices/linux.rs @@ -690,19 +690,20 @@ mod native { mod tests { use super::*; - fn tmp(name: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!( - "tw-linux-notices-{}-{name}-{}", - std::process::id(), - super::super::now_ms() - )); - let _ = std::fs::remove_dir_all(&d); - d.join(STORE_FILE) + /// The table's file in a directory that does not exist yet, inside a scratch + /// directory removed when the test ends (passed or failed). Keep the guard + fn tmp(name: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let root = tempfile::Builder::new() + .prefix(&format!("tw-linux-notices-{name}-")) + .tempdir() + .unwrap(); + let f = root.path().join("d").join(STORE_FILE); + (root, f) } #[test] fn a_saved_table_comes_back_in_the_same_session() { - let f = tmp("same"); + let (_tmp, f) = tmp("same"); let mut t = Table::new(stamp("boot", "2", ":1.40")); assert!(t.set("upstream:甲", 7)); assert!(!t.set("upstream:甲", 7)); @@ -716,7 +717,7 @@ mod tests { #[test] fn another_boot_session_or_server_drops_the_whole_table() { - let f = tmp("other"); + let (_tmp, f) = tmp("other"); let mut t = Table::new(stamp("boot", "2", ":1.40")); t.set("core", 9); t.save(&f); @@ -734,7 +735,7 @@ mod tests { #[test] fn a_missing_or_broken_file_is_an_empty_table() { - let f = tmp("broken"); + let (_tmp, f) = tmp("broken"); assert_eq!(Table::load(&f, "s"), Table::new("s".into())); std::fs::create_dir_all(f.parent().unwrap()).unwrap(); std::fs::write(&f, "{not json").unwrap(); @@ -922,10 +923,21 @@ mod tests { return; } }; - let dir = std::env::temp_dir().join(format!("tw-dbus-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let store = dir.join(STORE_FILE); + // The notifier tasks write the table in here. Removed at the end, once they + // have finished (a write after the removal would create it again); when the + // test fails part-way, the guard stops them first (see `Scratch`) + let scratch = Scratch { + tasks: Arc::default(), + dir: Some( + tempfile::Builder::new() + .prefix("tw-dbus-") + .tempdir() + .unwrap(), + ), + }; + let store = scratch.path().join("d").join(STORE_FILE); let clicks: Clicks = Arc::default(); + let running = scratch.tasks.clone(); let spawn = |clicks: Clicks| { let (tx, task) = start(Config { app_name: "ThinkWatch Lite".into(), @@ -935,7 +947,9 @@ mod tests { store: Some(store.clone()), on_click: Box::new(move |k, t| clicks.lock().unwrap().push((k, t))), }); - (tx, tokio::spawn(task)) + let task = tokio::spawn(task); + running.lock().unwrap().push(task.abort_handle()); + (tx, task) }; let (tx, task) = spawn(clicks.clone()); @@ -1091,7 +1105,43 @@ mod tests { drop(tx); task.await.unwrap(); drop(srv); - let _ = std::fs::remove_dir_all(&dir); + drop(scratch); + } + + /// The test's temporary directory, and the notifier tasks that write into it. + /// + /// **A failing assertion unwinds while those tasks still run**, and one of them + /// may be halfway through saving the table: removing the directory first lets + /// that write create it again. So on drop the tasks are aborted and waited for + /// (an abort lands at their next await, after any write in progress), and only + /// then is the directory removed. On the passing path every task has already + /// been awaited and this is just the removal + struct Scratch { + tasks: Arc>>, + dir: Option, + } + + impl Scratch { + fn path(&self) -> &std::path::Path { + self.dir.as_ref().expect("removed only on drop").path() + } + } + + impl Drop for Scratch { + fn drop(&mut self) { + let tasks = + std::mem::take(&mut *self.tasks.lock().unwrap_or_else(|e| e.into_inner())); + for t in &tasks { + t.abort(); + } + // Blocking here is fine: the test runs on the multi-threaded runtime, so + // the other workers finish the aborted tasks meanwhile + let until = std::time::Instant::now() + Duration::from_secs(5); + while tasks.iter().any(|t| !t.is_finished()) && std::time::Instant::now() < until { + std::thread::sleep(Duration::from_millis(10)); + } + drop(self.dir.take()); + } } async fn wait_for(cond: impl Fn() -> bool) { 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/store.rs b/src-tauri/src/notices/store.rs index 5fc88c80..44897d25 100644 --- a/src-tauri/src/notices/store.rs +++ b/src-tauri/src/notices/store.rs @@ -66,14 +66,15 @@ mod tests { use super::super::{Level, Mode, Notices, Signal}; use super::*; - fn tmp(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!( - "tw-notice-store-{}-{name}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&d); - d + /// 一个还不存在的目录(写的时候建出来),放在测试结束(过了、没过)就删掉的临时 + /// 目录里。拿着返回的第一项到测试结束 + fn tmp(name: &str) -> (tempfile::TempDir, PathBuf) { + let root = tempfile::Builder::new() + .prefix(&format!("tw-notice-store-{name}-")) + .tempdir() + .unwrap(); + let d = root.path().join("d"); + (root, d) } fn notice(key: &str) -> Notice { @@ -100,20 +101,19 @@ mod tests { /// 先取的那一份后排到:它比盘上的旧,**不该盖掉** #[test] fn an_older_snapshot_never_overwrites_a_newer_one() { - let dir = tmp("order"); + let (_tmp, dir) = tmp("order"); let s = Store::new(dir.join("notices.json")); s.save(2, &[notice("b")]); s.save(1, &[notice("a")]); assert_eq!(keys(&s.load()), ["b"]); s.save(3, &[notice("c")]); assert_eq!(keys(&Store::new(dir.join("notices.json")).load()), ["c"]); - std::fs::remove_dir_all(&dir).unwrap(); } /// 几个线程同时往总线上报:**最后落盘的就是列表此刻的样子**,一条不少 #[test] fn after_changes_from_many_threads_the_file_is_what_is_listed() { - let dir = tmp("threads"); + let (_tmp, dir) = tmp("threads"); let bus: Arc = Notices::new(Vec::new(), Some(dir.clone()), Mode::App); std::thread::scope(|scope| { for t in 0..8 { @@ -131,17 +131,15 @@ mod tests { let on_disk = Store::new(dir.join("notices.json")).load(); assert_eq!(on_disk.len(), 160); assert_eq!(keys(&on_disk), keys(&bus.list())); - std::fs::remove_dir_all(&dir).unwrap(); } /// 读不出来(写到一半的旧文件、别的东西)按空的算,不挡启动 #[test] fn a_broken_file_reads_as_empty() { - let dir = tmp("broken"); + let (_tmp, dir) = tmp("broken"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("notices.json"), "[{\"key\":\"a\",").unwrap(); assert!(Store::new(dir.join("notices.json")).load().is_empty()); assert!(Store::new(dir.join("missing.json")).load().is_empty()); - std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src-tauri/src/notices/tests.rs b/src-tauri/src/notices/tests.rs index fd08c5e5..3a93f012 100644 --- a/src-tauri/src/notices/tests.rs +++ b/src-tauri/src/notices/tests.rs @@ -509,12 +509,12 @@ async fn suspicious_content_appearing_again_after_being_read_is_news_again() { /// (这一条用一个临时目录:要的正是存下来又读回来的那一份) #[tokio::test] async fn a_block_read_before_a_restart_does_not_silence_the_next_one() { - let dir = std::env::temp_dir().join(format!( - "tw-notices-restart-{}-{}", - std::process::id(), - now_ms() - )); - let _ = std::fs::remove_dir_all(&dir); + // 删掉它的是这个守卫:过了、没过都删。声明在两条总线之前,比它们活得久 + let root = tempfile::Builder::new() + .prefix("tw-notices-restart-") + .tempdir() + .unwrap(); + let dir = root.path().join("d"); let before = Notices::new( vec![Box::new(Rec::default())], Some(dir.clone()), @@ -531,7 +531,6 @@ async fn a_block_read_before_a_restart_does_not_silence_the_next_one() { after.ingest(blocked("relay"), T0 + 60_000); assert_eq!(shown.lock().unwrap().len(), 1, "重启之后再拦下,要说"); assert!(!after.list()[0].read); - let _ = std::fs::remove_dir_all(&dir); } // ---------------------------------------------------------------- 开关 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/portable.rs b/src-tauri/src/portable.rs new file mode 100644 index 00000000..29cae6c9 --- /dev/null +++ b/src-tauri/src/portable.rs @@ -0,0 +1,983 @@ +//! 绿色版:Windows 上解压即用的 zip。 +//! +//! 和安装版的区别只在三处:数据放在 exe 旁边的 `data\`(安装版在 +//! `%APPDATA%\ThinkWatch`)、自更新换的是这个文件夹里的文件(不跑安装程序)、 +//! twcore 只认同目录那一份。怎么认出自己是绿色版见 [`is_portable`]。 +//! +//! **两份数据互不相干。**安装版和绿色版是两套独立的设置,谁也不读谁的;同一时间 +//! 只能运行其中一个(见 `single`)。 +//! +//! 自更新(按一次之后全自动,不弹 UAC):更新器下载这一版的 zip、验签,网关停下 +//! 之后把文件夹里的两个 exe 各自**先改名成 `<名字>.old`,再写新的** —— Windows 不让 +//! 删一个正在运行的 exe,但让改它的名字。改名、写入任何一步失败都把改过的名字改 +//! 回来([`swap_in`])。留下的 `.old` 在下次启动时删掉([`prepare`])。 + +use std::path::{Path, PathBuf}; + +/// 绿色版 zip 里的应用本体,**用产品名,和安装版的 `thinkwatch-lite.exe` 不同名**。 +/// +/// 内容就是安装程序里的那一个 exe(release.yml 打包时改的名)。不同名是因为安装程序和 +/// 卸载程序要关掉运行中的程序时按**文件名**找进程、一律结束(Tauri 的 NSIS 模板里的 +/// `CheckIfAppIsRunning`,按机器安装时连别的账户的也结束):同名的话,装一次、卸一次 +/// 安装版,正在运行的绿色版就被悄悄关掉了。解压出来的文件夹里,这个名字也更好认。 +/// +/// 自更新只认 zip 里的这个名字;换进来时写到**正在运行的那个路径**上(见 [`install`]), +/// 用户改过名也一样 +pub const APP_EXE: &str = "ThinkWatch Lite.exe"; + +/// 绿色版 zip 里的网关,放在应用本体旁边(见 `gateway::bundled_core`) +pub const CORE_EXE: &str = "twcore.exe"; + +/// 数据目录在 exe 旁边叫什么 +const DATA: &str = "data"; + +/// WebView2 的数据(缓存、本地存储)放在数据目录下的哪里 +const WEBVIEW: &str = "webview"; + +/// 绿色版把数据目录交给 core 和别处用的那个变量(`tw_api::data::dir` 最先看它) +const HOME_VAR: &str = "THINKWATCH_HOME"; + +/// 和 [`HOME_VAR`] 一起设的记号:**是哪个 exe 设的**。见 [`forget_inherited`] +pub const MARKER_VAR: &str = "THINKWATCH_PORTABLE_EXE"; + +/// 启动最早的一步:绿色版把数据目录定在 exe 旁边的 `data\`,并确认能写。 +/// +/// 用不了时弹系统对话框说清楚是哪一种(见 [`Refusal`]),然后退出进程。不是绿色版的话, +/// 只做第一件事:丢掉从别的绿色版继承来的数据目录([`forget_inherited`])。 +/// **必须在任何线程起来之前调用**:它要改进程的环境变量(`THINKWATCH_HOME`)。 +pub fn prepare() { + forget_inherited(); + if !is_portable() { + return; + } + // **找不到自己在哪也不往下走。**那样数据会落到默认的 `%APPDATA%\ThinkWatch`,也就是 + // 安装版的那一套设置 —— 两套本该互不相干,混在一起比起不来更糟 + let Ok(exe) = std::env::current_exe() else { + refuse(&Refusal::NoFolder); + }; + let Some(dir) = exe.parent().map(Path::to_path_buf) else { + refuse(&Refusal::NoFolder); + }; + let data = data_dir_in(&dir); + if let Err(e) = writable(&data) { + tracing::error!("绿色版的数据目录用不了({}):{e}", data.display()); + refuse(&refusal(&dir, &data, &e)); + } + // **core 是子进程,继承这个变量**:界面和网关看的是同一个目录。从用户环境 + // 带给 core 的那一份变量里不含它(`supervisor::user_env::keep`),不会被盖掉。 + // 记号一起设,见 `forget_inherited` + // + // SAFETY: `run()` 的第一步就是这里,在 Tauri、tokio、任何插件起线程之前;`main` + // 在这之前只装了日志订阅器,它不起线程。进程里此刻只有主线程,没有别的线程 + // 可能同时读写环境变量。 + unsafe { + std::env::set_var(HOME_VAR, &data); + std::env::set_var(MARKER_VAR, &exe); + } + sweep(dir); +} + +/// 丢掉**从另一个绿色版继承来的**数据目录。 +/// +/// 绿色版把 `THINKWATCH_HOME` 设在整个进程上,它拉起的每个程序都继承这个变量:core +/// (正要它这样),还有比如由这里第一次打开的浏览器。之后从那个浏览器点一个 +/// `thinkwatch://` 链接拉起安装版,安装版就会用上绿色版的数据目录 —— 两套设置混成一套。 +/// +/// 所以绿色版设它的时候带上记号([`MARKER_VAR`] = 自己的 exe)。启动时记号在、而且不是 +/// 当前这个 exe 的,两个变量都丢掉,用这一份自己的位置。**没有记号的 `THINKWATCH_HOME` +/// 照旧算数**:那是用户自己设的(测试隔离、换数据目录)。各种构建都做 +fn forget_inherited() { + let marker = std::env::var_os(MARKER_VAR).map(|m| m.to_string_lossy().into_owned()); + let exe = std::env::current_exe() + .ok() + .map(|e| e.to_string_lossy().into_owned()); + if inherited(marker.as_deref(), exe.as_deref()) { + tracing::info!( + marker = marker.as_deref().unwrap_or_default(), + "数据目录是另一份绿色版传下来的,不用它" + ); + // SAFETY: 同 `prepare` —— 这是它的第一步,进程里只有主线程 + unsafe { + std::env::remove_var(HOME_VAR); + std::env::remove_var(MARKER_VAR); + } + } +} + +/// 继承来的数据目录要不要丢掉:有记号、而记号不是当前这个 exe(不知道自己是谁也丢)。 +/// 路径的比法见 `winreg::same_path` +fn inherited(marker: Option<&str>, current: Option<&str>) -> bool { + match (marker, current) { + (None, _) => false, + (Some(marker), Some(current)) => !crate::winreg::same_path(marker, current), + (Some(_), None) => true, + } +} + +/// 绿色版用不了数据目录的几种情况 +#[derive(Debug, PartialEq, Eq)] +enum Refusal { + /// 连自己在哪个文件夹都不知道 + NoFolder, + /// 文件夹写不进(解压到了 Program Files、只读的盘) + NotWritable, + /// 文件夹写得进,`data\` 却打不开:它是另一个 Windows 账户建的(重装了系统、硬盘换到 + /// 了另一台机器)。建的时候给了只有那个账户能进的 DACL(见 `private_dir`),这里 + /// 不去放宽它 —— 里面有 API 密钥 + OtherAccount(PathBuf), +} + +/// 主句、正文 +fn refusal_text(why: &Refusal) -> (&'static str, String) { + let extract = || -> String { + tr!( + "请将 ThinkWatch Lite 解压到可写入的位置后再运行。", + "Extract ThinkWatch Lite to a writable location and run it again." + ) + .into() + }; + match why { + Refusal::NoFolder => ( + tr!( + "无法确定程序所在的文件夹", + "The program folder could not be determined" + ), + extract(), + ), + Refusal::NotWritable => ( + tr!("当前文件夹没有写入权限", "This folder is not writable"), + extract(), + ), + Refusal::OtherAccount(data) => ( + tr!( + "数据文件夹属于另一个 Windows 账户", + "The data folder belongs to another Windows account" + ), + tr!( + format!( + "当前账户无法访问 {}。可在该文件夹属性的「安全」页取得所有权后再运行,或将 ThinkWatch Lite 解压到新的位置。", + data.display() + ), + format!( + "The current account cannot access {}. Take ownership of the folder on its Properties › Security page and run ThinkWatch Lite again, or extract ThinkWatch Lite to a new location.", + data.display() + ) + ), + ), + } +} + +/// 说清楚,然后退出 +fn refuse(why: &Refusal) -> ! { + // 设置在用不了的那个目录里,读不到;只能按系统的语言说 + crate::i18n::set(crate::i18n::system()); + let (instruction, content) = refusal_text(why); + crate::dialog::show( + "ThinkWatch Lite", + instruction, + &content, + &[tr!("确定", "OK")], + ); + std::process::exit(1); +} + +/// 数据目录用不了(`writable` 报了 `error`)的时候,是哪一种。 +/// +/// **三样都对上才说是另一个账户的**:`data\` 在(从文件夹的目录列表里看,不碰它本身)、 +/// 打它不开(没有权限;或者连它在不在都看不了、建的时候才撞上「已经存在」)、文件夹 +/// 本身写得进。别的一律按「文件夹写不进」说 +fn refusal(folder: &Path, data: &Path, error: &std::io::Error) -> Refusal { + let denied = matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::AlreadyExists + ); + if denied && listed_dir(folder, data) && probe(folder).is_ok() { + Refusal::OtherAccount(data.to_path_buf()) + } else { + Refusal::NotWritable + } +} + +/// 文件夹的目录列表里有没有 `dir` 这个子目录。**只读文件夹的列表**:`dir` 本身打不开 +/// 时,问它自己(`exists`、`metadata`)答不准 +fn listed_dir(folder: &Path, dir: &Path) -> bool { + let Some(name) = dir.file_name() else { + return false; + }; + let Ok(entries) = std::fs::read_dir(folder) else { + return false; + }; + entries.flatten().any(|e| { + e.file_name().to_string_lossy().to_lowercase() == name.to_string_lossy().to_lowercase() + && e.file_type().is_ok_and(|t| t.is_dir()) + }) +} + +/// 这一份是不是绿色版:Windows、发版流水线打的构建、旁边没有卸载程序(见 +/// [`crate::update::windows_kind`])。 +/// +/// 答案在进程的一生里不会变,问一次记下来。 +pub fn is_portable() -> bool { + #[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 { + 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)?; + probe(data) +} + +/// 在 `dir` 里写一个文件再删掉 +fn probe(dir: &Path) -> std::io::Result<()> { + let probe = dir.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`),**只认根目录下的 +/// [`APP_EXE`] 和 [`CORE_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}; + + /// 测试文件夹建在哪个目录下。不设就是系统的临时目录 + const TEST_DIR: &str = "TW_PORTABLE_TEST_DIR"; + + /// 一个测试用的空文件夹,建在 [`TEST_DIR`] 指的目录下。 + /// + /// 绿色版会被放在 U 盘(FAT32、exFAT)和网络共享上,自更新在那里也得换得动、 + /// 改得回。Windows 的 CI 把这一组测试在这几种卷上各跑一遍(ci.yml) + fn scratch() -> tempfile::TempDir { + match std::env::var_os(TEST_DIR) { + Some(base) => tempfile::tempdir_in(base).unwrap(), + None => tempfile::tempdir().unwrap(), + } + } + + /// 和 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 = scratch(); + 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()); + } + } + + /// 记号是别的 exe 的(另一份绿色版拉起的浏览器又拉起了这一个):丢掉;是自己的 + /// (更新完重启自己):留着;没有记号(用户自己设的 `THINKWATCH_HOME`):留着 + #[test] + fn only_a_home_inherited_from_another_portable_copy_is_dropped() { + let portable = r"D:\Tools\ThinkWatch Lite\ThinkWatch Lite.exe"; + let installed = r"C:\Program Files\ThinkWatch Lite\thinkwatch-lite.exe"; + assert!(inherited(Some(portable), Some(installed))); + assert!(inherited(Some(portable), None)); + assert!(!inherited(Some(portable), Some(portable))); + assert!(!inherited( + Some(portable), + Some(r"\\?\d:\tools\thinkwatch lite\THINKWATCH LITE.EXE") + )); + assert!(!inherited(None, Some(installed))); + assert!(!inherited(None, None)); + } + + #[test] + fn each_refusal_says_what_is_wrong() { + let data = PathBuf::from(r"E:\ThinkWatch Lite\data"); + let other = Refusal::OtherAccount(data.clone()); + with_lang(Lang::Zh, || { + assert_eq!( + refusal_text(&Refusal::NotWritable), + ( + "当前文件夹没有写入权限", + "请将 ThinkWatch Lite 解压到可写入的位置后再运行。".to_string() + ) + ); + assert_eq!( + refusal_text(&Refusal::NoFolder).0, + "无法确定程序所在的文件夹" + ); + assert_eq!( + refusal_text(&other), + ( + "数据文件夹属于另一个 Windows 账户", + format!( + "当前账户无法访问 {}。可在该文件夹属性的「安全」页取得所有权后再运行,或将 ThinkWatch Lite 解压到新的位置。", + data.display() + ) + ) + ); + }); + with_lang(Lang::En, || { + assert_eq!( + refusal_text(&other), + ( + "The data folder belongs to another Windows account", + format!( + "The current account cannot access {}. Take ownership of the folder on its Properties › Security page and run ThinkWatch Lite again, or extract ThinkWatch Lite to a new location.", + data.display() + ) + ) + ); + assert_eq!( + refusal_text(&Refusal::NoFolder).0, + "The program folder could not be determined" + ); + }); + } + + #[test] + fn a_directory_is_found_in_its_folder_listing() { + let dir = tempfile::tempdir().unwrap(); + let data = data_dir_in(dir.path()); + assert!(!listed_dir(dir.path(), &data)); + std::fs::write(&data, b"").unwrap(); + assert!(!listed_dir(dir.path(), &data), "同名的文件不算"); + std::fs::remove_file(&data).unwrap(); + std::fs::create_dir(&data).unwrap(); + assert!(listed_dir(dir.path(), &data)); + } + + /// 文件夹写得进、`data` 却打不开:是另一个账户的;文件夹本身写不进:照旧是写不进。 + /// unix 上用权限位代替 Windows 的 DACL,判断走的是同一段 + #[cfg(unix)] + #[test] + fn a_locked_data_folder_is_told_apart_from_a_read_only_folder() { + use std::os::unix::fs::PermissionsExt; + // root 不受权限位约束,那种环境下这条说明不了什么 + if unsafe { libc::geteuid() } == 0 { + return; + } + let dir = tempfile::tempdir().unwrap(); + let data = data_dir_in(dir.path()); + std::fs::create_dir(&data).unwrap(); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o000)).unwrap(); + let e = writable(&data).unwrap_err(); + let why = refusal(dir.path(), &data, &e); + std::fs::set_permissions(&data, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(why, Refusal::OtherAccount(data.clone())); + + let ro = dir.path().join("ro"); + std::fs::create_dir(&ro).unwrap(); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap(); + let data = data_dir_in(&ro); + let e = writable(&data).unwrap_err(); + let why = refusal(&ro, &data, &e); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap(); + assert_eq!(why, Refusal::NotWritable); + } + + /// 真的 DACL:`data` 只给 SYSTEM(像是另一个账户建的、只给那个账户),文件夹照常 + /// 可写 → 是另一个账户的;文件夹本身只给 SYSTEM → 写不进 + #[cfg(windows)] + #[test] + fn a_data_folder_of_another_account_is_recognised() { + use windows_sys::Win32::Security::{DACL_SECURITY_INFORMATION, SetFileSecurityW}; + use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; + + fn wide(p: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() + } + /// 建一个只有 SYSTEM 能进的目录 + fn locked(dir: &Path) { + crate::private_dir::with_security_attributes("D:P(A;OICI;FA;;;SY)", |sa| { + let w = wide(dir); + // SAFETY: 路径以 0 结尾,`sa` 在调用期间有效 + if unsafe { CreateDirectoryW(w.as_ptr(), sa) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + .unwrap(); + } + /// 放开,好让临时目录删得掉(建它的人是所有者,改得了 DACL) + fn unlock(dir: &Path) { + crate::private_dir::with_security_attributes("D:(A;OICI;FA;;;WD)", |sa| { + let w = wide(dir); + // SAFETY: 路径以 0 结尾;安全描述符来自 `sa`,在调用期间有效 + let ok = unsafe { + SetFileSecurityW( + w.as_ptr(), + DACL_SECURITY_INFORMATION, + (*sa).lpSecurityDescriptor, + ) + }; + if ok == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }) + .unwrap(); + } + + let dir = tempfile::tempdir().unwrap(); + let data = data_dir_in(dir.path()); + locked(&data); + let e = writable(&data).unwrap_err(); + let why = refusal(dir.path(), &data, &e); + unlock(&data); + assert_eq!(why, Refusal::OtherAccount(data.clone()), "{e}"); + + let ro = dir.path().join("ro"); + locked(&ro); + let data = data_dir_in(&ro); + let e = writable(&data).unwrap_err(); + let why = refusal(&ro, &data, &e); + unlock(&ro); + assert_eq!(why, Refusal::NotWritable, "{e}"); + } + + /// 应用本体按正在运行的那个名字找(用户可能改过名),网关的名字是定的 + #[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") + ] + ); + assert_eq!( + leftovers(folder, std::ffi::OsStr::new("tw.exe"))[0], + folder.join("tw.exe.old") + ); + } + + #[test] + fn removing_leftovers_counts_missing_files_as_removed() { + let dir = scratch(); + 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() { + assert_eq!(APP_EXE, "ThinkWatch Lite.exe"); + 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"")]), + zip_of(&[("thinkwatch-lite.exe", b"MZ"), ("twcore.exe", b"MZ")]), + 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 = scratch(); + 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 = scratch(); + 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 = scratch(); + 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/prefs.rs b/src-tauri/src/prefs.rs index 7420cef4..a82bfa21 100644 --- a/src-tauri/src/prefs.rs +++ b/src-tauri/src/prefs.rs @@ -146,41 +146,38 @@ pub fn update(dir: &Path, f: impl FnOnce(&mut Prefs)) -> anyhow::Result { mod tests { use super::*; - fn tmp() -> PathBuf { - let p = std::env::temp_dir().join(format!( - "tw-prefs-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p + /// 一个空目录,测试结束(过了、没过)就删掉。拿着返回的第一项到测试结束 + fn tmp() -> (tempfile::TempDir, PathBuf) { + let d = tempfile::Builder::new() + .prefix("tw-prefs-") + .tempdir() + .unwrap(); + let p = d.path().to_path_buf(); + (d, p) } #[test] fn with_no_settings_file_the_check_is_on_and_the_language_and_theme_follow_the_system() { - let dir = tmp(); + let (_tmp, dir) = tmp(); let p = load(&dir); assert!(p.check_updates); assert_eq!(p.language, None); assert_eq!(p.theme, None); assert_eq!(p.notices, Mode::System); - std::fs::remove_dir_all(&dir).unwrap(); } /// 关掉之后存得住:出厂是开的,用户写下的 `false` 必须照样被读成 `false`。 #[test] fn turning_the_check_off_is_remembered() { - let dir = tmp(); + let (_tmp, dir) = tmp(); update(&dir, |p| p.check_updates = false).unwrap(); assert!(!load(&dir).check_updates); - std::fs::remove_dir_all(&dir).unwrap(); } /// 改一项不能把另一项冲掉。 #[test] fn changing_one_setting_keeps_the_others() { - let dir = tmp(); + let (_tmp, dir) = tmp(); update(&dir, |p| p.language = Some(Lang::En)).unwrap(); update(&dir, |p| p.check_updates = false).unwrap(); update(&dir, |p| p.notices = Mode::Off).unwrap(); @@ -188,13 +185,12 @@ mod tests { assert_eq!(p.language, Some(Lang::En)); assert!(!p.check_updates); assert_eq!(p.notices, Mode::Off); - std::fs::remove_dir_all(&dir).unwrap(); } /// **每一项都不是出厂值**:哪一项没被读回来,这里就对不上 #[test] fn settings_survive_a_round_trip() { - let dir = tmp(); + let (_tmp, dir) = tmp(); let want = Prefs { check_updates: false, language: Some(Lang::Zh), @@ -212,13 +208,12 @@ mod tests { }; save(&dir, &want).unwrap(); assert_eq!(load(&dir), want); - std::fs::remove_dir_all(&dir).unwrap(); } /// 旧的设置文件里没有菜单栏这一项:**别的设置照旧**,菜单栏按出厂那一档 #[test] fn a_file_without_the_menubar_setting_keeps_everything_else() { - let dir = tmp(); + let (_tmp, dir) = tmp(); std::fs::write( prefs_path(&dir), br#"{"check_updates":false,"language":"en","theme":null,"notices":"app"}"#, @@ -229,26 +224,24 @@ mod tests { assert!(!p.check_updates); assert_eq!(p.menubar, crate::menubar::Style::Full); assert!(p.client_locations.is_empty()); - std::fs::remove_dir_all(&dir).unwrap(); } /// 文件坏了按出厂设置。 #[test] fn a_corrupt_settings_file_falls_back_to_the_default() { - let dir = tmp(); + let (_tmp, dir) = tmp(); std::fs::write(prefs_path(&dir), b"{ not json").unwrap(); assert_eq!(load(&dir), Prefs::default()); // 是 JSON,但不是一份设置 std::fs::write(prefs_path(&dir), b"[\"en\"]").unwrap(); assert_eq!(load(&dir), Prefs::default()); - std::fs::remove_dir_all(&dir).unwrap(); } /// 降级之后:新版本写下的、这一版不认识的值(提醒多了一档、菜单栏多了一种样式、 /// 位置换了写法),**只有那一项按出厂值**,语言、外观、别的客户端的位置照旧 #[test] fn a_value_this_version_does_not_know_resets_only_that_setting() { - let dir = tmp(); + let (_tmp, dir) = tmp(); std::fs::write( prefs_path(&dir), br#"{ @@ -276,13 +269,12 @@ mod tests { ["claude-code"], "读不懂的只丢那一个客户端" ); - std::fs::remove_dir_all(&dir).unwrap(); } /// 写法没变:**旧版本(整份按一个结构体读)照样读得懂这一版写下的文件** #[test] fn what_this_version_writes_an_older_one_still_reads() { - let dir = tmp(); + let (_tmp, dir) = tmp(); update(&dir, |p| { p.language = Some(Lang::En); p.notices = Mode::App; @@ -291,13 +283,12 @@ mod tests { let strict: Prefs = serde_json::from_slice(&std::fs::read(prefs_path(&dir)).unwrap()).unwrap(); assert_eq!(strict, load(&dir)); - std::fs::remove_dir_all(&dir).unwrap(); } /// 类型不对的一项(手改坏的):同样只有那一项按出厂值 #[test] fn a_value_of_the_wrong_type_resets_only_that_setting() { - let dir = tmp(); + let (_tmp, dir) = tmp(); std::fs::write( prefs_path(&dir), br#"{"check_updates":"no","language":"ja","theme":1,"notices":"off","menubar":"icon"}"#, @@ -309,13 +300,12 @@ mod tests { assert_eq!(p.theme, None); assert_eq!(p.notices, Mode::Off); assert_eq!(p.menubar, crate::menubar::Style::Icon); - std::fs::remove_dir_all(&dir).unwrap(); } /// 写到一半的文件(以前不是整份换上去的):按出厂设置,不崩 #[test] fn a_truncated_settings_file_falls_back_to_the_default() { - let dir = tmp(); + let (_tmp, dir) = tmp(); let full = serde_json::to_vec_pretty(&Prefs { language: Some(Lang::En), ..Prefs::default() @@ -323,7 +313,6 @@ mod tests { .unwrap(); std::fs::write(prefs_path(&dir), &full[..full.len() / 2]).unwrap(); assert_eq!(load(&dir), Prefs::default()); - std::fs::remove_dir_all(&dir).unwrap(); } /// 存设置是**整份换上去**,不是在原处截断重写:写到一半退出,留下的是上一份完整的, @@ -332,7 +321,7 @@ mod tests { #[test] fn saving_replaces_the_file_instead_of_rewriting_it_in_place() { use std::io::Read; - let dir = tmp(); + let (_tmp, dir) = tmp(); update(&dir, |p| p.language = Some(Lang::En)).unwrap(); let mut before = std::fs::File::open(prefs_path(&dir)).unwrap(); update(&dir, |p| p.language = Some(Lang::Zh)).unwrap(); @@ -345,6 +334,5 @@ mod tests { .map(|e| e.unwrap().file_name()) .collect(); assert_eq!(files, [PREFS_FILE]); - std::fs::remove_dir_all(&dir).unwrap(); } } diff --git a/src-tauri/src/private_dir.rs b/src-tauri/src/private_dir.rs index c0a3b05a..271dc42d 100644 --- a/src-tauri/src/private_dir.rs +++ b/src-tauri/src/private_dir.rs @@ -10,6 +10,10 @@ use std::path::Path; +/// 带着一份 SDDL 写的安全描述符做一件事。测试拿它造「别的账户的」目录和锁 +#[cfg(all(windows, test))] +pub(crate) use imp::with_security_attributes; + /// 建出数据目录。已经在了就什么都不做。 pub fn create(dir: &Path) -> std::io::Result<()> { if dir.exists() { @@ -149,7 +153,7 @@ mod imp { } /// 把一段 SDDL 变成 `SECURITY_ATTRIBUTES`,用完释放。 - fn with_security_attributes( + pub(crate) fn with_security_attributes( sddl: &str, f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> std::io::Result, ) -> std::io::Result { @@ -183,15 +187,21 @@ mod imp { mod tests { use super::*; - fn tmp(name: &str) -> std::path::PathBuf { - let p = std::env::temp_dir().join(format!("tw-private-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&p); - p + /// 一个还不存在的目录(由 `create` 建),放在测试结束(过了、没过)就删掉的临时 + /// 目录里。拿着返回的第一项到测试结束 + fn tmp(name: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let root = tempfile::Builder::new() + .prefix(&format!("tw-private-{name}-")) + .tempdir() + .unwrap(); + let p = root.path().join("d"); + (root, p) } #[test] fn it_creates_the_directory_and_its_parents() { - let dir = tmp("parents").join("a/b/data"); + let (_tmp, dir) = tmp("parents"); + let dir = dir.join("a/b/data"); create(&dir).unwrap(); assert!(dir.is_dir()); } @@ -199,7 +209,7 @@ mod tests { /// 已经在了就不动它 #[test] fn creating_one_that_is_already_there_is_not_an_error() { - let dir = tmp("again"); + let (_tmp, dir) = tmp("again"); create(&dir).unwrap(); std::fs::write(dir.join("x"), "1").unwrap(); create(&dir).unwrap(); @@ -210,7 +220,8 @@ mod tests { #[test] fn a_new_directory_is_private_on_unix() { use std::os::unix::fs::PermissionsExt; - let dir = tmp("mode").join("data"); + let (_tmp, dir) = tmp("mode"); + let dir = dir.join("data"); create(&dir).unwrap(); let mode = std::fs::metadata(&dir).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o700, "{mode:o}"); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 25346d3f..11268116 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) { @@ -127,22 +129,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 +158,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 +309,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 +322,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 new file mode 100644 index 00000000..21e0fe28 --- /dev/null +++ b/src-tauri/src/single.rs @@ -0,0 +1,781 @@ +//! 同一时间只运行一个实例,谁运行就听谁的。 +//! +//! 安装版和绿色版用的是同一把锁(单实例插件按应用标识起名),所以它们也互斥。 +//! 插件只会把第二个进程的参数转交给第一个、然后让第二个退出;这里补上插件 +//! 不做的那部分:开的是另一个位置的程序时说明情况,并且可以切换过去。 +//! +//! # 切换怎么走 +//! +//! 1. 新开的这一个([`precheck`],Tauri 起来之前)看见锁被占着、占着它的程序在别的位置, +//! 问一句;选了切换,就置位运行中那一个建的命名事件([`handoff_name`])。 +//! 2. 运行中的那一个([`listen`])收到事件:等手上的请求结束(最多三分钟,和装更新时同一套)、 +//! 停掉 core、退出,全程不超过 [`STEP_ASIDE_LIMIT`]。退出时插件放开那把锁。 +//! 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); + +/// 运行中的那一个让位,从收到请求到退出最多花多久:比请求的那一边等的 [`WAIT_LIMIT`] +/// 短,它等得到 +pub(crate) const STEP_ASIDE_LIMIT: std::time::Duration = std::time::Duration::from_secs(190); + +/// 让位时留给停 core 的时间:`stop_and_wait` 请它退、等它、强杀、再等,都在这里面 +pub(crate) const STOP_BUDGET: std::time::Duration = std::time::Duration::from_secs(10); + +/// 单实例插件建的锁 +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 elevated_text() -> &'static str { + tr!( + "运行中的程序以管理员身份运行,无法从此处停止。请先退出该程序。", + "The running program runs as administrator and cannot be stopped from here. Quit it first." + ) +} + +/// 等到上限它还没退 +pub(crate) fn timeout_text() -> &'static str { + tr!( + "运行中的程序未能退出。", + "The running program did not quit." + ) +} + +/// 程序真正启动之前调用。已经有一个实例在运行时,按情况静默转交、提示后退出, +/// 或者等运行中的那个退出后接着启动。没有在运行的实例、或者不是 Windows,什么都不做。 +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, ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, GetLastError, + 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, ResetEvent, 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 { + /// **空句柄连包都不包。**写成 `then_some(Self(h))` 的话,包着空句柄的那一个先建出来 + /// 再丢掉,`Drop` 对空句柄调一次 `CloseHandle`,把调用方接着要读的 `GetLastError` + /// 改成「句柄无效」 + fn new(h: HANDLE) -> Option { + if h.is_null() { None } else { Some(Self(h)) } + } + } + + impl Drop for Handle { + fn drop(&mut self) { + // SAFETY: 这个句柄是这里独有的,只关这一次 + unsafe { CloseHandle(self.0) }; + } + } + + pub fn precheck() { + let lock = match open_mutex(&mutex_name()) { + Ok(lock) => lock, + // 锁不在:没有在运行的实例 + Err(ERROR_FILE_NOT_FOUND) => return, + // **锁在,但打不开**:运行中的那一个权限更高(以管理员身份运行),它建的锁 + // 不让这里碰。插件自己这时也认不出它:`CreateMutexW` 失败、报的不是「已经存在」, + // 插件就当自己是第一个接着启动(它的参数也转不过去 —— 低权限的进程发给高权限 + // 窗口的消息会被系统拦下)。放过去就是两个实例抢同一个端口 + Err(ERROR_ACCESS_DENIED) => { + tell(elevated_text()); + std::process::exit(1); + } + Err(e) => { + tracing::warn!(code = e, "打不开单实例的锁,交给插件判断"); + 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 let Err(code) = signal(&handoff_name(), Duration::from_secs(2)) { + // 对话框开着的时候,运行中的那一个可能已经自己退出了(事件跟着它没了): + // 锁放开了,就照常启动 + if wait_released(lock, Duration::ZERO) { + tracing::info!("运行中的程序已经退出,照常启动"); + return; + } + tell(if code == ERROR_ACCESS_DENIED { + elevated_text() + } else { + 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")]); + } + + /// 打开一把已经在的命名锁(只要能等它的权限)。打不开时是系统给的错误码:不在是 + /// `ERROR_FILE_NOT_FOUND`,在但没有权限是 `ERROR_ACCESS_DENIED` + pub(super) fn open_mutex(name: &str) -> Result { + let name = wide(name); + // SAFETY: 名字以 0 结尾 + let raw = unsafe { OpenMutexW(SYNCHRONIZATION_SYNCHRONIZE, 0, name.as_ptr()) }; + // SAFETY: 紧跟在上一个调用之后,中间没有别的系统调用 + Handle::new(raw).ok_or_else(|| unsafe { GetLastError() }) + } + + /// 等这把锁被放开,至多 `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 + } + + /// 置位运行中那一个建的事件。打不开时是系统给的错误码: + /// + /// - `ERROR_FILE_NOT_FOUND`:没有这个事件。它刚启动、还没建好的话等一小会儿再试 + /// (`patience`);一直没有,那是不支持切换的旧版本(或者它已经退出了)。 + /// - `ERROR_ACCESS_DENIED`:事件在,但它是以管理员身份运行的那一个建的,这里没有 + /// 权限置位。不再等 + pub(super) fn signal(name: &str, patience: Duration) -> Result<(), u32> { + let name = wide(name); + let deadline = Instant::now() + patience; + loop { + // SAFETY: 名字以 0 结尾 + let raw = unsafe { OpenEventW(EVENT_MODIFY_STATE, 0, name.as_ptr()) }; + // SAFETY: 紧跟在上一个调用之后 + let code = unsafe { GetLastError() }; + if let Some(event) = Handle::new(raw) { + // SAFETY: 句柄有效,有 EVENT_MODIFY_STATE 权限 + if unsafe { SetEvent(event.0) } != 0 { + return Ok(()); + } + // SAFETY: 紧跟在上一个调用之后 + return Err(unsafe { GetLastError() }); + } + if code != ERROR_FILE_NOT_FOUND || Instant::now() >= deadline { + return Err(code); + } + 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 Some(event) = create_handoff_event(&handoff_name()) 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}"); + } + } + + /// 建切换用的事件:自动复位、一开始没置位。 + /// + /// **已经有了就先复位。**上一个实例让位时把事件留到了它退出(见 `listen`),这期间 + /// 又有人置位过的话,它就一直是置位的;新起来的这一个要是接着用,一启动就会以为有人 + /// 请它让位 + pub(super) fn create_handoff_event(name: &str) -> Option { + let name = wide(name); + // SAFETY: 名字以 0 结尾;安全属性用默认的 + let raw = unsafe { CreateEventW(std::ptr::null(), 0, 0, name.as_ptr()) }; + // SAFETY: 紧跟在上一个调用之后 + let existed = unsafe { GetLastError() } == ERROR_ALREADY_EXISTS; + let event = Handle::new(raw)?; + if existed { + // SAFETY: 句柄有效,建的时候拿到的是全部权限 + unsafe { ResetEvent(event.0) }; + } + Some(event) + } + + /// 让位:记成用户自己退出的 → 等手上的请求结束(最多三分钟,和装更新时同一套)→ + /// 停掉 core、等它真的走 → 退出。**整个过程有一个总时限**([`STEP_ASIDE_LIMIT`]), + /// 比请求的那一边等的短:到点了不管走到哪一步都退出。 + /// + /// **core 要在退出之前停干净**:插件在退出的那一刻放开锁,新的那一个马上起来拉它自己的 + /// core,旧的还占着网关的端口就会撞上 + async fn step_aside(app: tauri::AppHandle) { + use tauri::Manager; + // 用户在另一个程序里选了「停止运行中的程序」:和菜单里点「退出」一样,退出时 + // 删掉 `.last-exit`,之后再打开这一份不当成更新之后的重开 + crate::updater::quitting_by_user(); + let sup = app + .try_state::() + .map(|st| st.supervisor.clone()); + if let Some(sup) = sup { + let deadline = tokio::time::Instant::now() + STEP_ASIDE_LIMIT; + // 停 core 的时间留出来:等请求最多等到总时限前 `STOP_BUDGET` + let quiet = tokio::time::timeout_at( + deadline - STOP_BUDGET, + crate::updater::wait_for_quiet(sup.control(), |_| {}), + ) + .await; + if quiet.is_err() { + // **到点了照样走**:请求切换的那一个还开着等待的对话框,它等的就是这里 + // 退出;它最多等 `WAIT_LIMIT`,过了就报「未能退出」,两边都落空 + tracing::warn!("让位:请求没在时限内结束,照样退出"); + } + if tokio::time::timeout_at(deadline, sup.stop_and_wait(Duration::from_secs(5))) + .await + .is_err() + { + tracing::warn!("让位:core 没在时限内停下,照样退出(它的 --parent 守望会跟着走)"); + } + } + app.exit(0); + } + + /// 锁、事件、进程路径这几样只能在真的 Windows 上测。名字带上进程号,不和这台机器上 + /// 正在跑的 ThinkWatch Lite 撞 + #[cfg(test)] + mod tests { + use windows_sys::Win32::Foundation::WAIT_TIMEOUT; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::System::Threading::CreateMutexW; + + use super::*; + + fn unique(what: &str) -> String { + format!(r"Local\thinkwatch-test-{}-{what}", std::process::id()) + } + + /// 带着一份安全描述符(SDDL)建对象。`D:P` 是一份空的受保护 DACL:谁都打不开它, + /// 建它的这一个句柄除外 —— 和管理员身份的实例建的锁、事件在这里的样子一样 + fn with_sddl(sddl: &str, f: impl FnOnce(*const SECURITY_ATTRIBUTES) -> T) -> T { + crate::private_dir::with_security_attributes(sddl, |sa| Ok(f(sa))).unwrap() + } + + /// 建一把锁并占着它,像插件那样 + 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_eq!( + open_mutex(&name).err(), + Some(ERROR_FILE_NOT_FOUND), + "等完之后不该还有句柄留着它" + ); + 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()); + } + + /// 只看一眼(问过之后打不开事件时):还被占着是 `false`,已经放开了是 `true` + #[test] + fn a_glance_tells_a_held_lock_from_one_let_go() { + let name = unique("glance"); + 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(); + // SAFETY: 这个线程是它的主人 + unsafe { ReleaseMutex(h.0) }; + } + }); + ready_rx.recv().unwrap(); + assert!(!wait_released(open_mutex(&name).unwrap(), Duration::ZERO)); + let lock = open_mutex(&name).unwrap(); + go_tx.send(()).unwrap(); + owner.join().unwrap(); + assert!(wait_released(lock, Duration::ZERO)); + } + + /// 管理员身份的实例建的锁(这里用一份谁都不让进的 DACL 代替):打开时报的是没有 + /// 权限,不是不在 —— `precheck` 靠这个区分「没在运行」和「在运行但够不着」 + #[test] + fn a_lock_without_access_is_told_apart_from_no_lock() { + let name = unique("denied-lock"); + let w = wide(&name); + let _h = with_sddl("D:P", |sa| { + // SAFETY: 名字以 0 结尾,`sa` 在调用期间有效 + Handle::new(unsafe { CreateMutexW(sa, 1, w.as_ptr()) }).unwrap() + }); + assert_eq!(open_mutex(&name).err(), Some(ERROR_ACCESS_DENIED)); + assert_eq!( + open_mutex(&unique("no-lock")).err(), + Some(ERROR_FILE_NOT_FOUND) + ); + } + + /// 运行中的那一边建了事件,这一边置得上;没建(旧版本)报不在;没有权限(管理员 + /// 身份的实例建的)当场报没有权限,不干等 + #[test] + fn the_handoff_reaches_the_listener_or_says_why_not() { + let name = unique("handoff"); + assert_eq!( + signal(&name, Duration::from_millis(200)), + Err(ERROR_FILE_NOT_FOUND) + ); + + let event = create_handoff_event(&name).unwrap(); + assert_eq!(signal(&name, Duration::from_millis(200)), Ok(())); + // SAFETY: 句柄有效 + assert_eq!(unsafe { WaitForSingleObject(event.0, 1000) }, WAIT_OBJECT_0); + + let denied = unique("denied-event"); + let w = wide(&denied); + let _e = with_sddl("D:P", |sa| { + // SAFETY: 名字以 0 结尾,`sa` 在调用期间有效 + Handle::new(unsafe { CreateEventW(sa, 0, 0, w.as_ptr()) }).unwrap() + }); + let started = Instant::now(); + assert_eq!( + signal(&denied, Duration::from_secs(5)), + Err(ERROR_ACCESS_DENIED) + ); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + /// 上一个实例留下的、已经置位的事件:新的这一个建的时候复位,不会一启动就让位 + #[test] + fn a_stale_signal_is_cleared_when_the_event_is_created_again() { + let name = unique("stale"); + let old = create_handoff_event(&name).unwrap(); + // SAFETY: 句柄有效 + assert_ne!(unsafe { SetEvent(old.0) }, 0); + let fresh = create_handoff_event(&name).unwrap(); + // SAFETY: 句柄有效 + assert_eq!(unsafe { WaitForSingleObject(fresh.0, 0) }, WAIT_TIMEOUT); + assert_eq!(signal(&name, Duration::ZERO), Ok(())); + // SAFETY: 句柄有效 + assert_eq!(unsafe { WaitForSingleObject(fresh.0, 0) }, WAIT_OBJECT_0); + drop(old); + } + + #[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()) + ); + } + + /// 让位的一方要在请求的一方放弃之前走完;等请求之外还要留出停 core 的时间 + #[test] + fn the_running_side_finishes_before_the_requester_gives_up() { + assert!(STEP_ASIDE_LIMIT < WAIT_LIMIT); + assert!(STOP_BUDGET < STEP_ASIDE_LIMIT); + assert!(STEP_ASIDE_LIMIT - STOP_BUDGET >= crate::updater::DRAIN_LIMIT); + } + + #[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), "运行中的程序未能退出。"); + assert_eq!( + with_lang(Lang::Zh, elevated_text), + "运行中的程序以管理员身份运行,无法从此处停止。请先退出该程序。" + ); + assert_eq!( + with_lang(Lang::En, elevated_text), + "The running program runs as administrator and cannot be stopped from here. Quit it first." + ); + } +} diff --git a/src-tauri/src/supervisor/mod.rs b/src-tauri/src/supervisor/mod.rs index 1619b989..cfa061dd 100644 --- a/src-tauri/src/supervisor/mod.rs +++ b/src-tauri/src/supervisor/mod.rs @@ -781,28 +781,94 @@ mod tests { tw_api::control::Address::in_dir(std::path::Path::new("/tw-no-such-dir-xyz")) } - /// 一个不管参数、一直跑到被杀掉的「core」。 + /// 等「迟早该发生的事」最多等多久:core 起来、停下、守护循环收尾。 + /// + /// **这是活性的上限,不是快慢的要求**:只为了出错时报错而不是挂住。平时几毫秒 + /// 的事,机器满载时慢上几秒不算错;「根本不会发生」的错,等多久都抓得到。 + /// 和被测代码自己的超时比快慢的断言(「没等到超时那一档」)比的是传进去的那个 + /// 超时,不是它 + const PATIENCE: Duration = Duration::from_secs(30); + + /// 假 core 认的第一个参数:带着它就当场退出,什么都不做(见 [`warmed`])。守护 + /// 起它时第一个参数是 `serve` + const WARM: &str = "warm"; + + /// 刚写出来的假 core 先空跑一次,再交给测试。 + /// + /// **新写出来的可执行文件,第一次运行要等系统先看过它。**macOS 上这类文件带着 + /// `com.apple.provenance`,第一次 exec 时子进程在跑到第一行之前停住:闲的时候 + /// 零点几秒,几个会话同时在编的时候实测停过 49 秒;同一个文件第二次运行不到 + /// 一毫秒。以前这一段算在测试「core 起来没有」的那五秒里,机器一忙,看 + /// 「起过几次」的两条(`a_core_that_is_still_starting_…`、 + /// `a_core_waiting_out_its_backoff_…`)就超时。慢的是系统、不是守护:放在 + /// 计时之前,不设上限。 + /// + /// 刚写完就运行在 Linux 上可能碰上「文本文件忙」,和守护自己一样等一下再试 + /// (见 `run_once`) + fn warmed(bin: PathBuf) -> PathBuf { + let t0 = Instant::now(); + loop { + let ran = std::process::Command::new(&bin) + .arg(WARM) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match ran { + Ok(status) => { + assert!(status.success(), "假 core 空跑失败:{status}"); + return bin; + } + Err(e) + if e.kind() == std::io::ErrorKind::ExecutableFileBusy + && t0.elapsed() < PATIENCE => + { + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("假 core 起不来:{e}"), + } + } + } + + /// 放假 core 的目录,测试结束(过了、没过)就删掉。 + /// + /// **拿着它的变量要比守护活得久**:守护起的假 core 就在这里面,计数的那种每起一次 + /// 还往里写一行。所以每条测试在守护之前声明它(在守护之后析构);测试走到结尾时 + /// 守护循环都已经跑完、假 core 都已经退出。测试没过、提前 panic 时它先被删掉,但 + /// 单线程的运行时不会再往下跑守护循环,不会再起一个、把目录写回来 + fn fake_core_dir(name: &str) -> tempfile::TempDir { + tempfile::Builder::new() + .prefix(&format!("tw-sup-{name}-")) + .tempdir() + .unwrap() + } + + /// 一个不管参数、一直跑到被杀掉的「core」(除了 [`WARM`])。返回它所在的目录 + /// (见 [`fake_core_dir`])和它自己。 /// /// **两个平台各写一份。**shebang 和执行位是 unix 的东西;Windows 上 /// 写一个 `.cmd`,`std::process::Command` 认得它。 - fn long_runner(name: &str) -> PathBuf { - let dir = std::env::temp_dir().join(format!("tw-sup-{}-{name}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + fn long_runner(name: &str) -> (tempfile::TempDir, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); #[cfg(unix)] - { + let bin = { let bin = dir.join("fake-core"); - std::fs::write(&bin, "#!/bin/sh\nexec sleep 30\n").unwrap(); + let script = format!("#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\nexec sleep 30\n"); + std::fs::write(&bin, script).unwrap(); use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); bin - } + }; #[cfg(windows)] - { + let bin = { let bin = dir.join("fake-core.cmd"); // `timeout` 要一个控制台,测试进程里没有;`ping` 不要。 - std::fs::write(&bin, "@ping -n 30 127.0.0.1 >nul\r\n").unwrap(); + let script = + format!("@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@ping -n 30 127.0.0.1 >nul\r\n"); + std::fs::write(&bin, script).unwrap(); bin - } + }; + (tmp, warmed(bin)) } async fn until_running(s: &Supervisor) -> u32 { @@ -811,7 +877,7 @@ mod tests { if let CoreState::Running { pid } = *rx.borrow_and_update() { return pid; } - tokio::time::timeout(Duration::from_secs(5), rx.changed()) + tokio::time::timeout(PATIENCE, rx.changed()) .await .expect("core 迟迟没起来") .unwrap(); @@ -823,8 +889,9 @@ mod tests { /// 要么进安全模式、把主窗口弹到正在重启的应用上。 #[tokio::test] async fn a_core_stopped_for_exit_is_not_restarted() { + let (_dir, bin) = long_runner("stop"); let s = Arc::new(Supervisor::new( - long_runner("stop"), + bin, None, always_ready(), test_address(), @@ -859,8 +926,9 @@ mod tests { /// 转发。停它、在安全模式里点「重新启动」,都要够得着这个 core #[tokio::test] async fn a_safe_mode_core_stays_in_safe_mode_until_restarted() { + let (_dir, bin) = long_runner("safe"); let s = Arc::new(Supervisor::new( - long_runner("safe"), + bin, None, always_ready(), test_address(), @@ -880,7 +948,7 @@ mod tests { CoreState::Stopped if !began => {} ref other => panic!("安全模式里不该出现 {other:?}"), } - tokio::time::timeout(Duration::from_secs(5), rx.changed()) + tokio::time::timeout(PATIENCE, rx.changed()) .await .expect("迟迟没起来") .unwrap(); @@ -900,12 +968,17 @@ mod tests { } } - #[tokio::test] + /// 没在跑就当场返回,**一点都不等**。 + /// + /// 用暂停的时钟量:它只在运行时无事可做、等着计时器的时候才往前跳,等了那个超时 + /// 就量出整整五秒,一点没等就是零。以前看墙上时钟、限一百毫秒,机器满载时线程 + /// 被晾上一会儿就会误报 + #[tokio::test(start_paused = true)] async fn stopping_what_is_not_running_returns_at_once() { let s = sup(); - let t0 = Instant::now(); + let t0 = tokio::time::Instant::now(); s.stop_and_wait(Duration::from_secs(5)).await; - assert!(t0.elapsed() < Duration::from_millis(100)); + assert_eq!(t0.elapsed(), Duration::ZERO); } /// **控制面答应之前是「启动中」,不是「运行中」。**界面见到「运行中」就去 @@ -920,8 +993,9 @@ mod tests { async move { n >= 3 } }) }; + let (_dir, bin) = long_runner("ready"); let s = Arc::new(Supervisor::new( - long_runner("ready"), + bin, None, third_time, test_address(), @@ -934,7 +1008,7 @@ mod tests { }; let mut seen = Vec::new(); loop { - tokio::time::timeout(Duration::from_secs(5), rx.changed()) + tokio::time::timeout(PATIENCE, rx.changed()) .await .expect("迟迟没有就绪") .unwrap(); @@ -954,8 +1028,9 @@ mod tests { /// 换掉它再起 —— 和启动就崩一个待遇,连着几次就进安全模式 #[tokio::test(start_paused = true)] async fn a_core_that_never_answers_is_replaced() { + let (_dir, bin) = long_runner("never"); let s = Supervisor::new( - long_runner("never"), + bin, None, probe(|| async { false }), test_address(), @@ -976,8 +1051,9 @@ mod tests { /// core,没装成又接回来。记号要是留着,从那以后网关崩了不会再起。 #[tokio::test] async fn after_resuming_a_crash_is_a_crash_again() { + let (_dir, bin) = long_runner("resume"); let s = Arc::new(Supervisor::new( - long_runner("resume"), + bin, None, always_ready(), test_address(), @@ -1013,8 +1089,9 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn a_requested_restart_still_comes_back() { + let (_dir, bin) = long_runner("restart"); let s = Arc::new(Supervisor::new( - long_runner("restart"), + bin, None, always_ready(), test_address(), @@ -1041,14 +1118,13 @@ mod tests { } /// 一个每起一次就往 `starts` 那个文件里记一行的「core」(unix 上记的是它的 pid)。 - /// 返回程序和那个文件 - fn counting_core(name: &str, acts: Acts) -> (PathBuf, PathBuf) { - let dir = std::env::temp_dir().join(format!("tw-sup-{}-{name}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).unwrap(); + /// 返回它所在的目录(见 [`fake_core_dir`])、程序和那个文件。空跑([`warmed`])不记 + fn counting_core(name: &str, acts: Acts) -> (tempfile::TempDir, PathBuf, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); let starts = dir.join("starts"); #[cfg(unix)] - { + let bin = { let bin = dir.join("fake-core"); let then = match acts { Acts::Stays => "exec sleep 30", @@ -1056,23 +1132,30 @@ mod tests { Acts::IgnoresTheAsk => "trap '' TERM\nexec sleep 30", Acts::Crashes => "exit 1", }; - let script = format!("#!/bin/sh\necho $$ >> '{}'\n{then}\n", starts.display()); + let script = format!( + "#!/bin/sh\n[ \"$1\" = {WARM} ] && exit 0\necho $$ >> '{}'\n{then}\n", + starts.display() + ); std::fs::write(&bin, script).unwrap(); use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap(); - (bin, starts) - } + bin + }; #[cfg(windows)] - { + let bin = { let bin = dir.join("fake-core.cmd"); let then = match acts { Acts::Stays | Acts::IgnoresTheAsk => "@ping -n 30 127.0.0.1 >nul", Acts::Crashes => "@exit /b 1", }; - let script = format!("@echo x>> \"{}\"\r\n{then}\r\n", starts.display()); + let script = format!( + "@if \"%~1\"==\"{WARM}\" exit /b 0\r\n@echo x>> \"{}\"\r\n{then}\r\n", + starts.display() + ); std::fs::write(&bin, script).unwrap(); - (bin, starts) - } + bin + }; + (tmp, warmed(bin), starts) } /// 起过几次 @@ -1080,12 +1163,16 @@ mod tests { std::fs::read_to_string(file).map_or(0, |s| s.lines().count()) } + /// 等到起过 `times` 次。只能看文件,所以隔一会儿看一眼 async fn until_started(file: &std::path::Path, times: usize) { - let t0 = Instant::now(); - while starts(file) < times { - assert!(t0.elapsed() < Duration::from_secs(5), "core 迟迟没起来"); - tokio::time::sleep(Duration::from_millis(10)).await; - } + let started = async { + while starts(file) < times { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }; + tokio::time::timeout(PATIENCE, started) + .await + .expect("core 迟迟没起来"); } /// **还在启动的 core 也停得下来。**以前只认「运行中」:切到远程的那一刻本机的 core @@ -1100,7 +1187,7 @@ mod tests { async move { yes } }) }; - let (bin, started) = counting_core("starting", Acts::Stays); + let (_dir, bin, started) = counting_core("starting", Acts::Stays); let s = Arc::new(Supervisor::new( bin, None, @@ -1116,11 +1203,12 @@ mod tests { until_started(&started, 1).await; assert_eq!(s.state(), CoreState::Starting); + // 停还在启动的 core 不走控制面(按句柄直接杀),两个平台上都不该等到超时 let t0 = Instant::now(); - s.stop_and_wait(Duration::from_secs(5)).await; + s.stop_and_wait(PATIENCE).await; assert_eq!(s.state(), CoreState::Stopped, "还在启动的 core 没被停下"); - assert!(t0.elapsed() < Duration::from_secs(5), "等到超时才停下"); - let next = tokio::time::timeout(Duration::from_secs(5), looped) + assert!(t0.elapsed() < PATIENCE, "等到超时才停下"); + let next = tokio::time::timeout(PATIENCE, looped) .await .expect("守护循环没有停下") .unwrap(); @@ -1153,7 +1241,7 @@ mod tests { /// **在退避里等着重起的 core 也停得下来**:不会睡醒了再起一个 #[tokio::test] async fn a_core_waiting_out_its_backoff_is_not_started_again() { - let (bin, started) = counting_core("backoff", Acts::Crashes); + let (_dir, bin, started) = counting_core("backoff", Acts::Crashes); let s = Arc::new(Supervisor::new( bin, None, @@ -1177,14 +1265,14 @@ mod tests { let mut rx = s.watch(); let backing_off = rx.wait_for(|st| matches!(st, CoreState::Restarting { in_ms, .. } if *in_ms > 0)); - let _ = tokio::time::timeout(Duration::from_secs(5), backing_off) + let _ = tokio::time::timeout(PATIENCE, backing_off) .await .expect("迟迟没进退避") .unwrap(); s.stop_and_wait(Duration::from_secs(5)).await; assert_eq!(s.state(), CoreState::Stopped, "退避里的 core 没被停下"); - let next = tokio::time::timeout(Duration::from_secs(5), looped) + let next = tokio::time::timeout(PATIENCE, looped) .await .expect("守护循环没有停下") .unwrap(); @@ -1195,7 +1283,7 @@ mod tests { /// 要停的请求落在两轮之间(比如按要求重启的那一下):**下一轮不起** #[tokio::test] async fn a_stop_between_two_rounds_keeps_the_next_one_from_starting() { - let (bin, started) = counting_core("between", Acts::Stays); + let (_dir, bin, started) = counting_core("between", Acts::Stays); let s = Supervisor::new( bin, None, @@ -1205,7 +1293,7 @@ mod tests { ); // 此刻没有在跑的:只是把记号立起来 s.stop_and_wait(Duration::from_secs(5)).await; - let next = tokio::time::timeout(Duration::from_secs(5), s.run_once(false)) + let next = tokio::time::timeout(PATIENCE, s.run_once(false)) .await .expect("要停的时候又起了一个") .unwrap(); @@ -1221,8 +1309,9 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn a_wedged_core_that_leaves_when_asked_is_not_killed_afterwards() { + let (_dir, bin) = long_runner("wedged-leaves"); let s = Arc::new(Supervisor::new( - long_runner("wedged-leaves"), + bin, None, always_ready(), test_address(), @@ -1253,7 +1342,7 @@ mod tests { /// 请它退出也不走的,**照样强杀、换掉**:收手只收在它自己走了的时候 #[tokio::test] async fn a_wedged_core_that_ignores_the_ask_is_still_killed() { - let (bin, _) = counting_core("wedged-stays", Acts::IgnoresTheAsk); + let (_dir, bin, _) = counting_core("wedged-stays", Acts::IgnoresTheAsk); let s = Arc::new(Supervisor::new( bin, None, @@ -1267,7 +1356,7 @@ mod tests { }; until_running(&s).await; s.report_wedged().await.unwrap(); - let next = tokio::time::timeout(Duration::from_secs(5), looped) + let next = tokio::time::timeout(PATIENCE, looped) .await .expect("强杀之后它还在") .unwrap(); @@ -1283,7 +1372,7 @@ mod tests { /// 也进不了安全模式 #[tokio::test] async fn a_restart_that_could_not_be_asked_for_leaves_no_mark() { - let (bin, _) = counting_core("restart-unasked", Acts::Crashes); + let (_dir, bin, _) = counting_core("restart-unasked", Acts::Crashes); let s = Supervisor::new( bin, None, @@ -1308,8 +1397,9 @@ mod tests { /// 那个重启的记号**不能带进下一轮**,不然接回来之后第一次真崩溃会被当成按要求重启 #[tokio::test] async fn a_restart_cut_short_by_a_stop_does_not_carry_into_the_next_round() { + let (_dir, bin) = long_runner("restart-then-stop"); let s = Arc::new(Supervisor::new( - long_runner("restart-then-stop"), + bin, None, always_ready(), test_address(), @@ -1342,10 +1432,13 @@ mod tests { ); } - /// 一个握了手就一声不吭的控制面。走回环端口:两个平台都认这一种传输 - async fn silent_control_plane(name: &str) -> (tw_api::control::Address, PathBuf) { - let dir = std::env::temp_dir().join(format!("tw-sup-{}-{name}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); + /// 一个握了手就一声不吭的控制面。走回环端口:两个平台都认这一种传输。第一项是放 + /// 配置和端口文件的目录(见 [`fake_core_dir`]),拿着它到测试结束 + async fn silent_control_plane( + name: &str, + ) -> (tempfile::TempDir, tw_api::control::Address, PathBuf) { + let tmp = fake_core_dir(name); + let dir = tmp.path(); let key = "ab".repeat(32); let config = dir.join("config.yaml"); std::fs::write( @@ -1367,14 +1460,18 @@ mod tests { } } }); - (tw_api::control::Address::Loopback { port_file }, config) + ( + tmp, + tw_api::control::Address::Loopback { port_file }, + config, + ) } /// 控制面握了手却一直不答:**请它退出这一步不能跟着挂住**。心跳换掉卡死的 core、 /// 装更新、退出应用都要先走这一步 #[tokio::test] async fn asking_a_core_whose_control_plane_never_answers_gives_up() { - let (at, config) = silent_control_plane("silent").await; + let (_dir, at, config) = silent_control_plane("silent").await; let s = Supervisor::new(PathBuf::from("/x"), None, always_ready(), at, config); let asked = tokio::time::timeout(Duration::from_secs(10), s.ask_to_exit(NO_SUCH_PID)) .await diff --git a/src-tauri/src/supervisor/user_env.rs b/src-tauri/src/supervisor/user_env.rs index 23e79437..400048e2 100644 --- a/src-tauri/src/supervisor/user_env.rs +++ b/src-tauri/src/supervisor/user_env.rs @@ -86,7 +86,8 @@ pub async fn last() -> Vec<(String, String)> { /// core 行为、而不只是给 `${…}` 取值的那些一律不带,保持应用自己的: /// /// - 自己人:`THINKWATCH_HOME` 决定数据目录,从 shell 带一个不同的过去,core 和 -/// 界面就各看各的数据了;`TW_*` 是 core 自己的开关。 +/// 界面就各看各的数据了;和它一起设的 `THINKWATCH_PORTABLE_EXE`(见 `portable`) +/// 同理;`TW_*` 是 core 自己的开关。 /// - 代理:「系统代理」这一档会读 `HTTPS_PROXY` 这些。终端里为了别的工具设的 /// 代理,不该悄悄改掉网关的出站路径。 /// - `PATH`:Windows 上系统和用户各有一份、要拼起来才对,单拿一份会更糟。 @@ -367,6 +368,7 @@ mod tests { "HTTPS_PROXY", "no_proxy", "THINKWATCH_HOME", + crate::portable::MARKER_VAR, "TW_LOG", "RUST_LOG", "DYLD_INSERT_LIBRARIES", diff --git a/src-tauri/src/uninstall.rs b/src-tauri/src/uninstall.rs index 4a690758..2fe2b3b3 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,193 +33,319 @@ 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) ), - }); + }) + .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()) + )); } - // 注销 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!( + if !restored_all { + return UninstallStep::failed(tr!( format!( - "未能取消开机启动({e})。请在「系统设置 › 通用 › 登录项」中关闭 ThinkWatch Lite。" + "数据目录已保留:{}。上面有客户端没能还原,它们的备份在这个目录里;处理好之后再删除它", + dir.display() ), format!( - "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in System Settings › General › Login Items." + "Data directory kept: {}. Some clients above could not be restored, and their backups are in it; delete it once they are sorted out.", + dir.display() ) - ))), - #[cfg(windows)] - Err(e) => log.push(UninstallStep::failed(tr!( - format!("未能取消开机启动({e})。请在「设置 › 应用 › 启动」中关闭 ThinkWatch Lite。"), + )); + } + 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!( - "Launch at login could not be turned off ({e}). Turn off ThinkWatch Lite in Settings › Apps › Startup." + "The data directory could not be deleted: {} ({e})", + dir.display() ) - ))), - // 各家桌面的「开机启动的应用」设置不在同一个地方,文件在哪儿却是确定的 - #[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}.") - ))) + )), + } +} + +/// 删 `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, } } - // 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!( - format!( - "数据目录已保留:{}。上面有客户端没能还原,它们的备份在这个目录里;处理好之后再删除它", - dir.display() - ), - format!( - "Data directory kept: {}. Some clients above could not be restored, and their backups are in it; delete it once they are sorted out.", - dir.display() +} + +fn remove(dir: &Path, keep: Option<&Path>) -> std::io::Result<()> { + // `keep` 得是 `dir` 下面的一项才算数。两边可能是不同的写法(一个带 `\\?\` 前缀), + // 对不上字面时按磁盘上的真实路径比 + let same = |a: &Path| { + a == dir + || matches!( + (std::fs::canonicalize(a), std::fs::canonicalize(dir)), + (Ok(x), Ok(y)) if x == y ) - ))); - } else if drop_data { - // 先停掉本机的 core:它开着数据库和日志,Windows 上删到一半就会失败,而且留下 - // 一个没有数据目录还在跑的网关。连着远程时它本来就停着,这一步什么都不做 - if let Some(st) = app.try_state::() { - st.supervisor - .stop_and_wait(std::time::Duration::from_secs(5)) - .await; + }; + let Some(name) = keep + .filter(|k| k.parent().is_some_and(same)) + .and_then(|k| k.file_name()) + else { + return std::fs::remove_dir_all(dir); + }; + // 一项一项删,删不掉的记下第一个原因、接着删别的 + let mut first = None; + for e in std::fs::read_dir(dir)? { + let e = e?; + if e.file_name() == name { + continue; } - 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() - ) - ))), + let p = e.path(); + 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 +374,132 @@ 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"]); + // 换一种写法(Windows 上 `canonicalize` 给的是带 `\\?\` 的那种)也认得出是它 + std::fs::write(dir.join("config.yaml"), "x").unwrap(); + let spelled = std::fs::canonicalize(&dir).unwrap().join("webview"); + let step = drop_data(&dir, true, true, Some(&spelled), ONCE); + assert!(step.ok && webview.exists() && !dir.join("config.yaml").exists()); + // 不在数据目录里的 `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/update.rs b/src-tauri/src/update.rs index 563db9dd..674041c1 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,35 @@ pub fn nsis_installed(exe: &Path) -> Install { } } +/// Windows 上这一份是怎么装上来的:安装程序装的、绿色版,还是开发构建。 +/// +/// **旁边有卸载程序的是安装版**(见 [`nsis_installed`]);没有的,发版流水线打出来的 +/// 就是绿色版 —— 发出去的 exe 只有这两种去处,zip 里没有卸载程序。开发构建旁边也没有: +/// `tauri dev`、`cargo build` 靠 `release` 分开,自己编的 release 构建(`cargo build +/// --release`、本机的 `tauri build`)靠 `official` 分开(见 [`official_build`])。它们 +/// 不该把数据放到 `target\` 里、把链接和开机自启改指向 `target\…`,也不该去替换自己。 +/// +/// 自己编的那一份装上之后(旁边有了卸载程序)照样是安装版。 +// 判断本身和平台无关,所以在哪都测;只有 Windows 上真的拿它来用 +#[cfg_attr(not(windows), allow(dead_code))] +pub fn windows_kind(exe: &Path, release: bool, official: bool) -> Install { + match nsis_installed(exe) { + Install::Standalone => Install::Standalone, + _ if release && official => Install::Portable, + _ => Install::Dev, + } +} + +/// 这个二进制是不是发版流水线打出来的:`release.yml` 在 Windows 的构建那一步设 +/// `TW_OFFICIAL_BUILD=1`(`build.rs` 让它一变就重编)。 +/// +/// 只有 Windows 上用得到:绿色版和自己编的 release 构建长得一模一样(见 [`windows_kind`])。 +/// 想在本机试绿色版,构建时自己设上这个变量。 +#[cfg_attr(not(windows), allow(dead_code))] +pub fn official_build() -> bool { + option_env!("TW_OFFICIAL_BUILD") == Some("1") +} + /// Linux 上这一份是怎么装上来的。 /// /// **AppImage 还要看 `APPIMAGE` 在不在。**同一个打过补丁的二进制,用户用 @@ -207,7 +240,7 @@ pub fn kind() -> Install { }; #[cfg(windows)] { - nsis_installed(&exe) + windows_kind(&exe, !cfg!(debug_assertions), official_build()) } #[cfg(target_os = "macos")] { @@ -217,8 +250,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 +287,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` 之类不带具体版本的 @@ -281,18 +332,18 @@ pub fn newer(candidate: &str, current: &str) -> bool { mod tests { use super::*; + /// 一个空目录,测试结束(过了、没过)就删掉。拿着返回的第一项到测试结束。 + /// /// 只有上面那两个 macOS 专有的测试用它 —— 跟着它们一起分平台, /// 否则在别处是一段没人调的死代码。 #[cfg(target_os = "macos")] - fn tmp() -> PathBuf { - let p = std::env::temp_dir().join(format!( - "tw-update-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&p); - std::fs::create_dir_all(&p).unwrap(); - p + fn tmp() -> (tempfile::TempDir, PathBuf) { + let d = tempfile::Builder::new() + .prefix("tw-update-") + .tempdir() + .unwrap(); + let p = d.path().to_path_buf(); + (d, p) } #[cfg(target_os = "macos")] @@ -323,7 +374,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn a_caskroom_link_pointing_here_means_homebrew_put_it_here() { - let root = tmp(); + let (_tmp, root) = tmp(); let apps = root.join("Applications"); let bundle = apps.join("ThinkWatch Lite.app"); std::fs::create_dir_all(bundle.join("Contents/MacOS")).unwrap(); @@ -347,15 +398,13 @@ mod tests { Install::Homebrew ); assert!(!kind_at(&exe, std::slice::from_ref(&prefix)).can_self_update()); - - std::fs::remove_dir_all(&root).unwrap(); } /// 别人的 cask 里有个同名链接,指向的却是另一个包 —— 不算。 #[cfg(target_os = "macos")] #[test] fn a_link_to_a_different_bundle_does_not_count() { - let root = tmp(); + let (_tmp, root) = tmp(); let mine = root.join("Applications/ThinkWatch Lite.app"); std::fs::create_dir_all(mine.join("Contents/MacOS")).unwrap(); let exe = mine.join("Contents/MacOS/thinkwatch-lite"); @@ -373,18 +422,16 @@ mod tests { kind_at(&exe, std::slice::from_ref(&prefix)), Install::Standalone ); - std::fs::remove_dir_all(&root).unwrap(); } /// 装好的那一份旁边有卸载程序,开发构建旁边没有。 #[test] fn an_installed_copy_sits_next_to_its_uninstaller() { - let root = std::env::temp_dir().join(format!( - "tw-nsis-{}-{:?}", - std::process::id(), - std::thread::current().id() - )); - let _ = std::fs::remove_dir_all(&root); + let tmp = tempfile::Builder::new() + .prefix("tw-nsis-") + .tempdir() + .unwrap(); + let root = tmp.path(); let dir = root.join("ThinkWatch Lite"); std::fs::create_dir_all(&dir).unwrap(); let exe = dir.join("thinkwatch-lite.exe"); @@ -398,8 +445,41 @@ mod tests { let other = root.join("dev"); std::fs::create_dir_all(other.join("uninstall.exe")).unwrap(); assert_eq!(nsis_installed(&other.join("x.exe")), Install::Dev); + } + + /// 旁边没有卸载程序的发版构建是绿色版;自己编的 release 构建、开发构建不管放在哪 + /// 都是开发构建;有卸载程序的永远是安装版。 + #[test] + fn only_an_official_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, true), Install::Portable); + assert!(windows_kind(&exe, true, true).can_self_update()); + // `cargo build --release` 出来的:不写 `target\release\data\`、不碰注册表、不替换自己 + assert_eq!(windows_kind(&exe, true, false), Install::Dev); + assert!(!windows_kind(&exe, true, false).can_self_update()); + assert_eq!(windows_kind(&exe, false, false), Install::Dev); + assert_eq!(windows_kind(&exe, false, true), Install::Dev); + + std::fs::write(dir.path().join("uninstall.exe"), b"").unwrap(); + for (release, official) in [(true, true), (true, false), (false, true), (false, false)] { + assert_eq!( + windows_kind(&exe, release, official), + Install::Standalone, + "release={release} official={official}" + ); + } + } - std::fs::remove_dir_all(&root).unwrap(); + /// 界面认的是这个词(`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 上信打包时写进去的标记,不信路径和环境变量。 @@ -448,6 +528,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 +551,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..45cc336c 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(()) } @@ -325,24 +344,32 @@ pub(crate) enum Step { Restarting, } +/// 等请求结束时问一次网关的状态最多等多久。**答不上来就当问不到**:卡住的 core 不该 +/// 让等它的那一方(装更新、让位给另一个位置的程序)也跟着卡住 +const STATUS_WAIT: std::time::Duration = std::time::Duration::from_secs(5); + /// 等网关手上的请求都结束。 /// /// **重启会掐断所有还没结束的流**:一个正在吐字的 Claude Code 任务,那段 /// 输出就没了,那个请求得从头再发一次。等计数归零,重启就落在两个请求 /// 之间的空档里。 /// -/// 问不到(core 不在跑、控制面没答应)就不等 —— 没有网关,也就没有要保护 -/// 的请求。 +/// 问不到(core 不在跑、控制面没答应、[`STATUS_WAIT`] 内没回话)就不等 —— 没有 +/// 网关,也就没有要保护的请求。 pub(crate) async fn wait_for_quiet(control: &ControlClient, on_wait: impl Fn(usize)) { let started = std::time::Instant::now(); let mut waited = false; loop { - let s = match control.status().await { - Ok(s) => s, - Err(e) => { + let s = match tokio::time::timeout(STATUS_WAIT, control.status()).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { tracing::info!("等请求结束:问不到网关的状态,不等({e:#})"); return; } + Err(_) => { + tracing::warn!("等请求结束:网关 {STATUS_WAIT:?} 内没回话,不等"); + return; + } }; if s.in_flight == 0 { if waited { @@ -382,7 +409,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 +443,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 +460,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 +539,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 +605,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/webview2.rs b/src-tauri/src/webview2.rs new file mode 100644 index 00000000..b9c6b0c4 --- /dev/null +++ b/src-tauri/src/webview2.rs @@ -0,0 +1,71 @@ +//! 启动之前确认 WebView2 运行时在。 +//! +//! 没有它就开不了任何窗口。安装程序会替用户装上,绿色版不会;系统里被卸掉的 +//! 情况两边都可能有。缺的时候用系统对话框说明,并给出微软的下载页。 +//! +//! **问的是框架自己用的那一个函数**(`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() { + #[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/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-tauri/src/winreg.rs b/src-tauri/src/winreg.rs new file mode 100644 index 00000000..846550fd --- /dev/null +++ b/src-tauri/src/winreg.rs @@ -0,0 +1,793 @@ +//! 当前用户名下(HKCU)的几项注册:`thinkwatch://` 链接、系统通知的应用登记、开机自启。 +//! +//! **谁运行就听谁的。**安装版和绿色版同一时间只会有一个在运行,所以每次启动都把 +//! 这几项改成指向正在运行的这个 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、或者是开发构建,什么都不做。 +/// +/// **要在挑选系统通知的投递端之前调**:那里问 [`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` 的注册项(链接、开机自启、这一份的通知登记和图标),做了什么、 +/// 哪一项没做成,各一行。**指向别处的不动**:那是另一份 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(()) + } + + /// 系统通知的名字和图标。图标从二进制里写出来。 + /// + /// **图标写不出来,`IconUri` 也照样填这一份该有的路径**:系统找不到文件就用默认的 + /// 图标,名字照常显示;而清理时认「这份登记是不是自己的」靠的正是这个路径(见 + /// `release_notifications`)—— 不填的话,这份登记卸载时就没人认领了 + fn register_notifications(hive: &Hive, dir: &Path, aumid: &str) -> Result<()> { + let icon = dir.join(NOTIFICATION_ICON); + if let Err(e) = write_icon(&icon) { + tracing::warn!(icon = %icon.display(), "通知图标写不出来,通知上用默认的图标:{e}"); + } + let key = hive.create(&aumid_key(aumid))?; + // 两个都写成 REG_EXPAND_SZ,和 Windows App SDK 给未打包应用登记时一样 + key.set_expand_string("DisplayName", PRODUCT_NAME)?; + key.set_expand_string("IconUri", icon.to_string_lossy()) + } + + /// 内容没变就不重写 + 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 matches!( + e.kind(), + std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory + ) => {} + 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"; + // 绿色版:文件名里也有空格(`portable::APP_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 a_missing_icon_still_leaves_a_registration_this_copy_owns() { + let s = Scratch::new("noicon"); + let dir = tempfile::tempdir().unwrap(); + let not_a_dir = dir.path().join("data"); + std::fs::write(¬_a_dir, b"").unwrap(); + assert!(claim(&s.0, EXE, ¬_a_dir, "a.test")); + let key = s.0.open(&aumid_key("a.test")).unwrap().unwrap(); + assert!(same_path( + &key.get_string("IconUri").unwrap(), + ¬_a_dir.join(NOTIFICATION_ICON).to_string_lossy() + )); + let steps = crate::i18n::with_lang(crate::i18n::Lang::Zh, || { + release_all(&s.0, EXE, ¬_a_dir, "a.test") + }); + assert!(steps.iter().all(|s| s.ok), "{:?}", texts(&steps)); + assert!(texts(&steps).contains(&"已移除系统通知的登记".to_string())); + assert!(s.0.open(&aumid_key("a.test")).unwrap().is_none()); + } + + /// 只删指向自己的;指向别处的留着,并且说一声开机启动还在 + #[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") + ); + // 文件名里也有空格的(绿色版) + assert_eq!( + command_exe(r"D:\Tools\ThinkWatch Lite\ThinkWatch Lite.exe --autostart"), + Some(r"D:\Tools\ThinkWatch Lite\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" + )); + // 绿色版的文件名里也有空格(`portable::APP_EXE`):整个路径都在引号里 + let portable = r"D:\Tools\ThinkWatch Lite\ThinkWatch Lite.exe"; + assert_eq!( + open_command(portable), + r#""D:\Tools\ThinkWatch Lite\ThinkWatch Lite.exe" "%1""# + ); + assert_eq!( + autostart_command(portable), + r#""D:\Tools\ThinkWatch Lite\ThinkWatch Lite.exe" --autostart"# + ); + assert!(command_runs(&open_command(portable), portable)); + assert!(command_runs(&autostart_command(portable), portable)); + assert!(!command_runs(&autostart_command(portable), 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()); + } +} 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/tests/control_plane.rs b/src-tauri/tests/control_plane.rs index 7a1c0d8c..e08a626b 100644 --- a/src-tauri/tests/control_plane.rs +++ b/src-tauri/tests/control_plane.rs @@ -39,27 +39,25 @@ fn core_binary() -> PathBuf { /// `~/.thinkwatch`,也不碰这台机器上正开着的那个实例。** struct Core { child: std::sync::Mutex, - home: PathBuf, + /// **每个 core 都要自己的目录。**同一个进程里的测试是并行跑的,而两个 core 共用 + /// 一个 `THINKWATCH_HOME` 时,先到的那个拿走单实例锁、后到的根本起不来 —— 表现为 + /// 「core 三十秒都没答应」,一个看上去完全不像是测试自己造成的失败。 + /// + /// 字段在 `drop` 之后才析构:core 先停下、等它退出,目录再删 + home: tempfile::TempDir, } -/// 同一个测试二进制里的第几个 core。 -/// -/// **每个都要自己的目录。**同一个进程里的测试是并行跑的,而两个 core 共用一个 -/// `THINKWATCH_HOME` 时,先到的那个拿走单实例锁、后到的根本起不来 —— 表现为 -/// 「core 三十秒都没答应」,一个看上去完全不像是测试自己造成的失败。 -static NTH: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0); - impl Core { fn start() -> Self { - let nth = NTH.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - // 短路径:unix socket 的 `sun_path` 只有一百来字节,而 macOS 的 - // `TMPDIR` 本身就很长。 - let home = std::env::temp_dir().join(format!("tw-ctl-{}-{nth}", std::process::id())); - let _ = std::fs::remove_dir_all(&home); - std::fs::create_dir_all(&home).unwrap(); + // 短名字:unix socket 的 `sun_path` 只有一百来字节,而 macOS 的 + // `TMPDIR` 本身就很长。起不来、测试没过,目录都跟着删掉 + let home = tempfile::Builder::new() + .prefix("tw-ctl-") + .tempdir() + .unwrap(); let child = Command::new(core_binary()) .args(["serve", "--config"]) - .arg(home.join("config.yaml")) + .arg(home.path().join("config.yaml")) // 这条测试不打数据面,端口只是不能撞上:同一个测试二进制里的另一个 // core、另一个检出里同时在跑的这条测试、这台机器上别的什么。 // @@ -68,10 +66,10 @@ impl Core { // 跑几份测试时真撞上过,core 报「already in use」退出,而这边只看得到 // 「三十秒都没答应」 .args(["--port", "0"]) - .env("THINKWATCH_HOME", &home) + .env("THINKWATCH_HOME", home.path()) // 输出留在它自己的目录里:起不来时 `wait_ready` 把它和退出状态一起报出来 - .stdout(std::fs::File::create(home.join("core.out")).unwrap()) - .stderr(std::fs::File::create(home.join("core.err")).unwrap()) + .stdout(std::fs::File::create(home.path().join("core.out")).unwrap()) + .stderr(std::fs::File::create(home.path().join("core.err")).unwrap()) .spawn() .expect("起不来 twcore"); Self { @@ -82,12 +80,12 @@ impl Core { /// core 的配置。**钥匙由 core 写进去**,桌面端从这里读 fn config(&self) -> PathBuf { - self.home.join("config.yaml") + self.home.path().join("config.yaml") } /// 一份写着另一把钥匙的配置:拿它去连,就是「钥匙不对」 fn wrong_key(&self) -> PathBuf { - let p = self.home.join("wrong.yaml"); + let p = self.home.path().join("wrong.yaml"); std::fs::write( &p, format!("listen:\n control:\n key: \"{}\"\n", "0f".repeat(32)), @@ -97,7 +95,7 @@ impl Core { } fn address(&self) -> Address { - Address::in_dir(&self.home) + Address::in_dir(self.home.path()) } fn client(&self, key_file: PathBuf) -> ControlClient { @@ -122,7 +120,8 @@ impl Core { // 只报一句「没答应」的话,端口被占、配置被拒、二进制不对都长一个样 if Instant::now() >= deadline { let exited = self.child.lock().unwrap().try_wait(); - let log = |f: &str| std::fs::read_to_string(self.home.join(f)).unwrap_or_default(); + let log = + |f: &str| std::fs::read_to_string(self.home.path().join(f)).unwrap_or_default(); panic!( "core 三十秒都没答应\n最后一次:{last}\n退出状态:{exited:?}\n--- stdout\n{}\n--- stderr\n{}", log("core.out"), @@ -139,7 +138,6 @@ impl Drop for Core { let mut c = self.child.lock().unwrap(); let _ = c.kill(); let _ = c.wait(); - let _ = std::fs::remove_dir_all(&self.home); } } @@ -219,7 +217,7 @@ async fn after_the_key_is_rotated_the_next_connection_gets_in() { let rotated = Command::new(core_binary()) .args(["control-key", "--rotate", "--config"]) .arg(core.config()) - .env("THINKWATCH_HOME", &core.home) + .env("THINKWATCH_HOME", core.home.path()) .output() .expect("twcore control-key 跑不起来"); assert!(rotated.status.success(), "{rotated:?}"); @@ -236,7 +234,7 @@ async fn after_the_key_is_rotated_the_next_connection_gets_in() { tokio::time::sleep(Duration::from_millis(200)).await; } // 旧钥匙进不去了 - let old = core.home.join("old.yaml"); + let old = core.home.path().join("old.yaml"); std::fs::write( &old, format!("listen:\n control:\n key: \"{}\"\n", before.to_hex()), diff --git a/src-tauri/tests/msg_codes.rs b/src-tauri/tests/msg_codes.rs index fd1f50ba..519127ac 100644 --- a/src-tauri/tests/msg_codes.rs +++ b/src-tauri/tests/msg_codes.rs @@ -611,12 +611,14 @@ mod tests { fn h() { msg!("t.only" => "x"); } } "####; - let dir = std::env::temp_dir().join(format!("tw-msg-codes-{}", std::process::id())); - let crate_src = dir.join("crates/x/src"); + let dir = tempfile::Builder::new() + .prefix("tw-msg-codes-") + .tempdir() + .unwrap(); + let crate_src = dir.path().join("crates/x/src"); std::fs::create_dir_all(&crate_src).unwrap(); std::fs::write(crate_src.join("lib.rs"), src).unwrap(); - let got = manifest(&dir); - std::fs::remove_dir_all(&dir).unwrap(); + let got = manifest(dir.path()); let body: Vec<&str> = got.lines().filter(|l| !l.starts_with('#')).collect(); assert_eq!( body, diff --git a/src-tauri/windows/English.nsh b/src-tauri/windows/English.nsh new file mode 100644 index 00000000..00f2d2ac --- /dev/null +++ b/src-tauri/windows/English.nsh @@ -0,0 +1,32 @@ +; 从 Tauri CLI 2.11.4 自带的同名语言文件原样复制(crates/tauri-bundler/src/bundle/windows/nsis/languages), +; 改了 deleteAppData 那一句(卸载程序里那个勾选框删的是什么要说清楚),末尾加了 +; cleanupIncomplete(卸载前的清理有一步没做成时的提示),见 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)" +LangString cleanupIncomplete ${LANG_ENGLISH} "Some cleanup steps did not complete. Reinstall ThinkWatch Lite and use Uninstall in the app to see and finish the remaining steps." diff --git a/src-tauri/windows/SimpChinese.nsh b/src-tauri/windows/SimpChinese.nsh new file mode 100644 index 00000000..d4452445 --- /dev/null +++ b/src-tauri/windows/SimpChinese.nsh @@ -0,0 +1,32 @@ +; 从 Tauri CLI 2.11.4 自带的同名语言文件原样复制(crates/tauri-bundler/src/bundle/windows/nsis/languages), +; 改了 deleteAppData 那一句(卸载程序里那个勾选框删的是什么要说清楚),末尾加了 +; cleanupIncomplete(卸载前的清理有一步没做成时的提示),见 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 密钥、请求记录)" +LangString cleanupIncomplete ${LANG_SIMPCHINESE} "部分清理步骤未完成。重新安装 ThinkWatch Lite 后,可在应用内的「卸载」中查看并完成剩余步骤。" diff --git a/src-tauri/windows/hooks.nsh b/src-tauri/windows/hooks.nsh new file mode 100644 index 00000000..008f1af8 --- /dev/null +++ b/src-tauri/windows/hooks.nsh @@ -0,0 +1,66 @@ +; 安装版的卸载程序钩子。Tauri 的 NSIS 模板(以 Tauri CLI 2.11.4 的 installer.nsi 为准) +; 在 `Section Uninstall` 的第一行插入 NSIS_HOOK_PREUNINSTALL,那时还一个文件都没删; +; NSIS_HOOK_POSTUNINSTALL 在这一节的最后,文件、注册项都已经删完。 +; +; 卸载之前先做应用内「卸载」那几步:还原接管过的客户端(含 WSL 里的)、删掉指向这个 +; 程序的注册项,勾了「同时删除数据」再删数据目录。这些事只有应用自己知道怎么做,交给 +; 同一个 exe 的无界面模式 `--uninstall-cleanup`(src-tauri/src/cleanup.rs),等它做完。 +; +; 用到的几样都是模板里的:`$UpdateMode`、`$PassiveMode`(.onInit / un.onInit 里按 /UPDATE、 +; /P 设好)、`$DeleteAppDataCheckboxState`(确认页那个勾选框,离开确认页时读)、 +; `CheckIfAppIsRunning`(utils.nsh)、`MAINBINARYNAME` / `PRODUCTNAME`。宏体在插入的地方才 +; 展开,那时它们都已经声明过了。 + +; 开机自启那一项,指向的不是这个安装目录时,卸载前的原值(见下面两个钩子) +Var TWKeptAutostart +; 清理进程的退出码:0 是每一步都做成了(src-tauri/src/cleanup.rs) +Var TWCleanupExit + +!macro NSIS_HOOK_PREUNINSTALL + StrCpy $TWKeptAutostart "" + ; 自更新也走一遍卸载(/UPDATE):接管、注册项、数据都要留给新装上的那一版 + ${If} $UpdateMode <> 1 + ; 先关掉正在运行的程序:开着的时候它的 core 占着数据目录,它自己也会在启动时把 + ; 注册项改回指向自己。用户在提示框里取消就整个不卸载。模板紧接着会再查一次, + ; 那时已经没有在运行的了(两次插入的标签带着各自的行号,不会重名) + !insertmacro CheckIfAppIsRunning "${MAINBINARYNAME}.exe" "${PRODUCTNAME}" + ClearErrors + ${If} $DeleteAppDataCheckboxState = 1 + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-cleanup --delete-data' $TWCleanupExit + ${Else} + ExecWait '"$INSTDIR\${MAINBINARYNAME}.exe" --uninstall-cleanup' $TWCleanupExit + ${EndIf} + ; 有一步没做成(退出码不是 0),或者程序根本没起来(出错标志;这时退出码没有定义, + ; 先看它):说一声,接着卸载。静默卸载(winget 之类)不弹框,弹了就没人点、一直卡着 + ${If} ${Errors} + StrCpy $TWCleanupExit 1 + ${EndIf} + ${If} $TWCleanupExit <> 0 + ${AndIfNot} ${Silent} + MessageBox MB_ICONEXCLAMATION|MB_OK "$(cleanupIncomplete)" + ${EndIf} + + ; **模板随后不论指向哪里都删开机自启那一项**(HKCU\…\Run 里值名是产品名的那个)。 + ; 全机只有这一项,安装版和绿色版共用:它指向的是另一份(绿色版)时,那是那一份的 + ; 设置,卸载这一份不该动它。上面的清理只删指向这个 exe 的;这里记下指向别处的原值, + ; 删完再写回去。比较不分大小写(`!=` 是 StrCmp),带不带引号都认 + ReadRegStr $R0 HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" + ${If} $R0 != "" + StrCpy $R1 "$INSTDIR\" + StrLen $R2 $R1 + StrCpy $R3 $R0 $R2 + StrCpy $R4 $R0 $R2 1 + ${If} $R3 != $R1 + ${AndIf} $R4 != $R1 + StrCpy $TWKeptAutostart $R0 + ${EndIf} + ${EndIf} + ${EndIf} +!macroend + +!macro NSIS_HOOK_POSTUNINSTALL + ; 写回上面记下的、指向另一份的开机自启(「启动应用」里的开关模板不碰,原样留着) + ${If} $TWKeptAutostart != "" + WriteRegStr HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" $TWKeptAutostart + ${EndIf} +!macroend 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/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) && (