diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 084b8a1..be88ccc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,14 +1,13 @@ -name: Build and Release +name: Build on: push: branches: [main] - tags: ["v*"] pull_request: workflow_dispatch: permissions: - contents: write + contents: read jobs: build: @@ -98,10 +97,3 @@ jobs: with: name: ThermoTray-release-${{ env.AppVersion }} path: artifacts/release - - - name: Create GitHub Release - if: startsWith(github.ref, 'refs/tags/v') - uses: softprops/action-gh-release@v3 - with: - files: artifacts/release/* - generate_release_notes: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index afb9697..5e5a585 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,12 +32,31 @@ jobs: "AppVersion=$version" >> $env:GITHUB_ENV "ReleaseTag=$tag" >> $env:GITHUB_ENV - - run: dotnet restore ThermoTray.sln + # Restore with the publish RID so build, test and publish share one assets graph. + - run: dotnet restore ThermoTray.sln --runtime win-x64 - run: dotnet build ThermoTray.sln --configuration Release --no-restore - - run: dotnet test ThermoTray.sln --configuration Release --no-build + - run: dotnet test ThermoTray.sln --configuration Release --no-build --no-restore - run: dotnet format ThermoTray.sln --verify-no-changes --no-restore --verbosity minimal - - run: dotnet publish src/ThermoTray/ThermoTray.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --output publish/win-x64 + - run: dotnet publish src/ThermoTray/ThermoTray.csproj --configuration Release --runtime win-x64 --self-contained true -p:PublishSingleFile=true --no-restore --output publish/win-x64 + + - name: Verify embedded administrator manifest + shell: pwsh + run: | + $mt = Get-ChildItem -Path "${env:ProgramFiles(x86)}\Windows Kits\10\bin" -Filter mt.exe -Recurse -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 + if (-not $mt) { throw 'Windows SDK Manifest Tool (mt.exe) was not found' } + + New-Item -ItemType Directory -Force -Path artifacts/verification | Out-Null + & $mt.FullName '-inputresource:publish/win-x64/ThermoTray.exe;#1' '-out:artifacts/verification/manifest.xml' + if ($LASTEXITCODE -ne 0) { throw "mt.exe failed with exit code $LASTEXITCODE" } + + $manifest = Get-Content artifacts/verification/manifest.xml -Raw + if ($manifest -notmatch 'requestedExecutionLevel\s+level="requireAdministrator"') { + throw 'The published executable does not require administrator elevation' + } + - run: choco install innosetup --no-progress --yes - run: '& "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" /DAppVersion=$env:AppVersion installer\ThermoTray.iss' shell: pwsh @@ -46,9 +65,25 @@ jobs: - name: Build the portable archive shell: pwsh run: | - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - $files = Get-ChildItem publish/win-x64 -Exclude *.pdb - Compress-Archive -Path $files -DestinationPath "artifacts/ThermoTray-$env:AppVersion-win-x64-portable.zip" -Force + $releaseDir = Join-Path $PWD 'artifacts/release' + New-Item -ItemType Directory -Force -Path $releaseDir | Out-Null + $files = Get-ChildItem publish/win-x64 -File | Where-Object Extension -ne '.pdb' + $zipPath = Join-Path $releaseDir "ThermoTray-$env:AppVersion-win-x64-portable.zip" + Compress-Archive -Path $files -DestinationPath $zipPath -CompressionLevel Optimal -Force + + $installerPath = "artifacts/installer/ThermoTray-Setup-$env:AppVersion.exe" + if (-not (Test-Path -LiteralPath $installerPath)) { + throw "Installer was not created: $installerPath" + } + Copy-Item -LiteralPath $installerPath -Destination $releaseDir + + $checksums = Get-ChildItem $releaseDir -File | + Sort-Object Name | + ForEach-Object { + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash + "$hash $($_.Name)" + } + $checksums | Set-Content -LiteralPath (Join-Path $releaseDir 'SHA256SUMS.txt') -Encoding utf8 # Creates the tag at this commit when it does not exist yet, which is what makes a manual run a # complete release path rather than something that has to be tagged first. @@ -58,8 +93,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | gh release create $env:ReleaseTag ` - "artifacts/installer/ThermoTray-Setup-$env:AppVersion.exe" ` - "artifacts/ThermoTray-$env:AppVersion-win-x64-portable.zip" ` + "artifacts/release/ThermoTray-Setup-$env:AppVersion.exe" ` + "artifacts/release/ThermoTray-$env:AppVersion-win-x64-portable.zip" ` + "artifacts/release/SHA256SUMS.txt" ` --target $env:GITHUB_SHA ` --title "ThermoTray $env:AppVersion" ` --generate-notes diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 2cc2730..5790105 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -216,3 +216,55 @@ Findings and what each one changed. None of them reproduced on this machine's ow - User requested publication to GitHub and release of the executable. The repository is `https://github.com/nojackno2-ctrl/ThermoTray.git`, currently on `main` with the full intended ThermoTray change set uncommitted. - `gh` version `2.95.0` is installed, but `gh auth status` reports `The token in default is invalid` for `nojackno2-ctrl`. No commit, push, pull request, or GitHub Release was performed. - Resume with `gh auth login -h github.com`, then rerun `gh auth status` before staging. The intended release assets are the latest portable ZIP in `artifacts\\release` and the self-contained executable from `publish\\win-x64`; Inno Setup is unavailable locally, so no fresh installer exists. + +## 2026-08-05 full code review and safe cleanup + +- The working tree was clean on `main` at `b8625f7`; the current baseline built with 0 warnings/errors, passed 137 tests, passed `dotnet format --verify-no-changes`, `git diff --check`, and an additional .NET analyzer build with 0 warnings. +- Fixed hardware-topology lifecycle issues: removed old sensor event handlers before rebuilding the cached topology, cleared reference-keyed source-name entries when sensors are re-enumerated, retry-marked failed topology scans, and detached computer handlers when `Computer.Open()` fails so retries cannot accumulate callbacks. +- Fixed a possible UI crash when registry I/O fails while changing the startup setting by treating `IOException` like the other expected startup failures. +- Fixed the native HICON cleanup path so an exception during icon conversion cannot leak the temporary icon handle. +- Fixed a startup-setting race: background reconciliation now serializes Task Scheduler operations and ignores stale results when the user changes the checkbox during the probe or registration. +- Fixed CI/release overlap: the regular build workflow is now read-only CI and no longer triggers or creates releases on tags; the dedicated release workflow now restores with `win-x64`, publishes with the shared assets graph, and includes a `SHA256SUMS.txt` release asset as documented. +- Final validation: Release build passed with 0 warnings/errors; all 137 tests passed; `dotnet format --verify-no-changes` and `git diff --check` passed; explicit `win-x64` self-contained single-file publish passed. The review EXE in `publish\win-x64-review` contains 9 files totalling 187,372,368 bytes, has SHA-256 `0067F2779C16CA35265E8FEEC7BBA2B7CF7C9A1E3481371B1665EA6FDDD9315D`, and its embedded manifest is `requireAdministrator`. Static workflow checks confirmed only `release.yml` owns tag releases, preserves the embedded-manifest check, and publishes `SHA256SUMS.txt`. README examples now use 1.1.3. No commit or push was performed. + +## 2026-08-05 complete codebase Chinese commenting + +- Added comprehensive Traditional Chinese XML documentation comments (`/// `) and inline logic comments to all C# source files in `src/ThermoTray/`, all unit test files in `tests/ThermoTray.Tests/`, and the Inno Setup script `installer/ThermoTray.iss`. +- Preserved all code behavior, method signatures, and recent uncommitted bug fixes in `HardwareSensorService.cs`, `MainViewModel.cs`, and `TrayIconService.cs`. +- Validation: `dotnet build ThermoTray.sln --configuration Release` passed with 0 warnings and 0 errors; `dotnet test ThermoTray.sln --configuration Release` passed all 137 tests; `dotnet format ThermoTray.sln --verify-no-changes` passed with code 0. + +## 2026-08-05 GitHub release preparation + +- Bumped the product version from 1.1.3 to 1.1.4 because v1.1.3 already exists on GitHub; updated the installer fallback and README release examples accordingly. +- Explicit `win-x64` restore, Release build, 137 tests, format verification, and `git diff --check` passed. +- Self-contained `win-x64` publish passed and `mt.exe` confirmed the embedded `requireAdministrator` manifest. Inno Setup 6.7.3 compiled `artifacts\\installer\\ThermoTray-Setup-1.1.4.exe` successfully. +- The first local ZIP command failed because `Compress-Archive` received `FileInfo` objects instead of full paths; no product failure was indicated. Re-running with explicit full paths succeeded: installer `artifacts\\release\\ThermoTray-Setup-1.1.4.exe` is 53,992,887 bytes with SHA-256 `72DC90703E5F6F637F916A7EB2C1D70CED40EDB40F1E8052F1A0BECC13D472AB`; portable ZIP is 73,804,923 bytes with SHA-256 `FBA7485DB57BF2C4C27A329B4AEF847CDAC5B1253474EF356394CEB5703CBFCE`; the ZIP contains 8 files and no PDB. +- Pushed branch `agent/release-1.1.4` at commit `76665a3` and opened draft PR #3. GitHub Actions release run `30962293962` passed every build, test, format, publish, manifest, installer, archive, and release step. +- Published formal GitHub Release `v1.1.4` at `https://github.com/nojackno2-ctrl/ThermoTray/releases/tag/v1.1.4`. Downloaded remote assets were rehashed successfully: installer `53,994,651` bytes, SHA-256 `D9F0EBCCB2B6137DCCA8C6A46188BCFB38A798A67B5F7011206617E818124808`; portable ZIP `76,249,140` bytes, SHA-256 `EBDDA19A879F9F25455CDF2EE8989B497F83A606D60FC40552EAC33FD1D3268F`. + +## 2026-08-05 dual-GPU utilization investigation + +- The user reported that GPU utilization is ambiguous when the machine has both an NVIDIA dGPU and an AMD integrated GPU. The current `HardwareSensorService` ranks all GPU temperature sensors globally and all GPU load sensors globally, so the selected utilization and temperature can come from different physical GPUs; the screenshot shows 5% utilization while the source label is NVIDIA, matching this defect. +- Planned fix: retain GPU identity through the reading pipeline, pair temperature and load by `IHardware`, and expose one view-model card per readable GPU so each device has its own utilization, temperature, and source label. Live hardware verification remains pending. +- Implemented: `HardwareSensorService` now builds one candidate group per GPU hardware and returns `GpuReading` entries with paired temperature/load values; `MainViewModel` exposes an independent `GpuViewModel` for every entry; `MainWindow` renders a dynamic card for each GPU; `TrayIconService` creates and updates one independent GPU icon per entry with GPU number/device-name tooltips. The former single-GPU properties were removed, and the UI test fixture now covers NVIDIA + AMD cards. +- Validation after the implementation: `dotnet build ThermoTray.sln --configuration Release --no-restore` passed with 0 warnings/errors; `dotnet test ThermoTray.sln --configuration Release --no-build` passed 137/137. Live dual-GPU readings and final visual tray ordering remain pending manual launch on the user's machine. +- Final local validation after the README and ranking cleanup: Release build passed with 0 warnings/errors; all 137 tests passed; `dotnet format ThermoTray.sln --verify-no-changes --no-restore --verbosity minimal` passed; `git diff --check` passed. No commit or push was performed. + +## 2026-08-05 card headings and tray field visibility + +- Implemented the user's request to move device names to the top of each CPU/GPU card. CPU readings now carry the hardware name separately from the sensor source label; the source line below the values contains only the sensor name. +- Added independent persisted notification-area visibility choices for CPU utilization, CPU temperature, and each GPU's utilization and temperature. Existing settings default to showing every field; GPU choices are keyed by the stable GPU hardware ID. +- The cards keep showing all live values regardless of these choices. Tray icons hide unchecked lines, hide the whole icon when both lines are unchecked, and tooltips list only selected values while retaining device identification. +- Added 3 regression tests, bringing the local total to 140. Release `dotnet test --no-restore` passed 140/140; `dotnet format --verify-no-changes --no-restore` and `git diff --check` passed. No commit or push was performed. +- Elevated live UI and notification-area visual verification remains pending; the user must launch the updated publish/installer build to confirm the final arrangement on the actual display. + +## 2026-08-05 GPU device-name duplication fix + +- The user reported that GPU names appeared twice in the card. The top line already showed `DeviceName`, while the bottom `Source` line still contained `DeviceName • GPU Core`. +- `GpuViewModel` now strips the hardware-name prefix from the source line, matching the CPU card behavior; the card keeps the top device name and the `GPU 0/1` identifier. +- Added a regression test for the source text. Release `dotnet test --no-restore` passed 141/141; final visual verification remains pending manual launch. + +## 2026-08-05 local branch integration + +- Fast-forwarded the local `main` branch from `b8625f7` to `0407c60`, integrating the committed `agent/release-1.1.4` release work without creating an unnecessary merge commit. +- The working tree still contains the existing uncommitted GPU/card changes and was not staged, committed, stashed, or discarded. `origin/main` remains at `b8625f7`; no remote push was performed. diff --git a/Directory.Build.props b/Directory.Build.props index a7d5d74..49e9ce9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ - 1.1.3 + 1.1.4 latest enable enable diff --git a/README.md b/README.md index f24830a..7edea11 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # ThermoTray +每張 CPU/GPU 卡片都能分別勾選使用率與溫度是否顯示在系統匣;選項會保存到設定檔,卡片本身仍會保留完整讀值。 + [繁體中文](#繁體中文) | [English](#english) ## 繁體中文 @@ -20,13 +22,13 @@ PawnIO 只把裝置開放給**已提權**的處理程序。ThermoTray 的執行 ### 功能 -- 顯示 CPU 套件 / Tctl-Tdie 與主要 GPU Core 的實際溫度。 +- 顯示 CPU 套件 / Tctl-Tdie,以及每張 GPU 各自的 GPU Core 溫度。 - 感測器、權限或驅動程式不支援時顯示「無法取得」,而不是 `0 °C` 或估算值。 -- 顯示 CPU 與 GPU 使用率;0% 是合法的閒置狀態,讀不到時顯示「無法取得」。 +- 顯示 CPU 與每張 GPU 的使用率;0% 是合法的閒置狀態,讀不到時顯示「無法取得」。雙 GPU 會分別顯示各自的裝置名稱、使用率與溫度,不會交叉配對。 - 視窗開啟時每秒一次背景取樣,縮到系統匣後改為每兩秒一次(此時只有整數位的托盤圖示可見,減半的取樣率可直接減半閒置時的 CPU 用量)。 -- 取樣熱路徑不建立暫存陣列:感測器清單、名稱比對與排序都在硬體出現時算好,之後每次取樣只讀值。托盤圖示只在顯示數字改變時重繪,筆刷全程重複使用。 +- 感測器清單、名稱比對與排序都在硬體出現時算好;每次取樣只讀值並建立目前各 GPU 的小型快照。托盤圖示只在顯示數字改變時重繪,筆刷全程重複使用。 - 關閉 LibreHardwareMonitor 每個感測器預設保留一天的歷史值;ThermoTray 只顯示當下數值,長時間常駐時該歷史會持續佔用記憶體並拖慢每次更新。 -- 系統匣使用兩個獨立圖示,分別顯示 CPU 與 GPU;每個圖示上方顯示使用率、下方顯示溫度,並提供帶標籤的提示文字。 +- 系統匣使用獨立圖示,分別顯示 CPU 與每張 GPU;每個圖示上方顯示使用率、下方顯示溫度,Tooltip 會標示 GPU 序號與裝置名稱。 - 托盤數字是把字型外框本身縮放到剛好填滿該行,因此無論一位或三位數都完整可見且盡可能大;`100 °C` 不會被裁成 `10`。圖示依通知區當下的實際尺寸繪製,並以兩倍解析度算圖後平均縮小,讓筆畫粗細均勻。 - 沒有可讀取 GPU 感測器的機器(例如純內顯且驅動程式不提供數值)會隱藏 GPU 卡片與托盤圖示,不會永久顯示無法解決的警告。 - 只允許單一執行個體;重複啟動會喚醒既有視窗,而不是產生第二組托盤圖示與第二個硬體輪詢。 @@ -57,7 +59,7 @@ dotnet publish .\src\ThermoTray\ThermoTray.csproj -c Release -r win-x64 --self-c ### 建立安裝檔 -安裝 [Inno Setup](https://jrsoftware.org/isinfo.php),以 Inno Setup Compiler 開啟 `installer\ThermoTray.iss` 並編譯(命令列可用 `ISCC.exe /DAppVersion=1.1.1 installer\ThermoTray.iss` 指定版本)。它會使用 `publish\win-x64` 的輸出,安裝檔生成於 `artifacts\installer`。安裝精靈安裝到目前使用者的 LocalAppData,本身不需系統管理員權限;啟動 ThermoTray 時才會顯示 UAC。登入自動啟動請在程式內勾選「隨 Windows 啟動」。安裝精靈使用英文;已安裝的 ThermoTray 本身可切換繁體中文與英文。 +安裝 [Inno Setup](https://jrsoftware.org/isinfo.php),以 Inno Setup Compiler 開啟 `installer\ThermoTray.iss` 並編譯(命令列可用 `ISCC.exe /DAppVersion=1.1.4 installer\ThermoTray.iss` 指定版本)。它會使用 `publish\win-x64` 的輸出,安裝檔生成於 `artifacts\installer`。安裝精靈安裝到目前使用者的 LocalAppData,本身不需系統管理員權限;啟動 ThermoTray 時才會顯示 UAC。登入自動啟動請在程式內勾選「隨 Windows 啟動」。安裝精靈使用英文;已安裝的 ThermoTray 本身可切換繁體中文與英文。 ### 溫度正確性說明 @@ -83,10 +85,10 @@ ThermoTray is a lightweight Windows CPU/GPU temperature monitor. It reads physic **Prerequisites for CPU temperature:** LibreHardwareMonitor 0.9.6 reads CPU registers through the [PawnIO](https://pawnio.eu/) kernel driver instead of WinRing0, and PawnIO grants its device to elevated processes only. ThermoTray therefore requests administrator rights in its application manifest and shows Windows UAC on every normal launch; declining the prompt prevents the application from starting. AMD Ryzen otherwise reports `Core (Tctl/Tdie)` as a constant 0, which ThermoTray rejects. NVIDIA GPU readings go through NVAPI and do not require PawnIO. "Start with Windows" registers an `RL HIGHEST` logon task for prompt-free elevated startup; `HKCU\Run` is no longer used. -It samples in the background every second while its window is open and every two seconds once it is hidden in the tray, where only the whole-degree icons are readable. The sampling hot path allocates nothing: the sensor list, the name matching, and the ranking are all resolved when hardware appears, so a sample only reads values. It also turns off LibreHardwareMonitor's per-sensor value history, which otherwise keeps a day of samples for every sensor in a process that is meant to run indefinitely. It redraws a tray icon only when its displayed digits change and reuses its brushes for the life of the process. It shows CPU/GPU utilization and temperature in the main window and in separate tray icons; each icon places utilization above temperature and provides a labelled tooltip. Tray digits are drawn by scaling the glyph outlines themselves to fill their line, so a one- or three-digit reading is equally complete and as large as the icon allows, and 100 °C can never appear as 10. Each icon is drawn for the notification area's current size at twice the resolution and averaged down, which keeps the strokes even. It hides the GPU card and icon entirely on machines that never report GPU telemetry, and allows only one running instance. A second launch asks the running instance which version it is: an identical version simply raises the existing window, a newer one offers to close the running instance and take over the tray, and an older one says so and raises the newer window instead. Instances older than 1.1.3 cannot hand the tray over, so the launch asks you to exit them from the notification area first, and Setup detects a running ThermoTray through a mutex and asks you to close it — Windows locks a running executable, and an unelevated installer cannot terminate a process running as administrator. It supports English and Traditional Chinese, can hide to the tray, and can start with the current Windows user. +It samples in the background every second while its window is open and every two seconds once it is hidden in the tray, where only the whole-degree icons are readable. The sensor list, name matching, and ranking are resolved when hardware appears, and each sample keeps temperature and utilization paired to the same physical GPU. It also turns off LibreHardwareMonitor's per-sensor value history, which otherwise keeps a day of samples for every sensor in a process that is meant to run indefinitely. It redraws a tray icon only when its displayed digits change and reuses its brushes for the life of the process. It shows CPU utilization and temperature plus one independent card for every GPU in the main window; the tray has one independent icon for CPU and one for each GPU, with the GPU number and device name in its tooltip. Each icon places utilization above temperature. Tray digits are drawn by scaling the glyph outlines themselves to fill their line, so a one- or three-digit reading is equally complete and as large as the icon allows, and 100 °C can never appear as 10. Each icon is drawn for the notification area's current size at twice the resolution and averaged down, which keeps the strokes even. It hides GPU cards and icons entirely on machines that never report GPU telemetry, and allows only one running instance. A second launch asks the running instance which version it is: an identical version simply raises the existing window, a newer one offers to close the running instance and take over the tray, and an older one says so and raises the newer window instead. Instances older than 1.1.3 cannot hand the tray over, so the launch asks you to exit them from the notification area first, and Setup detects a running ThermoTray through a mutex and asks you to close it — Windows locks a running executable, and an unelevated installer cannot terminate a process running as administrator. It supports English and Traditional Chinese, can hide to the tray, and can start with the current Windows user. Build it with Visual Studio 2022 / .NET 8 using `ThermoTray.sln` and run `dotnet test .\ThermoTray.sln -c Release` for the unit tests covering sensor ranking, temperature validation, tray formatting, and localization. `` in `Directory.Build.props` is the single source of the product version; the installer script and CI both read it from there. Packaging instructions are above. -After installation, accept UAC and wait one or two seconds for the first sample. CPU/GPU utilization and temperature are shown together; 0% is a valid idle reading, while missing data is shown as `Unavailable` and never as `0 °C` for temperature. Each tray icon places utilization above temperature. The CPU tray icon should be left of the GPU icon unless Windows has preserved a manually rearranged notification-area order. The startup option creates a `ThermoTray` logon task with `RL HIGHEST` so it can start elevated without another UAC prompt. +After installation, accept UAC and wait one or two seconds for the first sample. CPU utilization and temperature plus one card per GPU are shown together; 0% is a valid idle reading, while missing data is shown as `Unavailable` and never as `0 °C` for temperature. Each card independently controls whether its utilization and temperature appear in the tray; the card values remain visible, while the icon and tooltip follow those choices. Each tray icon places utilization above temperature, and each GPU tooltip identifies its GPU number and device name. The CPU tray icon is registered to the left of the GPU icons unless Windows has preserved a manually rearranged notification-area order. The startup option creates a `ThermoTray` logon task with `RL HIGHEST` so it can start elevated without another UAC prompt. -For a tagged GitHub release, push a tag matching the version in `Directory.Build.props`, such as `v1.1.1`. The workflow verifies the tag, publishes the self-contained `win-x64` build, creates the Inno Setup installer, creates a PDB-free portable ZIP, verifies the embedded `requireAdministrator` manifest, and publishes SHA-256 checksums with the release assets. +For a tagged GitHub release, push a tag matching the version in `Directory.Build.props`, such as `v1.1.4`. The release workflow verifies the tag, publishes the self-contained `win-x64` build, creates the Inno Setup installer, creates a PDB-free portable ZIP, verifies the embedded `requireAdministrator` manifest, and publishes SHA-256 checksums with the release assets. diff --git a/installer/ThermoTray.iss b/installer/ThermoTray.iss index 6a41108..8d7f27f 100644 --- a/installer/ThermoTray.iss +++ b/installer/ThermoTray.iss @@ -1,10 +1,14 @@ -; Build the self-contained publish output first: -; dotnet publish .\src\ThermoTray\ThermoTray.csproj -c Release -r win-x64 --self-contained true -o .\publish\win-x64 +; Inno Setup 安裝檔建置腳本 +; 建置步驟: +; 1. 先執行自包含發行發布 (Self-contained Publish): +; dotnet publish .\src\ThermoTray\ThermoTray.csproj -c Release -r win-x64 --self-contained true -o .\publish\win-x64 +; 2. 呼叫 ISCC 進行編譯: +; ISCC.exe /DAppVersion=1.1.4 .\installer\ThermoTray.iss #define AppName "ThermoTray" -; Overridden by CI with /DAppVersion= read from Directory.Build.props. +; 可由 CI 或命令列參數 /DAppVersion= 覆蓋預設版本號 #ifndef AppVersion - #define AppVersion "1.1.3" + #define AppVersion "1.1.4" #endif #define AppPublisher "ThermoTray" #define AppExeName "ThermoTray.exe" @@ -14,6 +18,7 @@ AppId={{1B57D245-2F65-4E07-B338-CC94F7C7E6CB} AppName={#AppName} AppVersion={#AppVersion} AppPublisher={#AppPublisher} +; 安裝至使用者 AppData\Local 目錄,不需系統管理員安裝權限 DefaultDirName={localappdata}\Programs\{#AppName} DefaultGroupName={#AppName} DisableProgramGroupPage=yes @@ -25,17 +30,16 @@ ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible PrivilegesRequired=lowest UninstallDisplayIcon={app}\{#AppExeName} -; Windows locks a running executable's image, so installing over a running ThermoTray fails. This -; mutex is created by the application purely so Setup can see it and ask the user to close it first. +; 檢查應用程式產生的全域互斥鎖,避免在 ThermoTray 執行中進行覆蓋安裝 AppMutex=Global\ThermoTray.Setup -; Restart Manager cannot help here: it would have to terminate a requireAdministrator process from -; an installer that deliberately runs at the lowest privilege level. AppMutex is the real gate. +; 關閉預設的 Restart Manager,改由 AppMutex 提示使用者結束程式 CloseApplications=no [Languages] Name: "english"; MessagesFile: "compiler:Default.isl" [Files] +; 包含發布目錄下的所有檔案(排除 .pdb 偵錯檔) Source: "..\publish\win-x64\*"; DestDir: "{app}"; Excludes: "*.pdb"; Flags: ignoreversion recursesubdirs createallsubdirs [Tasks] @@ -46,9 +50,9 @@ Name: "{autoprograms}\{#AppName}"; Filename: "{app}\{#AppExeName}" Name: "{autodesktop}\{#AppName}"; Filename: "{app}\{#AppExeName}"; Tasks: desktopicon [Registry] +; 舊版本 Run 登錄項清理標記 Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: none; ValueName: "ThermoTray"; Flags: deletevalue -; shellexec is required: this installer runs unelevated and ThermoTray's manifest demands -; administrator rights, so a plain CreateProcess launch would fail with ERROR_ELEVATION_REQUIRED. +; 安裝完成後啟動選項。使用 shellexec 觸發 UAC 管理員提權提示 [Run] Filename: "{app}\{#AppExeName}"; Description: "Launch ThermoTray"; Flags: nowait postinstall skipifsilent shellexec diff --git a/src/ThermoTray/App.xaml.cs b/src/ThermoTray/App.xaml.cs index 70e7469..f369d07 100644 --- a/src/ThermoTray/App.xaml.cs +++ b/src/ThermoTray/App.xaml.cs @@ -6,6 +6,9 @@ namespace ThermoTray; +/// +/// ThermoTray 的應用程式進入點與生命週期管理類別 (Inherits )。 +/// public partial class App : System.Windows.Application { private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(2); @@ -17,15 +20,25 @@ public partial class App : System.Windows.Application private MainViewModel? _viewModel; private bool _isShuttingDown; + /// + /// 取得當前組件的版本號資訊。 + /// private static Version? ProductVersion => typeof(App).Assembly.GetName().Version; - /// Loaded on demand: the common startup path never shows one of these messages. + /// + /// 依需求延遲載入的本地化訊息實例。一般啟動路徑不需載入此對話方塊字串。 + /// private Localizer Messages => _messages ??= new Localizer(new SettingsService().Load().Language); + /// + /// 處理應用程式啟動邏輯,包含命令列參數判斷、單一執行體協調、MVVM 與系統匣服務初始化。 + /// + /// 啟動事件引數。 protected override void OnStartup(StartupEventArgs e) { base.OnStartup(e); + // 檢查是否帶有 --diagnostics 診斷參數 if (e.Args.Contains("--diagnostics", StringComparer.OrdinalIgnoreCase)) { WriteSensorDiagnostics(); @@ -33,16 +46,17 @@ protected override void OnStartup(StartupEventArgs e) return; } - // Starting minimized never shows the window, which avoids a visible flash at logon. + // 開機最小化啟動不顯示主視窗,避免登入時視窗閃爍 var startedMinimized = e.Args.Contains("--minimized", StringComparer.OrdinalIgnoreCase); + // 嘗試取得單一執行體控制權,若已有舊實體執行且無法接手則結束 if (!TryBecomeTheRunningInstance(startedMinimized)) { Shutdown(); return; } - // The tray owns the lifetime, so hiding or closing the window must not end the process. + // 系統匣圖示擁有生命週期,因此關閉或隱藏主視窗不會自動終止處理程序 ShutdownMode = ShutdownMode.OnExplicitShutdown; var viewModel = new MainViewModel(new HardwareSensorService(), new SettingsService(), new StartupService()); @@ -52,8 +66,7 @@ protected override void OnStartup(StartupEventArgs e) var window = new MainWindow { DataContext = viewModel }; window.Closed += (_, _) => ExitApplication(); - // Hiding to the tray is the app's normal state, and it is the sampling rate's only input, so - // the window reports every visibility change rather than only the ones it initiates. + // 隱藏至系統匣為常態,主視窗回報顯示狀態以調整輪詢頻率 window.IsVisibleChanged += (_, _) => viewModel.SetWindowVisible(window.IsVisible); MainWindow = window; viewModel.Start(); @@ -65,24 +78,26 @@ protected override void OnStartup(StartupEventArgs e) } /// - /// A second set of tray icons polling the same hardware helps nobody, so exactly one instance - /// runs. Reports whether this process is the one that continues. + /// 嘗試取得系統匣單一執行體的持有權。防止重複啟動導致兩個圖示同時輪詢硬體。 /// + /// 是否以最小化模式啟動。 + /// 若本實體為繼續執行的實體傳回 true,否則傳回 false。 private bool TryBecomeTheRunningInstance(bool startedMinimized) { _coordinator = InstanceCoordinator.TryClaim(ProductVersion, PostShowMainWindow, PostExitApplication); return _coordinator is not null || TryTakeOverFromRunningInstance(startedMinimized); } + /// + /// 當已存在執行中的 ThermoTray 時,嘗試透過管道與其通訊並協調接手或顯示視窗。 + /// private bool TryTakeOverFromRunningInstance(bool startedMinimized) { using var client = InstanceClient.TryConnect(InstanceCoordinator.PipeName, ConnectTimeout); if (client is null) { - // An instance that cannot be reached over the pipe predates it. Raising its window is the - // only hand-over such a build understands, so an upgrade cannot take the tray from it and - // the user has to be told why their new build appeared to do nothing. + // 無法透過具名管道連接,代表對方為舊版本。觸發舊版互斥鎖/事件讓其顯示視窗 var raised = InstanceCoordinator.TrySignalLegacyInstance(); if (!startedMinimized) @@ -95,8 +110,7 @@ private bool TryTakeOverFromRunningInstance(bool startedMinimized) return false; } - // A logon launch must never stop at a modal prompt nobody is there to answer, whichever - // version turns out to be running, so it always degrades to a silent hand-over. + // 若為開機隨 Windows 啟動(--minimized),絕不跳出提示訊息阻礙使用者,直接進行無聲 Handover var action = startedMinimized ? InstanceAction.ShowRunning : InstanceProtocol.Decide(client.RunningVersion, ProductVersion); @@ -115,14 +129,18 @@ private bool TryTakeOverFromRunningInstance(bool startedMinimized) return false; } + /// + /// 將前景切換權限授予執行中的舊實體,並要求其顯示主視窗。 + /// private static void HandOver(InstanceClient client) { - // This process still holds the foreground right the user's launch gave it; the running - // instance needs it to raise its own window. NativeMethods.AllowSetForegroundWindow(client.RunningProcessId); client.RequestShow(); } + /// + /// 請求執行中的舊實體結束,並在舊實體退出後由當前實體接手系統匣。 + /// private bool TryReplace(InstanceClient client) { var runningVersion = client.RunningVersion; @@ -143,6 +161,9 @@ private bool TryReplace(InstanceClient client) return false; } + /// + /// 彈出對話方塊詢問使用者是否關閉舊版本改用新版本。 + /// private bool ConfirmReplacement(Version? runningVersion) => System.Windows.MessageBox.Show( Format("ReplaceRunningInstance", runningVersion), @@ -150,19 +171,34 @@ private bool ConfirmReplacement(Version? runningVersion) => MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes; + /// + /// 格式化本地化訊息中的版本資訊。 + /// private string Format(string key, Version? runningVersion) => string.Format( CultureInfo.CurrentCulture, Messages[key], MainViewModel.FormatVersion(runningVersion), MainViewModel.FormatVersion(ProductVersion)); + /// + /// 顯示系統訊息對話方塊。 + /// private static void ShowMessage(string message, MessageBoxImage icon) => System.Windows.MessageBox.Show(message, "ThermoTray", MessageBoxButton.OK, icon); + /// + /// 在 UI 執行緒上非同步分送顯示主視窗請求。 + /// private void PostShowMainWindow() => Dispatcher.InvokeAsync(ShowMainWindow); + /// + /// 在 UI 執行緒上非同步分送結束應用程式請求。 + /// private void PostExitApplication() => Dispatcher.InvokeAsync(ExitApplication); + /// + /// 顯示並啟動主視窗,將其帶入系統最前景。 + /// private void ShowMainWindow() { if (MainWindow is null) @@ -174,8 +210,6 @@ private void ShowMainWindow() MainWindow.WindowState = WindowState.Normal; MainWindow.Activate(); - // Activate() alone is a request the window manager may answer with a flashing taskbar button; - // the launching instance handed this process the right to take the foreground outright. var handle = new WindowInteropHelper(MainWindow).Handle; if (handle != IntPtr.Zero) { @@ -183,6 +217,9 @@ private void ShowMainWindow() } } + /// + /// 結束應用程式並釋放所有服務資源。 + /// private void ExitApplication() { if (_isShuttingDown) @@ -195,6 +232,9 @@ private void ExitApplication() Shutdown(); } + /// + /// 執行感測器診斷,將原始 LibreHardwareMonitor 感測器數據寫入檔案以利排除故障。 + /// private static void WriteSensorDiagnostics() { var outputPath = Path.Combine( @@ -227,6 +267,9 @@ private static void WriteSensorDiagnostics() } } + /// + /// 寫入診斷失敗紀錄。 + /// private static void TryReportDiagnosticsFailure(string outputPath, Exception exception) { try @@ -235,10 +278,13 @@ private static void TryReportDiagnosticsFailure(string outputPath, Exception exc } catch (Exception) { - // A windowed application has no console, so an unwritable output path leaves nowhere to report. + // 無主視窗應用程式無 Console,寫入失敗時忽略 } } + /// + /// 處理 WPF OnExit 事件,清理內部資源。 + /// protected override void OnExit(ExitEventArgs e) { _isShuttingDown = true; @@ -246,6 +292,9 @@ protected override void OnExit(ExitEventArgs e) base.OnExit(e); } + /// + /// 停止感測器輪詢並釋放系統匣圖示與互斥鎖。 + /// private void ReleaseServices() { _viewModel?.Stop(); diff --git a/src/ThermoTray/ElevationService.cs b/src/ThermoTray/ElevationService.cs index 6f18da5..83f3ea6 100644 --- a/src/ThermoTray/ElevationService.cs +++ b/src/ThermoTray/ElevationService.cs @@ -3,12 +3,20 @@ namespace ThermoTray; /// -/// Reports whether the current process received the administrator rights required by PawnIO. +/// 提供檢查當前處理程序是否具備管理員權限(Elevated)的服務。 +/// PawnIO 驅動程式需要管理員權限才能允許讀取 CPU 暫存器溫度。 /// public static class ElevationService { + /// + /// 取得一個值,表示當前處理程序是否以最高權限(系統管理員)執行。 + /// public static bool IsElevated { get; } = DetectElevation(); + /// + /// 檢測當前 Windows 帳戶識別碼是否屬於系統管理員角色。 + /// + /// 若為系統管理員傳回 true,否則傳回 false。 private static bool DetectElevation() { try diff --git a/src/ThermoTray/GpuViewModel.cs b/src/ThermoTray/GpuViewModel.cs new file mode 100644 index 0000000..ee82784 --- /dev/null +++ b/src/ThermoTray/GpuViewModel.cs @@ -0,0 +1,220 @@ +using System.ComponentModel; +using System.Runtime.CompilerServices; + +namespace ThermoTray; + +/// +/// 主視窗與系統匣使用的單一 GPU 顯示狀態。 +/// 每個實例只代表一張實體 GPU,避免雙 GPU 時把不同裝置的讀值混在一起。 +/// +public sealed class GpuViewModel : INotifyPropertyChanged +{ + private string _displayName = string.Empty; + private string _deviceName = string.Empty; + private string _usageLabel = string.Empty; + private string _usage = UtilizationFormatter.TrayPlaceholder; + private string _temperatureLabel = string.Empty; + private string _temperature = TemperatureFormatter.TrayPlaceholder; + private string _source = string.Empty; + private string _usageTrayDigits = UtilizationFormatter.TrayPlaceholder; + private string _temperatureTrayDigits = TemperatureFormatter.TrayPlaceholder; + private TrayDisplaySettings _traySettings = new(); + private Action _saveSettings = static () => { }; + + /// + /// 建立指定識別碼的 GPU 顯示狀態。 + /// + /// 拓撲掃描時產生的 GPU 識別碼。 + internal GpuViewModel(string id) => Id = id; + + /// + /// GPU 的拓撲識別碼。 + /// + public string Id { get; } + + /// + /// 顯示用的 GPU 序號標題,例如「GPU 0」或「GPU 1」。 + /// + public string DisplayName + { + get => _displayName; + private set => SetField(ref _displayName, value); + } + + /// + /// GPU 裝置名稱,例如 NVIDIA 或 AMD 的完整型號名稱。 + /// + public string DeviceName + { + get => _deviceName; + private set => SetField(ref _deviceName, value); + } + + /// + /// GPU 使用率欄位標籤。 + /// + public string UsageLabel + { + get => _usageLabel; + private set => SetField(ref _usageLabel, value); + } + + /// + /// GPU 使用率格式化文字。 + /// + public string Usage + { + get => _usage; + private set => SetField(ref _usage, value); + } + + /// + /// GPU 溫度欄位標籤。 + /// + public string TemperatureLabel + { + get => _temperatureLabel; + private set => SetField(ref _temperatureLabel, value); + } + + /// + /// GPU 溫度格式化文字。 + /// + public string Temperature + { + get => _temperature; + private set => SetField(ref _temperature, value); + } + + /// + /// GPU 感測器來源文字,用於卡片底部辨識實際感測器。 + /// + public string Source + { + get => _source; + private set => SetField(ref _source, value); + } + + /// + /// 工具列圖示上方顯示的整數使用率。 + /// + public string UsageTrayDigits + { + get => _usageTrayDigits; + private set => SetField(ref _usageTrayDigits, value); + } + + /// + /// 工具列圖示下方顯示的整數溫度。 + /// + public string TemperatureTrayDigits + { + get => _temperatureTrayDigits; + private set => SetField(ref _temperatureTrayDigits, value); + } + + public bool ShowUsageInTray + { + get => _traySettings.ShowUsage; + set + { + if (_traySettings.ShowUsage == value) + { + return; + } + + _traySettings.ShowUsage = value; + _saveSettings(); + OnPropertyChanged(); + } + } + + public bool ShowTemperatureInTray + { + get => _traySettings.ShowTemperature; + set + { + if (_traySettings.ShowTemperature == value) + { + return; + } + + _traySettings.ShowTemperature = value; + _saveSettings(); + OnPropertyChanged(); + } + } + + /// + /// GPU 讀值或語言設定改變時觸發的屬性通知。 + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// 套用單一 GPU 的讀值與本地化欄位標籤。 + /// + /// 同一張 GPU 配對後的溫度與使用率。 + /// 目前拓撲中的 GPU 序號。 + /// 本地化使用率標籤。 + /// 本地化溫度標籤。 + /// 溫度格式化委派。 + /// 使用率格式化委派。 + internal void Apply( + GpuReading reading, + int index, + string usageLabel, + string temperatureLabel, + Func formatTemperature, + Func formatUsage) + { + DisplayName = $"GPU {index}"; + DeviceName = reading.Name; + UsageLabel = usageLabel; + Usage = formatUsage(reading.Usage); + TemperatureLabel = temperatureLabel; + Temperature = formatTemperature(reading.Temperature); + Source = GetSensorName(reading.Temperature.IsAvailable + ? reading.Temperature.Source + : reading.Usage.Source); + UsageTrayDigits = UtilizationFormatter.ToTrayDigits(reading.Usage); + TemperatureTrayDigits = TemperatureFormatter.ToTrayDigits(reading.Temperature); + } + + private static string GetSensorName(string source) + { + var separator = source.IndexOf('\u2022'); + return separator >= 0 ? source[(separator + 1)..].Trim() : source; + } + + internal void ConfigureTraySettings(TrayDisplaySettings settings, Action saveSettings) + { + _traySettings = settings; + _saveSettings = saveSettings; + OnPropertyChanged(nameof(ShowUsageInTray)); + OnPropertyChanged(nameof(ShowTemperatureInTray)); + } + + /// + /// 只更新切換語言後的欄位標籤,保留最近一次硬體讀值。 + /// + internal void UpdateLabels(string usageLabel, string temperatureLabel) + { + UsageLabel = usageLabel; + TemperatureLabel = temperatureLabel; + } + + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + private bool SetField(ref TValue field, TValue value, [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } +} diff --git a/src/ThermoTray/HardwareSensorService.cs b/src/ThermoTray/HardwareSensorService.cs index dea62bf..58c712d 100644 --- a/src/ThermoTray/HardwareSensorService.cs +++ b/src/ThermoTray/HardwareSensorService.cs @@ -2,7 +2,10 @@ namespace ThermoTray; -/// Reads hardware sensors only. It deliberately has no estimated-temperature path. +/// +/// 負責直接存取硬體感測器並讀取 CPU/GPU 溫度與使用率的服務類別。 +/// 嚴格遵循「不安裝假資料、不安裝估算數值」的原則,若無法讀取真實感測器則明確回報為無法取得。 +/// public sealed class HardwareSensorService : IDisposable { private const float CpuMinimumCelsius = 1; @@ -26,35 +29,42 @@ public sealed class HardwareSensorService : IDisposable private readonly SensorEventHandler _onSensorChanged; private readonly Dictionary _sourceNames = new(ReferenceEqualityComparer.Instance); - // Everything about a sensor that cannot change between samples is resolved once, when the - // hardware appears, so a sample only reads values and compares numbers. + // 在拓撲掃描時快取感測器與優先級,避免每次採樣時重新進行字串匹配 private IHardware[] _hardware = []; private CpuCandidate[] _cpuCandidates = []; - private GpuCandidate[] _gpuCandidates = []; + private GpuDeviceCandidate[] _gpuDevices = []; private CpuCandidate[] _cpuUsageCandidates = []; - private GpuUsageCandidate[] _gpuUsageCandidates = []; - // Written by LibreHardwareMonitor's change events, which need not run on the sampling thread. + // 由 LibreHardwareMonitor 事件觸發的硬體變更標記 private volatile bool _topologyChanged; private bool _opened; + /// + /// 初始化 HardwareSensorService 並訂閱硬體變更委派。 + /// public HardwareSensorService() { _onHardwareChanged = _ => _topologyChanged = true; _onSensorChanged = _ => _topologyChanged = true; } + /// + /// 讀取一次完整的硬體快照(包含 CPU/GPU 溫度與使用率)。 + /// + /// 包含當前讀值的 public HardwareSnapshot Read() { UpdateHardware(); return new HardwareSnapshot( SelectCpuReading(), - SelectGpuReading(), SelectCpuUsage(), - SelectGpuUsage()); + SelectGpuReadings()); } + /// + /// 讀取所有裸感測器數據(供 `--diagnostics` 命令列診斷輸出使用)。 + /// public IReadOnlyList ReadRawSensors() { UpdateHardware(); @@ -68,14 +78,14 @@ public IReadOnlyList ReadRawSensors() return sensors; } + /// + /// 更新硬體感測器數值,若檢測到拓撲結構改變(如硬體插拔)則重新計算感測器優先順序。 + /// private void UpdateHardware() { EnsureOpen(); _computer.Accept(HardwareUpdater); - // The scan runs after the update because a hardware update is what activates sensors, so a - // sensor that appears on this pass is still selectable in this sample. The flag is cleared - // first, so anything that appears during the scan is picked up next sample instead of lost. if (_topologyChanged) { _topologyChanged = false; @@ -83,6 +93,9 @@ private void UpdateHardware() } } + /// + /// 確保 LibreHardwareMonitor Computer 物件已開啟並註冊變更監聽。 + /// private void EnsureOpen() { if (_opened) @@ -92,48 +105,94 @@ private void EnsureOpen() _computer.HardwareAdded += _onHardwareChanged; _computer.HardwareRemoved += _onHardwareChanged; - _computer.Open(); + + try + { + _computer.Open(); + } + catch + { + _computer.HardwareAdded -= _onHardwareChanged; + _computer.HardwareRemoved -= _onHardwareChanged; + throw; + } + _opened = true; _topologyChanged = false; RefreshTopology(); } /// - /// Rebuilds the cached sensor lists. and - /// both copy into a fresh array on every call, and the sensor-name matching below is pure string - /// work, so all of it is done here rather than once per sample. + /// 重新整理並重新掃描硬體樹狀結構,將感測器過濾與排序結果快取至陣列中。 /// private void RefreshTopology() { - var hardware = _computer.Hardware; - var roots = new IHardware[hardware.Count]; - hardware.CopyTo(roots, 0); - _hardware = roots; - - var cpuCandidates = new List(); - var gpuCandidates = new List(); - var cpuUsageCandidates = new List(); - var gpuUsageCandidates = new List(); - foreach (var root in roots) + try { - CollectCandidates(root, cpuCandidates, gpuCandidates, cpuUsageCandidates, gpuUsageCandidates); - } + // 在重新整理拓撲時先解綁舊硬體的事件,防止移除的硬體殘留委派導致記憶體洩漏 + foreach (var root in _hardware) + { + UnsubscribeSensorEvents(root); + } + + // 清除過期的感測器名稱快取 + _sourceNames.Clear(); + + var hardware = _computer.Hardware; + var roots = new IHardware[hardware.Count]; + hardware.CopyTo(roots, 0); + _hardware = roots; + + var cpuCandidates = new List(); + var cpuUsageCandidates = new List(); + var gpuBuilders = new List(); + var gpuBuildersByHardware = new Dictionary(ReferenceEqualityComparer.Instance); + foreach (var root in roots) + { + CollectCandidates( + root, + cpuCandidates, + cpuUsageCandidates, + gpuBuilders, + gpuBuildersByHardware, + gpuHardware: null); + } + + _cpuCandidates = cpuCandidates.ToArray(); + _cpuUsageCandidates = cpuUsageCandidates.ToArray(); + + var gpuDevices = new GpuDeviceCandidate[gpuBuilders.Count]; + for (var index = 0; index < gpuBuilders.Count; index++) + { + var builder = gpuBuilders[index]; + gpuDevices[index] = new GpuDeviceCandidate( + $"{builder.Hardware.HardwareType}:{builder.Hardware.Name}:{index}", + builder.Hardware.Name, + builder.TemperatureCandidates.ToArray(), + builder.UsageCandidates.ToArray()); + } - _cpuCandidates = cpuCandidates.ToArray(); - _gpuCandidates = gpuCandidates.ToArray(); - _cpuUsageCandidates = cpuUsageCandidates.ToArray(); - _gpuUsageCandidates = gpuUsageCandidates.ToArray(); + _gpuDevices = gpuDevices; + } + catch + { + // 發生暫時性拓撲掃描錯誤時標記重新整理,供下次採樣重試 + _topologyChanged = true; + throw; + } } + /// + /// 遞迴收集指定硬體及其子硬體中的 CPU/GPU 溫度與使用率候選感測器。 + /// private void CollectCandidates( IHardware hardware, List cpuCandidates, - List gpuCandidates, List cpuUsageCandidates, - List gpuUsageCandidates) + List gpuBuilders, + Dictionary gpuBuildersByHardware, + IHardware? gpuHardware) { - // Subscribing twice would raise the flag twice; removing an absent handler is a no-op, so this - // stays correct across the repeated scans that a hardware change triggers. hardware.SensorAdded -= _onSensorChanged; hardware.SensorAdded += _onSensorChanged; hardware.SensorRemoved -= _onSensorChanged; @@ -141,9 +200,20 @@ private void CollectCandidates( var isCpu = hardware.HardwareType == HardwareType.Cpu; var isGpu = hardware.HardwareType is HardwareType.GpuNvidia or HardwareType.GpuAmd or HardwareType.GpuIntel; - var hardwarePriority = hardware.HardwareType == HardwareType.GpuIntel + var currentGpuHardware = isGpu ? hardware : gpuHardware; + var hardwarePriority = currentGpuHardware?.HardwareType == HardwareType.GpuIntel ? GpuSensorRank.IntegratedPriority : GpuSensorRank.DiscretePriority; + GpuDeviceBuilder? gpuBuilder = null; + if (currentGpuHardware is not null) + { + if (!gpuBuildersByHardware.TryGetValue(currentGpuHardware, out gpuBuilder)) + { + gpuBuilder = new GpuDeviceBuilder(currentGpuHardware); + gpuBuildersByHardware.Add(currentGpuHardware, gpuBuilder); + gpuBuilders.Add(gpuBuilder); + } + } foreach (var sensor in hardware.Sensors) { @@ -155,15 +225,13 @@ private void CollectCandidates( { cpuCandidates.Add(new CpuCandidate(hardware, sensor, GetPreferredRank(sensor.Name, CpuPreferredNames))); } - else if (isGpu) + else if (gpuBuilder is not null) { - // Discovery order is the last tiebreaker, so the position in this list is the sequence. - var sequence = gpuCandidates.Count; - gpuCandidates.Add(new GpuCandidate( + var sequence = gpuBuilder.TemperatureCandidates.Count; + gpuBuilder.TemperatureCandidates.Add(new GpuCandidate( hardware, sensor, new GpuSensorRank(GetPreferredRank(sensor.Name, GpuPreferredNames), hardwarePriority, sequence), - // The fallback ignores the sensor name so an unrecognised GPU still reports something. new GpuSensorRank(GpuSensorRank.AnyName, hardwarePriority, sequence))); } } @@ -173,10 +241,10 @@ private void CollectCandidates( { cpuUsageCandidates.Add(new CpuCandidate(hardware, sensor, GetPreferredRank(sensor.Name, CpuUsagePreferredNames))); } - else if (isGpu) + else if (gpuBuilder is not null) { - var sequence = gpuUsageCandidates.Count; - gpuUsageCandidates.Add(new GpuUsageCandidate( + var sequence = gpuBuilder.UsageCandidates.Count; + gpuBuilder.UsageCandidates.Add(new GpuUsageCandidate( hardware, sensor, new GpuSensorRank(GetPreferredRank(sensor.Name, GpuUsagePreferredNames), hardwarePriority, sequence))); @@ -186,16 +254,19 @@ private void CollectCandidates( foreach (var subHardware in hardware.SubHardware) { - CollectCandidates(subHardware, cpuCandidates, gpuCandidates, cpuUsageCandidates, gpuUsageCandidates); + CollectCandidates( + subHardware, + cpuCandidates, + cpuUsageCandidates, + gpuBuilders, + gpuBuildersByHardware, + currentGpuHardware); } } /// - /// LibreHardwareMonitor keeps a day of averaged history for every sensor it exposes, including the - /// dozens ThermoTray never displays. ThermoTray only ever shows the current value, so in a process - /// that stays running that history is pure growth: each list fills all day, and once it is full every - /// later update shifts the whole list down to drop the expired entry. Turning the window off keeps - /// both the memory and the per-update cost flat. + /// 停用 LibreHardwareMonitor 感測器的歷史紀錄視窗 (ValuesTimeWindow = TimeSpan.Zero), + /// 避免在背景長期執行時 List 成長與頻繁移位造成的記憶體與 CPU 開銷。 /// private static void DisableValueHistory(ISensor sensor) { @@ -205,6 +276,9 @@ private static void DisableValueHistory(ISensor sensor) } } + /// + /// 從 CPU 候選者中評選最佳溫度讀值。優先使用符合名稱條件者,否則退回使用最高有效溫度。 + /// private TemperatureReading SelectCpuReading() { SensorCandidate preferred = default; @@ -233,69 +307,92 @@ private TemperatureReading SelectCpuReading() return ToReading(preferred.IsValid ? preferred : fallback); } - private TemperatureReading SelectGpuReading() + /// + /// 評選 CPU 總使用率讀值。 + /// + private UtilizationReading SelectCpuUsage() { SensorCandidate preferred = default; - var preferredRank = GpuSensorRank.None; - SensorCandidate fallback = default; - var fallbackRank = GpuSensorRank.None; + var preferredRank = int.MaxValue; - foreach (var candidate in _gpuCandidates) + foreach (var candidate in _cpuUsageCandidates) { - if (!TryGetTemperature(candidate.Sensor, GpuMinimumCelsius, GpuMaximumCelsius, out var celsius)) + if (!TryGetUsage(candidate.Sensor, out var percent)) { continue; } - if (candidate.PreferredRank.IsBetterThan(preferredRank)) + if (candidate.NameRank < preferredRank) { - preferred = new SensorCandidate(candidate.Hardware, candidate.Sensor, celsius); - preferredRank = candidate.PreferredRank; + preferred = new SensorCandidate(candidate.Hardware, candidate.Sensor, percent); + preferredRank = candidate.NameRank; } + } - if (candidate.FallbackRank.IsBetterThan(fallbackRank)) - { - fallback = new SensorCandidate(candidate.Hardware, candidate.Sensor, celsius); - fallbackRank = candidate.FallbackRank; - } + return ToUtilizationReading(preferred); + } + + /// + /// 逐張 GPU 評選最佳溫度與使用率,確保兩種讀值來自同一個實體裝置。 + /// + private IReadOnlyList SelectGpuReadings() + { + var readings = new GpuReading[_gpuDevices.Length]; + for (var index = 0; index < _gpuDevices.Length; index++) + { + var device = _gpuDevices[index]; + readings[index] = new GpuReading( + device.Id, + device.Name, + SelectGpuTemperature(device.TemperatureCandidates), + SelectGpuUsage(device.UsageCandidates)); } - return ToReading(preferred.IsValid ? preferred : fallback); + return readings; } /// - /// Unlike the temperature paths there is no name-agnostic fallback: a load sensor whose name is - /// not recognised may describe a single core or an engine, and showing that as "CPU usage" would - /// be wrong in a way an unavailable value is not. + /// 從單一 GPU 的候選者中評選最佳溫度讀值。 /// - private UtilizationReading SelectCpuUsage() + private TemperatureReading SelectGpuTemperature(IReadOnlyList candidates) { SensorCandidate preferred = default; - var preferredRank = int.MaxValue; + var preferredRank = GpuSensorRank.None; + SensorCandidate fallback = default; + var fallbackRank = GpuSensorRank.None; - foreach (var candidate in _cpuUsageCandidates) + foreach (var candidate in candidates) { - if (!TryGetUsage(candidate.Sensor, out var percent)) + if (!TryGetTemperature(candidate.Sensor, GpuMinimumCelsius, GpuMaximumCelsius, out var celsius)) { continue; } - if (candidate.NameRank < preferredRank) + if (candidate.PreferredRank.IsBetterThan(preferredRank)) { - preferred = new SensorCandidate(candidate.Hardware, candidate.Sensor, percent); - preferredRank = candidate.NameRank; + preferred = new SensorCandidate(candidate.Hardware, candidate.Sensor, celsius); + preferredRank = candidate.PreferredRank; + } + + if (candidate.FallbackRank.IsBetterThan(fallbackRank)) + { + fallback = new SensorCandidate(candidate.Hardware, candidate.Sensor, celsius); + fallbackRank = candidate.FallbackRank; } } - return ToUtilizationReading(preferred); + return ToReading(preferred.IsValid ? preferred : fallback); } - private UtilizationReading SelectGpuUsage() + /// + /// 從單一 GPU 的候選者中評選最佳使用率讀值。 + /// + private UtilizationReading SelectGpuUsage(IReadOnlyList candidates) { SensorCandidate preferred = default; var preferredRank = GpuSensorRank.None; - foreach (var candidate in _gpuUsageCandidates) + foreach (var candidate in candidates) { if (!TryGetUsage(candidate.Sensor, out var percent)) { @@ -312,6 +409,9 @@ private UtilizationReading SelectGpuUsage() return ToUtilizationReading(preferred); } + /// + /// 嘗試取得並校驗溫度數值是否落在合理範圍(如 1°C ~ 125°C)。 + /// private static bool TryGetTemperature(ISensor sensor, float minimumCelsius, float maximumCelsius, out float celsius) { var value = sensor.Value; @@ -319,6 +419,9 @@ private static bool TryGetTemperature(ISensor sensor, float minimumCelsius, floa return IsUsableTemperature(sensor.SensorType, value, minimumCelsius, maximumCelsius); } + /// + /// 嘗試取得並校驗使用率數值是否落在合理範圍 (0% ~ 100%)。 + /// private static bool TryGetUsage(ISensor sensor, out float percent) { percent = sensor.Value.GetValueOrDefault(); @@ -326,8 +429,7 @@ private static bool TryGetUsage(ISensor sensor, out float percent) } /// - /// A sensor that exists but reports zero, a placeholder, or an impossible value is treated as - /// missing rather than displayed, because showing it would be indistinguishable from a real reading. + /// 驗證溫度讀值是否可用且合理。排除 null、NaN、無窮大及超出邊界的異常讀值(如 0°C 或 200°C)。 /// internal static bool IsUsableTemperature(SensorType sensorType, float? value, float minimumCelsius, float maximumCelsius) => sensorType == SensorType.Temperature @@ -337,8 +439,7 @@ internal static bool IsUsableTemperature(SensorType sensorType, float? value, fl && celsius <= maximumCelsius; /// - /// A zero load is valid, because an idle CPU or GPU can legitimately report 0 percent. Only - /// null, non-finite, out-of-range, and non-load sensor values are rejected. + /// 驗證使用率讀值是否可用且合理。允許 0% 讀值(閒置狀態)。 /// internal static bool IsUsableUtilization(SensorType sensorType, float? value) => sensorType == SensorType.Load @@ -347,6 +448,9 @@ internal static bool IsUsableUtilization(SensorType sensorType, float? value) => && percent >= 0 && percent <= 100; + /// + /// 計算感測器名稱在偏好清單中的優先度索引(越小越優先)。 + /// internal static int GetPreferredRank(string sensorName, IReadOnlyList preferredNames) { for (var index = 0; index < preferredNames.Count; index++) @@ -360,6 +464,9 @@ internal static int GetPreferredRank(string sensorName, IReadOnlyList pr return int.MaxValue; } + /// + /// 將感測器候選者包裝為四捨五入至小數第一位的 。 + /// private TemperatureReading ToReading(SensorCandidate candidate) { if (!candidate.IsValid) @@ -367,9 +474,15 @@ private TemperatureReading ToReading(SensorCandidate candidate) return TemperatureReading.Unavailable; } - return new TemperatureReading(decimal.Round((decimal)candidate.Value, 1), GetSourceName(candidate)); + return new TemperatureReading( + decimal.Round((decimal)candidate.Value, 1), + GetSourceName(candidate), + candidate.Hardware!.Name); } + /// + /// 將感測器候選者包裝為四捨五入至小數第一位的 。 + /// private UtilizationReading ToUtilizationReading(SensorCandidate candidate) { if (!candidate.IsValid) @@ -377,9 +490,15 @@ private UtilizationReading ToUtilizationReading(SensorCandidate candidate) return UtilizationReading.Unavailable; } - return new UtilizationReading(decimal.Round((decimal)candidate.Value, 1), GetSourceName(candidate)); + return new UtilizationReading( + decimal.Round((decimal)candidate.Value, 1), + GetSourceName(candidate), + candidate.Hardware!.Name); } + /// + /// 取得硬體與感測器的完整組合來源名稱(例如 "AMD Ryzen 7 5800H • Core (Tctl/Tdie)")。 + /// private string GetSourceName(SensorCandidate candidate) { if (!_sourceNames.TryGetValue(candidate.Sensor!, out var source)) @@ -391,6 +510,9 @@ private string GetSourceName(SensorCandidate candidate) return source; } + /// + /// 遞迴將硬體的所有感測器資訊加入診斷列表。 + /// private static void AppendRawSensors(IHardware hardware, ICollection sensors) { foreach (var sensor in hardware.Sensors) @@ -412,6 +534,9 @@ private static void AppendRawSensors(IHardware hardware, ICollection + /// 關閉 LibreHardwareMonitor 並清理所有事件綁定與快整陣列。 + /// public void Dispose() { if (!_opened) @@ -429,13 +554,15 @@ public void Dispose() _computer.Close(); _hardware = []; _cpuCandidates = []; - _gpuCandidates = []; + _gpuDevices = []; _cpuUsageCandidates = []; - _gpuUsageCandidates = []; _sourceNames.Clear(); _opened = false; } + /// + /// 遞迴解綁感測器事件。 + /// private void UnsubscribeSensorEvents(IHardware hardware) { hardware.SensorAdded -= _onSensorChanged; @@ -447,28 +574,48 @@ private void UnsubscribeSensorEvents(IHardware hardware) } } - /// A CPU sensor with its sensor-name preference resolved at discovery time. private readonly record struct CpuCandidate(IHardware Hardware, ISensor Sensor, int NameRank); - /// A GPU temperature sensor with both of its rankings resolved at discovery time. private readonly record struct GpuCandidate( IHardware Hardware, ISensor Sensor, GpuSensorRank PreferredRank, GpuSensorRank FallbackRank); - /// A GPU load sensor with its ranking resolved at discovery time. private readonly record struct GpuUsageCandidate( IHardware Hardware, ISensor Sensor, GpuSensorRank PreferredRank); - /// A selected sensor together with the value (°C or percent) it reported this sample. + /// + /// 拓撲掃描期間暫存單一實體 GPU 的所有候選感測器。 + /// + private sealed class GpuDeviceBuilder(IHardware hardware) + { + public IHardware Hardware { get; } = hardware; + + public List TemperatureCandidates { get; } = []; + + public List UsageCandidates { get; } = []; + } + + /// + /// 拓撲掃描完成後快取的單一 GPU 候選感測器集合。 + /// + private readonly record struct GpuDeviceCandidate( + string Id, + string Name, + GpuCandidate[] TemperatureCandidates, + GpuUsageCandidate[] UsageCandidates); + private readonly record struct SensorCandidate(IHardware? Hardware, ISensor? Sensor, float Value) { public bool IsValid => Sensor is not null; } + /// + /// LibreHardwareMonitor 的硬體造訪者類別,用於觸發 hardware.Update()。 + /// private sealed class UpdateVisitor : IVisitor { public void VisitComputer(IComputer computer) => computer.Traverse(this); @@ -488,6 +635,9 @@ public void VisitParameter(IParameter parameter) { } } } +/// +/// 裸硬體感測器診斷資料模型。 +/// public sealed record RawHardwareSensor( string HardwareType, string HardwareName, @@ -496,18 +646,15 @@ public sealed record RawHardwareSensor( float? Value); /// -/// Orders GPU sensors by sensor-name preference first, then discrete before integrated, -/// then discovery order. Lower is better in every component. +/// GPU 感測器排序權重結構體。 +/// 比較順序:名稱偏好 -> 獨顯優先於內顯 -> 偵測順序。 /// internal readonly record struct GpuSensorRank(int NameRank, int HardwarePriority, int Sequence) { internal const int DiscretePriority = 0; internal const int IntegratedPriority = 1; - - /// Used by the fallback selection, which accepts any sensor name. internal const int AnyName = 0; - /// Loses to every real candidate, so the first sensor offered always wins. internal static GpuSensorRank None { get; } = new(int.MaxValue, int.MaxValue, int.MaxValue); internal bool IsBetterThan(GpuSensorRank other) diff --git a/src/ThermoTray/InstanceCoordinator.cs b/src/ThermoTray/InstanceCoordinator.cs index 0aa8fdc..75c9733 100644 --- a/src/ThermoTray/InstanceCoordinator.cs +++ b/src/ThermoTray/InstanceCoordinator.cs @@ -9,24 +9,21 @@ namespace ThermoTray; /// -/// Owns the single-instance handles and answers later launches. Only one instance may poll the -/// hardware and own the notification-area icons, so the guard is deliberately version independent; -/// what the versions decide is which of the two instances gets to be that one. +/// 負責管理單一執行體互斥鎖 (Mutex) 與跨實體通訊的協調器。 +/// 確保系統中同時只有一個 ThermoTray 實體持有系統匣圖示並輪詢硬體。 /// internal sealed class InstanceCoordinator : IDisposable { - // Session-local names: every instance runs elevated in the same session, so a wider scope - // would only invite name collisions with other sessions. + // Session 範圍的互斥鎖名稱:所有實體均在相同 Session 內提升權限執行 private const string OwnershipMutexName = "Local\\ThermoTray.SingleInstance"; /// - /// Still created, and still honoured, because builds before the pipe existed know only this. + /// 舊版本 (1.1.3 之前) 使用的視窗顯示訊號事件名稱,保留用於向下相容。 /// private const string LegacyShowWindowEventName = "Local\\ThermoTray.ShowWindow"; /// - /// Read by the installer's AppMutex. It has to be machine wide and readable by an - /// unelevated process, because the installer runs at the lowest privilege level. + /// 供 Inno Setup 安裝程式讀取的全域互斥鎖名稱。安裝程式以一般權限執行,透過此鎖判斷 ThermoTray 是否正在執行。 /// internal const string SetupMutexName = "Global\\ThermoTray.Setup"; @@ -42,13 +39,18 @@ internal sealed class InstanceCoordinator : IDisposable private InstanceCoordinator(Mutex? ownership) => _ownership = ownership; - /// Named per session because a pipe name, unlike a Local\ object, is machine wide. + /// + /// 依據 Session ID 產生的管道名稱。 + /// internal static string PipeName { get; } = BuildPipeName(); /// - /// Returns the coordinator when this process may own the tray, or when - /// another instance already does. + /// 嘗試取得系統匣單一執行體的持有權。 /// + /// 當前實體的版本號。 + /// 顯示主視窗的回調委派。 + /// 請求結束程式的回調委派。 + /// 若成功取得持有權傳回 實例,否則傳回 null。 internal static InstanceCoordinator? TryClaim(Version? version, Action showWindow, Action requestExit) { Mutex? ownership; @@ -66,7 +68,6 @@ internal sealed class InstanceCoordinator : IDisposable or IOException or WaitHandleCannotBeOpenedException) { - // Without the guard a duplicate instance becomes possible, which beats refusing to start. ownership = null; } @@ -76,9 +77,7 @@ or IOException } /// - /// Claims ownership after the previous instance was asked to exit. The name survives for a - /// moment after the process object signals, so a single attempt would lose a race it has - /// already won. + /// 在要求前一個實體退出後,帶重試機制地嘗試重新取得持有權。 /// internal static InstanceCoordinator? ClaimAfterHandover(Version? version, Action showWindow, Action requestExit) { @@ -97,9 +96,9 @@ or IOException } /// - /// Last resort for an instance that cannot be reached over the pipe, which means it predates it. - /// Reports whether the running instance was actually told to show itself. + /// 當無法透過具名管道連接時,嘗試發送舊版 EventWaitHandle 訊號要求對方顯示視窗。 /// + /// 若成功發送訊號傳回 true,否則傳回 false。 internal static bool TrySignalLegacyInstance() { try @@ -124,6 +123,9 @@ or IOException } } + /// + /// 等待指定處理程序 exit 結束。 + /// internal static bool WaitForProcessExit(int processId, TimeSpan timeout) { try @@ -133,11 +135,14 @@ internal static bool WaitForProcessExit(int processId, TimeSpan timeout) } catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) { - // The process is already gone, which is exactly what the caller was waiting for. + // 處理程序已不存在,視為已順利退出 return true; } } + /// + /// 釋放所有控制代碼與管道伺服器。 + /// public void Dispose() { _server?.Dispose(); @@ -154,6 +159,9 @@ public void Dispose() _ownership = null; } + /// + /// 啟動通訊伺服器與舊版訊號監聽。 + /// private void Start(Version? version, Action showWindow, Action requestExit) { _setupMutex = TryCreateSetupMutex(); @@ -163,7 +171,9 @@ private void Start(Version? version, Action showWindow, Action requestExit) _server.Start(); } - /// Lets a launch that speaks the older protocol bring this instance's window back. + /// + /// 註冊舊版訊號觸發監聽。 + /// private void RegisterLegacySignal(Action showWindow) { try @@ -180,14 +190,11 @@ private void RegisterLegacySignal(Action showWindow) or IOException or WaitHandleCannotBeOpenedException) { - // Single-instance enforcement still works; only the older hand-over gesture is lost. } } /// - /// Exists only to be seen. The installer opens it with SYNCHRONIZE to find out whether ThermoTray - /// is running, which it cannot learn any other way: it runs unelevated and so can neither read - /// this process nor replace the executable image while it is loaded. + /// 建立供 Inno Setup 探測的全域互斥鎖,賦予 Everyone 讀取與 SYNCHRONIZE 權限。 /// private static Mutex? TryCreateSetupMutex() { @@ -200,8 +207,6 @@ or IOException security.AddAccessRule(new MutexAccessRule(identity.User, MutexRights.FullControl, AccessControlType.Allow)); } - // Read-only for everyone else: an unelevated installer must be able to see it, and - // SYNCHRONIZE is all it needs. Mandatory integrity policy blocks writing up, not reading. security.AddAccessRule(new MutexAccessRule( new SecurityIdentifier(WellKnownSidType.WorldSid, domainSid: null), MutexRights.Synchronize | MutexRights.ReadPermissions, @@ -214,11 +219,13 @@ or IOException or WaitHandleCannotBeOpenedException or PlatformNotSupportedException) { - // The installer then falls back to its own files-in-use handling; monitoring is unaffected. return null; } } + /// + /// 依據 Process SessionId 建立管道名稱。 + /// private static string BuildPipeName() { try @@ -234,8 +241,7 @@ private static string BuildPipeName() } /// -/// Answers later launches on behalf of the instance that owns the tray. One client at a time is -/// enough: a launch asks who is running and then either hands over or asks this instance to exit. +/// 具名管道伺服器,負責監聽後續啟動的 ThermoTray 傳來的命令與版本查詢。 /// internal sealed class InstanceServer : IDisposable { @@ -269,13 +275,15 @@ public void Dispose() } catch (AggregateException) { - // The listener only ever fails on a broken client, which no longer matters here. } _listener = null; _cancellation.Dispose(); } + /// + /// 非同步持續監聽管道連線。 + /// private async Task ListenAsync(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) @@ -300,11 +308,13 @@ private async Task ListenAsync(CancellationToken cancellationToken) or UnauthorizedAccessException or ObjectDisposedException) { - // One client that disconnects mid-exchange must not stop the next launch being answered. } } } + /// + /// 處理單次管道連線中的命令(WHO / SHOW / EXIT)。 + /// private async Task ServeAsync(NamedPipeServerStream pipe, CancellationToken cancellationToken) { using var reader = new StreamReader(pipe, InstanceWire.Encoding, detectEncodingFromByteOrderMarks: false, BufferSize, leaveOpen: true); @@ -332,38 +342,36 @@ await writer.WriteLineAsync( return; case InstanceProtocol.ExitRequest: - // Acknowledged before shutting down, or the caller could not tell a refusal from a - // crash. The callback must not run inline, or shutdown would wait on this loop. await AcknowledgeAsync(writer, pipe, cancellationToken).ConfigureAwait(false); _requestExit(); return; default: - // Includes null, which is a disconnected client, and anything that is not ours. return; } } } + /// + /// 回應 OK 確認訊息並排空管道。 + /// private static async Task AcknowledgeAsync(StreamWriter writer, NamedPipeServerStream pipe, CancellationToken cancellationToken) { await writer.WriteLineAsync(InstanceProtocol.Acknowledgement.AsMemory(), cancellationToken).ConfigureAwait(false); try { - // Closing the pipe can discard what the client has not read yet, and on the exit path - // this process is about to disappear. pipe.WaitForPipeDrain(); } catch (Exception exception) when (exception is IOException or ObjectDisposedException) { - // The client left without reading; nothing more can be delivered to it. } } } -/// The launching side of the same exchange. Every call is synchronous by design: it runs -/// during startup, before there is a window or a message loop to keep responsive. +/// +/// 新啟動實體發起管道連線的用戶端。 +/// internal sealed class InstanceClient : IDisposable { private static readonly TimeSpan ExchangeTimeout = TimeSpan.FromSeconds(5); @@ -390,8 +398,7 @@ private InstanceClient(NamedPipeClientStream pipe) internal int RunningProcessId { get; private set; } /// - /// Returns a connected client that has already identified its peer, or - /// when nothing on the other end speaks this protocol. + /// 嘗試連接正在執行的管道伺服器。 /// internal static InstanceClient? TryConnect(string pipeName, TimeSpan timeout) { @@ -418,8 +425,14 @@ private InstanceClient(NamedPipeClientStream pipe) return null; } + /// + /// 發送 SHOW 命令。 + /// internal bool RequestShow() => Exchange(InstanceProtocol.ShowRequest) == InstanceProtocol.Acknowledgement; + /// + /// 發送 EXIT 命令。 + /// internal bool RequestExit() => Exchange(InstanceProtocol.ExitRequest) == InstanceProtocol.Acknowledgement; public void Dispose() @@ -435,6 +448,9 @@ public void Dispose() _pipe.Dispose(); } + /// + /// 發送 WHO 命令查詢對方版本號與 PID。 + /// private bool TryIdentify() { if (!InstanceProtocol.TryParseIdentity(Exchange(InstanceProtocol.IdentifyRequest), out var version, out var processId)) @@ -447,6 +463,9 @@ private bool TryIdentify() return true; } + /// + /// 發送管道請求並接收單行回應。 + /// private string? Exchange(string request) { try @@ -471,8 +490,7 @@ or InvalidOperationException or OperationCanceledException; /// - /// Runs the exchange off the calling thread. Blocking the UI thread on a continuation that the - /// dispatcher would have to run is a deadlock, and startup does block on these calls. + /// 在背景 ThreadPool 執行非同步工作並同步等待,防止 UI 執行緒死鎖。 /// private static void RunSync(Func operation) => Task.Run(() => operation().WaitAsync(ExchangeTimeout)).GetAwaiter().GetResult(); @@ -481,9 +499,14 @@ private static T RunSync(Func> operation) => Task.Run(() => operation().WaitAsync(ExchangeTimeout)).GetAwaiter().GetResult(); } +/// +/// 具名管道編碼與換行格式常數。 +/// internal static class InstanceWire { - /// Without the explicit constructor emits a byte order mark. + /// + /// 使用無 BOM 標記的 UTF-8 編碼。 + /// internal static Encoding Encoding { get; } = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); internal const string NewLine = "\n"; diff --git a/src/ThermoTray/InstanceProtocol.cs b/src/ThermoTray/InstanceProtocol.cs index 4a791d8..3ba3442 100644 --- a/src/ThermoTray/InstanceProtocol.cs +++ b/src/ThermoTray/InstanceProtocol.cs @@ -2,42 +2,81 @@ namespace ThermoTray; -/// What a starting instance should do when another instance already owns the tray. +/// +/// 當另一個執行個體已佔用系統匣時,新啟動的執行個體應採取的處置動作。 +/// internal enum InstanceAction { - /// Bring the running instance forward and exit without saying anything. + /// + /// 直接顯示已在執行的視窗,並無聲結束當前新啟動的執行個體。 + /// ShowRunning, - /// Same, but tell the user that what is running is newer than what they just launched. + /// + /// 顯示已在執行的視窗,並提示使用者目前執行中的版本較新。 + /// ShowNewerRunning, - /// Offer to shut the older instance down and take over the tray. + /// + /// 提示使用者是否要關閉舊版執行個體,並由當前新啟動的執行個體接手系統匣。 + /// ReplaceRunning, } /// -/// The line protocol a later launch speaks to the instance that already owns the tray, plus the -/// version comparison that decides which of them survives. Both halves are pure so the decision -/// table can be tested without starting processes. +/// 新啟動執行個體與已執行個體之間的具名管道溝通協定及版本比較邏輯。 +/// 包含純邏輯轉換,方便測試而無需實際啟動背景處理程序。 /// internal static class InstanceProtocol { + /// + /// 查詢身份與版本的請求命令 ("WHO")。 + /// internal const string IdentifyRequest = "WHO"; + + /// + /// 請求顯示主視窗的命令 ("SHOW")。 + /// internal const string ShowRequest = "SHOW"; + + /// + /// 請求退出並釋放系統匣的命令 ("EXIT")。 + /// internal const string ExitRequest = "EXIT"; + + /// + /// 回應確認文字 ("OK")。 + /// internal const string Acknowledgement = "OK"; - /// Guards against answering some unrelated program that happened to open the pipe. + /// + /// 身份識別的前綴字串,防止誤讀非本應用的管道回應。 + /// private const string IdentityPrefix = "THERMOTRAY"; - /// Stands in for an assembly that carries no version at all. + /// + /// 當元件無版本資訊時的占位字串。 + /// private const string UnknownVersion = "-"; + /// + /// 格式化身份識別回應字串(如 "THERMOTRAY 1.1.3.0 1234")。 + /// + /// 版本資訊。 + /// 處理程序 ID。 + /// 格式化後的身份字串。 internal static string FormatIdentity(Version? version, int processId) => string.Create( CultureInfo.InvariantCulture, $"{IdentityPrefix} {version?.ToString() ?? UnknownVersion} {processId}"); + /// + /// 解析身份識別回應字串。 + /// + /// 接收到的管道字串。 + /// 解析出的版本號。 + /// 解析出的處理程序 ID。 + /// 若成功解析傳回 true,否則傳回 false。 internal static bool TryParseIdentity(string? line, out Version? version, out int processId) { version = null; @@ -60,20 +99,22 @@ internal static bool TryParseIdentity(string? line, out Version? version, out in return false; } - // An unparsable version is reported as unknown rather than rejecting an otherwise valid peer. + // 若版本格式無法完整解析,仍保留處理程序 ID,版本視為 null version = Version.TryParse(parts[1], out var parsed) ? parsed : null; return true; } /// - /// Only the first three components name a release; the fourth is always zero, so two builds that - /// differ only there are the same product and the launch is an ordinary hand-over. + /// 比對已執行版本與新啟動版本,決定處置動作(只需比較 Major.Minor.Build 即可)。 /// + /// 已在執行的版本。 + /// 新啟動的版本。 + /// 建議執行的 internal static InstanceAction Decide(Version? running, Version? starting) { if (running is null || starting is null) { - // Without both versions there is nothing to compare, and handing over is the safe outcome. + // 無法確定版本時,安全性考量下直接顯示已執行的視窗 return InstanceAction.ShowRunning; } @@ -85,6 +126,11 @@ internal static InstanceAction Decide(Version? running, Version? starting) }; } + /// + /// 標準化版本號(將 Revision 忽略,主要比對主版號、次版號與組建編號)。 + /// + /// 原始版本物件。 + /// 標準化後的版本物件。 private static Version Normalize(Version version) => new(version.Major, version.Minor, Math.Max(version.Build, 0)); } diff --git a/src/ThermoTray/Localizer.cs b/src/ThermoTray/Localizer.cs index 1e34434..1a83dd7 100644 --- a/src/ThermoTray/Localizer.cs +++ b/src/ThermoTray/Localizer.cs @@ -1,10 +1,23 @@ namespace ThermoTray; +/// +/// 提供多語系 UI 字串本地化服務(支援繁體中文與英文)。 +/// public sealed class Localizer { + /// + /// 預設語言代碼(繁體中文 "zh-TW")。 + /// public const string DefaultLanguage = "zh-TW"; + + /// + /// 英文語言代碼 ("en-US")。 + /// public const string EnglishLanguage = "en-US"; + /// + /// 雙語字串對照字典。索引鍵為字串 Key,值為 (繁體中文, 英文) 的二元組。 + /// private static readonly IReadOnlyDictionary Strings = new Dictionary { @@ -13,6 +26,7 @@ public sealed class Localizer ["GpuTemperature"] = ("GPU 溫度", "GPU temperature"), ["CpuUsage"] = ("CPU 使用率", "CPU usage"), ["GpuUsage"] = ("GPU 使用率", "GPU usage"), + ["ShowInTray"] = ("顯示在工具列", "Show in tray"), ["Unavailable"] = ("無法取得", "Unavailable"), ["WaitingForSensors"] = ("正在讀取硬體感測器…", "Reading hardware sensors…"), ["NoSensor"] = ("找不到可用的溫度感測器。請確認硬體/驅動程式支援。", "No usable temperature sensor was found. Check hardware and driver support."), @@ -49,17 +63,37 @@ public sealed class Localizer "Could not close the running ThermoTray {0}. Exit it from the notification area, then start this version again."), }; + /// + /// 初始化 Localizer 的新實例。 + /// + /// 語系代碼(如 "zh-TW" 或 "en-US")。 public Localizer(string language) => Language = Normalize(language); + /// + /// 取得當前使用的語言代碼。 + /// public string Language { get; private set; } + /// + /// 依據指定的 Key 取得本地化後的文字。 + /// + /// 字串 Key。 + /// 若找到 Key 則傳回對應語言的文字,否則傳回 Key 原字串。 public string this[string key] => Strings.TryGetValue(key, out var text) ? Language == EnglishLanguage ? text.English : text.TraditionalChinese : key; + /// + /// 切換當前的語言。 + /// + /// 目標語言代碼。 public void SetLanguage(string language) => Language = Normalize(language); - /// Maps anything unrecognised, including a hand-edited settings file, onto a supported language. + /// + /// 將未辨識或格式不符合的語言代碼標準化為系統支援的語言代碼(預設為繁體中文)。 + /// + /// 輸入的語言字串。 + /// 標準化後的語言代碼。 private static string Normalize(string? language) => string.Equals(language, EnglishLanguage, StringComparison.OrdinalIgnoreCase) ? EnglishLanguage : DefaultLanguage; } diff --git a/src/ThermoTray/MainViewModel.cs b/src/ThermoTray/MainViewModel.cs index 0fe3a85..312f1a6 100644 --- a/src/ThermoTray/MainViewModel.cs +++ b/src/ThermoTray/MainViewModel.cs @@ -1,25 +1,34 @@ using System.ComponentModel; +using System.Collections.ObjectModel; using System.IO; using System.Runtime.CompilerServices; using System.Windows.Threading; namespace ThermoTray; +/// +/// 主視窗與系統匣圖示的主要 View Model (MVVM 架構)。 +/// 負責感測器定期輪詢、視窗顯示與隱藏狀態切換、開機啟動設定同步及多語系處理。 +/// public sealed class MainViewModel : INotifyPropertyChanged { + /// + /// 當主視窗顯示時的採樣週期(1 秒)。 + /// private static readonly TimeSpan VisiblePollingInterval = TimeSpan.FromSeconds(1); /// - /// Used while the window is hidden in the tray, which is where this app spends nearly all of its - /// life. Only the tray icons are readable then, and reading hardware sensors is by far the most - /// expensive thing ThermoTray does, so halving the sample rate halves its idle CPU cost. + /// 當主視窗隱藏至系統匣時的採樣週期(2 秒)。 + /// 在背景執行時降低採樣頻率可有效減半 CPU 與系統資源開銷。 /// private static readonly TimeSpan HiddenPollingInterval = TimeSpan.FromSeconds(2); private static readonly TimeSpan ShutdownTimeout = TimeSpan.FromSeconds(5); private const long DriverProbeIntervalMilliseconds = 30_000; - /// Samples a missing GPU is given before it is treated as absent rather than faulty. + /// + /// 判定 GPU 確實不存在前允許的連續無讀值次數(緩衝次數)。 + /// private const int MissingGpuGraceSamples = 5; private readonly HardwareSensorService _sensorService; @@ -27,125 +36,165 @@ public sealed class MainViewModel : INotifyPropertyChanged private readonly StartupService _startupService; private readonly AppSettings _settings; private readonly Localizer _localizer; + private readonly ObservableCollection _gpuItems = []; + private readonly ReadOnlyObservableCollection _gpus; private readonly Dispatcher _dispatcher = Dispatcher.CurrentDispatcher; + private readonly object _startupGate = new(); private SensorDriverStatus _driverStatus = SensorDriverStatus.Query(); private bool _isDriverActionVisible; private bool _isGpuPresent = true; private bool _gpuEverReported; private int _gpuMissingSamples; private bool _isStopped; + private int _startupRequestVersion; - // Read by the sampling thread and written by the UI thread, so it must not be cached in a register. private volatile bool _isWindowVisible; private CancellationTokenSource? _pollingCancellation; private Task? _pollingTask; private long _nextDriverProbeTick = Environment.TickCount64 + DriverProbeIntervalMilliseconds; private TemperatureReading? _lastCpuReading; - private TemperatureReading? _lastGpuReading; private UtilizationReading? _lastCpuUsage; - private UtilizationReading? _lastGpuUsage; private string _cpuTemperature = "…"; - private string _gpuTemperature = "…"; private string _cpuUsage = "…"; - private string _gpuUsage = "…"; private string _cpuTrayDigits = TemperatureFormatter.TrayPlaceholder; - private string _gpuTrayDigits = TemperatureFormatter.TrayPlaceholder; private string _cpuUsageTrayDigits = UtilizationFormatter.TrayPlaceholder; - private string _gpuUsageTrayDigits = UtilizationFormatter.TrayPlaceholder; + private string _cpuDeviceName = string.Empty; private string _cpuSource = string.Empty; - private string _gpuSource = string.Empty; private string _statusMessage; + /// + /// 初始化 MainViewModel 的新實例。 + /// public MainViewModel(HardwareSensorService sensorService, SettingsService settingsService, StartupService startupService) { _sensorService = sensorService; _settingsService = settingsService; _startupService = startupService; _settings = settingsService.Load(); + _settings.GpuTraySettings ??= new(); _localizer = new Localizer(_settings.Language); _settings.Language = _localizer.Language; + _gpus = new ReadOnlyObservableCollection(_gpuItems); _statusMessage = _localizer["WaitingForSensors"]; } public event PropertyChangedEventHandler? PropertyChanged; + /// + /// 本地化服務實例。 + /// public Localizer T => _localizer; /// - /// The product version as a user reads it. Neither underlying value is shown verbatim: the assembly - /// version's fourth component is always zero, and the informational version carries the commit hash. + /// 格式化後顯示於視窗標題列的產品版本號字串 (如 "v1.1.3")。 /// public string Version { get; } = FormatVersion(typeof(MainViewModel).Assembly.GetName().Version); + /// + /// 格式化後的 CPU 溫度顯示字串(例如 "45.2 °C")。 + /// public string CpuTemperature { get => _cpuTemperature; private set => SetField(ref _cpuTemperature, value); } - public string GpuTemperature - { - get => _gpuTemperature; - private set => SetField(ref _gpuTemperature, value); - } - + /// + /// 格式化後的 CPU 使用率顯示字串(例如 "12.5%")。 + /// public string CpuUsage { get => _cpuUsage; private set => SetField(ref _cpuUsage, value); } - public string GpuUsage - { - get => _gpuUsage; - private set => SetField(ref _gpuUsage, value); - } - - /// Whole degrees for the tray icon, independent of the current culture's number format. + /// + /// CPU 系統匣圖示顯示的整數位元數字。 + /// public string CpuTrayDigits { get => _cpuTrayDigits; private set => SetField(ref _cpuTrayDigits, value); } - public string GpuTrayDigits - { - get => _gpuTrayDigits; - private set => SetField(ref _gpuTrayDigits, value); - } - - /// Whole percentage points for the tray icon, independent of the current culture. + /// + /// CPU 使用率系統匣圖示顯示的整數位元數字。 + /// public string CpuUsageTrayDigits { get => _cpuUsageTrayDigits; private set => SetField(ref _cpuUsageTrayDigits, value); } - public string GpuUsageTrayDigits + public string CpuDeviceName { - get => _gpuUsageTrayDigits; - private set => SetField(ref _gpuUsageTrayDigits, value); + get => _cpuDeviceName; + private set => SetField(ref _cpuDeviceName, value); } + /// + /// CPU 感測器來源名稱。 + /// public string CpuSource { get => _cpuSource; private set => SetField(ref _cpuSource, value); } - public string GpuSource + public bool ShowCpuUsageInTray { - get => _gpuSource; - private set => SetField(ref _gpuSource, value); + get => _settings.ShowCpuUsageInTray; + set + { + if (_settings.ShowCpuUsageInTray == value) + { + return; + } + + _settings.ShowCpuUsageInTray = value; + SaveSettings(); + OnPropertyChanged(); + } } + public bool ShowCpuTemperatureInTray + { + get => _settings.ShowCpuTemperatureInTray; + set + { + if (_settings.ShowCpuTemperatureInTray == value) + { + return; + } + + _settings.ShowCpuTemperatureInTray = value; + SaveSettings(); + OnPropertyChanged(); + } + } + + /// + /// 當前所有 GPU 的獨立顯示狀態。每個項目對應一張實體顯示卡。 + /// + public ReadOnlyObservableCollection Gpus => _gpus; + + /// + /// 供 UI 測試與 ViewModel 內部同步使用的可變 GPU 集合。 + /// + internal ObservableCollection GpuItems => _gpuItems; + + /// + /// 狀態欄訊息文字。 + /// public string StatusMessage { get => _statusMessage; private set => SetField(ref _statusMessage, value); } - /// True when the missing kernel driver is the reason a reading is unavailable. + /// + /// 是否顯示 PawnIO 驅動程式下載與提醒提示按鈕。 + /// public bool IsDriverActionVisible { get => _isDriverActionVisible; @@ -153,9 +202,7 @@ public bool IsDriverActionVisible } /// - /// False once this machine has gone long enough without a single GPU temperature or usage - /// reading to conclude that it has no readable GPU telemetry. Its tray icon and card are then - /// hidden instead of showing a warning that can never be resolved. + /// 取得一個值,表示當前系統是否存在可讀取的 GPU。若經過多次採樣均無 GPU 感測器,則隱藏 GPU 圖示。 /// public bool IsGpuPresent { @@ -163,8 +210,14 @@ public bool IsGpuPresent private set => SetField(ref _isGpuPresent, value); } + /// + /// PawnIO 驅動程式下載連結。 + /// public string DriverDownloadUrl => SensorDriverStatus.DownloadUrl; + /// + /// 取得或設定是否隨 Windows 啟動。 + /// public bool StartWithWindows { get => _settings.StartWithWindows; @@ -175,11 +228,10 @@ public bool StartWithWindows return; } + Interlocked.Increment(ref _startupRequestVersion); if (!TrySetStartupEnabled(value)) { StatusMessage = T["StartupError"]; - - // Push the unchanged value back so the checkbox does not claim a state that was not applied. OnPropertyChanged(); return; } @@ -190,6 +242,9 @@ public bool StartWithWindows } } + /// + /// 取得或設定關閉主視窗時是否縮小至系統匣。 + /// public bool HideWhenClosed { get => _settings.HideWhenClosed; @@ -206,6 +261,9 @@ public bool HideWhenClosed } } + /// + /// 取得或設定當前介面語言。 + /// public string Language { get => _localizer.Language; @@ -220,14 +278,19 @@ public string Language _settings.Language = _localizer.Language; SaveSettings(); _lastCpuReading = null; - _lastGpuReading = null; _lastCpuUsage = null; - _lastGpuUsage = null; + foreach (var gpu in _gpuItems) + { + gpu.UpdateLabels(T["GpuUsage"], T["GpuTemperature"]); + } StatusMessage = T["WaitingForSensors"]; OnPropertyChanged(string.Empty); } } + /// + /// 啟動感測器背景輪詢與開機啟動設定校驗。 + /// public void Start() { if (_isStopped || _pollingCancellation is not null) @@ -238,20 +301,18 @@ public void Start() var cancellation = new CancellationTokenSource(); _pollingCancellation = cancellation; - // The whole loop runs on the thread pool, so a sample never needs its own dispatch back onto it. _pollingTask = Task.Run(() => PollAsync(cancellation.Token), CancellationToken.None); - - // schtasks can block for seconds, so the startup preference is reconciled off the UI thread. _ = Task.Run(ReconcileStartupSetting); } /// - /// Tells the sampling loop whether anything other than the tray icons is on screen. The new rate - /// takes effect on the next tick rather than immediately, which keeps the loop free of extra - /// cross-thread signalling for a change worth at most one interval. + /// 設定當前主視窗是否顯示,據此動態調整輪詢頻率(顯示時 1s,隱藏至系統匣時 2s)。 /// public void SetWindowVisible(bool isVisible) => _isWindowVisible = isVisible; + /// + /// 停止輪詢並處置感測器服務。 + /// public void Stop() { _isStopped = true; @@ -270,8 +331,6 @@ public void Stop() if (!WaitForPollingToStop(pollingTask)) { - // A sampling pass is still running; closing the sensor stack underneath it would be worse - // than leaving it to process teardown. return; } @@ -279,6 +338,9 @@ public void Stop() _sensorService.Dispose(); } + /// + /// 等待輪詢工作退出。 + /// private static bool WaitForPollingToStop(Task? pollingTask) { if (pollingTask is null) @@ -292,11 +354,13 @@ private static bool WaitForPollingToStop(Task? pollingTask) } catch (AggregateException) { - // A faulted polling task has still finished, which is all this wait needs to establish. return true; } } + /// + /// 背景輪詢迴圈,依指定頻率呼叫感測器服務並更新 UI。 + /// private async Task PollAsync(CancellationToken cancellationToken) { var interval = CurrentPollingInterval; @@ -332,26 +396,26 @@ private async Task PollAsync(CancellationToken cancellationToken) } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - // Normal shutdown. } } private TimeSpan CurrentPollingInterval => GetPollingInterval(_isWindowVisible); /// - /// Three components, because that is what Directory.Build.props sets and what every release - /// and installer is named after. An absent component reads as zero rather than as -1. + /// 將 Version 物件格式化為 "v1.1.3" 格式字串。 /// internal static string FormatVersion(Version? version) => version is null ? string.Empty : $"v{version.Major}.{version.Minor}.{Math.Max(version.Build, 0)}"; + /// + /// 依據視窗顯示狀態取得採樣間隔(可測試函數)。 + /// internal static TimeSpan GetPollingInterval(bool isWindowVisible) => isWindowVisible ? VisiblePollingInterval : HiddenPollingInterval; /// - /// Queues UI work without awaiting it. Awaiting would make the polling loop depend on the UI - /// thread, which then could not block on that loop during shutdown. + /// 非同步分派 UI 委派動作至 Dispatcher。 /// private void Post(Action action) { @@ -366,10 +430,12 @@ private void Post(Action action) } catch (InvalidOperationException) { - // The dispatcher shut down after the check above; the update is no longer needed. } } + /// + /// 將讀取到的硬體快照套用至各 View Model 屬性與系統匣字串。 + /// private void ApplySnapshot(HardwareSnapshot snapshot) { if (_lastCpuReading != snapshot.CpuTemperature) @@ -377,15 +443,10 @@ private void ApplySnapshot(HardwareSnapshot snapshot) _lastCpuReading = snapshot.CpuTemperature; CpuTemperature = Format(snapshot.CpuTemperature); CpuTrayDigits = TemperatureFormatter.ToTrayDigits(snapshot.CpuTemperature); - CpuSource = snapshot.CpuTemperature.Source; - } - - if (_lastGpuReading != snapshot.GpuTemperature) - { - _lastGpuReading = snapshot.GpuTemperature; - GpuTemperature = Format(snapshot.GpuTemperature); - GpuTrayDigits = TemperatureFormatter.ToTrayDigits(snapshot.GpuTemperature); - GpuSource = snapshot.GpuTemperature.Source; + CpuDeviceName = snapshot.CpuTemperature.IsAvailable + ? snapshot.CpuTemperature.DeviceName + : snapshot.CpuUsage.DeviceName; + CpuSource = GetSensorName(snapshot.CpuTemperature.Source); } if (_lastCpuUsage != snapshot.CpuUsage) @@ -393,22 +454,117 @@ private void ApplySnapshot(HardwareSnapshot snapshot) _lastCpuUsage = snapshot.CpuUsage; CpuUsage = FormatUsage(snapshot.CpuUsage); CpuUsageTrayDigits = UtilizationFormatter.ToTrayDigits(snapshot.CpuUsage); + if (!snapshot.CpuTemperature.IsAvailable) + { + CpuDeviceName = snapshot.CpuUsage.DeviceName; + CpuSource = GetSensorName(snapshot.CpuUsage.Source); + } } - if (_lastGpuUsage != snapshot.GpuUsage) + ApplyGpuReadings(snapshot.Gpus); + UpdateGpuPresence(snapshot.Gpus); + StatusMessage = GetAvailabilityMessage(snapshot); + } + + /// + /// 將快照中的每張 GPU 同步至對應的 ViewModel,並移除已離開拓撲的裝置。 + /// + private void ApplyGpuReadings(IReadOnlyList readings) + { + for (var index = 0; index < readings.Count; index++) { - _lastGpuUsage = snapshot.GpuUsage; - GpuUsage = FormatUsage(snapshot.GpuUsage); - GpuUsageTrayDigits = UtilizationFormatter.ToTrayDigits(snapshot.GpuUsage); + var reading = readings[index]; + var gpu = FindGpu(reading.Id); + if (gpu is null) + { + gpu = new GpuViewModel(reading.Id); + gpu.ConfigureTraySettings(GetGpuTraySettings(reading.Id), SaveSettings); + _gpuItems.Add(gpu); + } + + gpu.Apply( + reading, + index, + T["GpuUsage"], + T["GpuTemperature"], + Format, + FormatUsage); } - UpdateGpuPresence(snapshot.GpuTemperature, snapshot.GpuUsage); - StatusMessage = GetAvailabilityMessage(snapshot); + for (var index = _gpuItems.Count - 1; index >= 0; index--) + { + if (!ContainsGpu(readings, _gpuItems[index].Id)) + { + _gpuItems.RemoveAt(index); + } + } + } + + private TrayDisplaySettings GetGpuTraySettings(string id) + { + if (!_settings.GpuTraySettings.TryGetValue(id, out var settings) || settings is null) + { + settings = new TrayDisplaySettings(); + _settings.GpuTraySettings[id] = settings; + } + + return settings; + } + + private static string GetSensorName(string source) + { + var separator = source.IndexOf('\u2022'); + return separator >= 0 ? source[(separator + 1)..].Trim() : source; + } + + /// + /// 依拓撲識別碼尋找既有 GPU ViewModel。 + /// + private GpuViewModel? FindGpu(string id) + { + foreach (var gpu in _gpuItems) + { + if (string.Equals(gpu.Id, id, StringComparison.Ordinal)) + { + return gpu; + } + } + + return null; + } + + /// + /// 判斷最新快照是否仍包含指定 GPU。 + /// + private static bool ContainsGpu(IReadOnlyList readings, string id) + { + foreach (var reading in readings) + { + if (string.Equals(reading.Id, id, StringComparison.Ordinal)) + { + return true; + } + } + + return false; } - private void UpdateGpuPresence(TemperatureReading gpuTemperature, UtilizationReading gpuUsage) + /// + /// 依據 GPU 讀值動態判斷系統中是否配備可讀取的 GPU。 + /// + private void UpdateGpuPresence(IReadOnlyList readings) { - if (gpuTemperature.IsAvailable || gpuUsage.IsAvailable) + var anyGpuReading = false; + foreach (var reading in readings) + { + if (reading.IsAvailable) + { + anyGpuReading = true; + break; + } + } + + if (anyGpuReading) { _gpuEverReported = true; _gpuMissingSamples = 0; @@ -418,7 +574,6 @@ private void UpdateGpuPresence(TemperatureReading gpuTemperature, UtilizationRea if (_gpuEverReported) { - // A GPU that reported once and then stopped is a real fault, so keep surfacing it. return; } @@ -430,11 +585,13 @@ private void UpdateGpuPresence(TemperatureReading gpuTemperature, UtilizationRea IsGpuPresent = _gpuMissingSamples < MissingGpuGraceSamples; } + /// + /// 依據硬體快照回報適當狀態與錯誤提醒訊息。 + /// private string GetAvailabilityMessage(HardwareSnapshot snapshot) { if (!snapshot.CpuTemperature.IsAvailable) { - // The driver can be installed while ThermoTray runs, so re-probe instead of trusting the startup value. if (!_driverStatus.IsInstalled && Environment.TickCount64 >= _nextDriverProbeTick) { _driverStatus = SensorDriverStatus.Query(); @@ -447,7 +604,7 @@ private string GetAvailabilityMessage(HardwareSnapshot snapshot) return T["DriverMissing"]; } - return snapshot.GpuTemperature.IsAvailable || snapshot.GpuUsage.IsAvailable + return HasAvailableGpu(snapshot.Gpus) ? T["CpuSensorUnavailable"] : T["NoSensor"]; } @@ -458,18 +615,41 @@ private string GetAvailabilityMessage(HardwareSnapshot snapshot) return T["CpuUsageUnavailable"]; } - // A machine with no readable GPU telemetry is not a fault and must not raise a permanent warning. if (!IsGpuPresent) { return string.Empty; } - if (!snapshot.GpuTemperature.IsAvailable) + foreach (var gpu in snapshot.Gpus) + { + if (!gpu.Temperature.IsAvailable) + { + return T["GpuSensorUnavailable"]; + } + + if (!gpu.Usage.IsAvailable) + { + return T["GpuUsageUnavailable"]; + } + } + + return string.Empty; + } + + /// + /// 判斷快照中是否至少有一張 GPU 提供可信的即時讀值。 + /// + private static bool HasAvailableGpu(IReadOnlyList readings) + { + foreach (var reading in readings) { - return T["GpuSensorUnavailable"]; + if (reading.IsAvailable) + { + return true; + } } - return snapshot.GpuUsage.IsAvailable ? string.Empty : T["GpuUsageUnavailable"]; + return false; } private string Format(TemperatureReading reading) => reading.Celsius is decimal celsius @@ -488,40 +668,67 @@ private void SaveSettings() } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - // Preferences are non-critical; avoid a user-visible crash if a local profile is locked. } } private bool TrySetStartupEnabled(bool enabled) { - try + lock (_startupGate) { - _startupService.SetEnabled(enabled); - return true; - } - catch (Exception exception) when (exception is UnauthorizedAccessException - or System.Security.SecurityException - or InvalidOperationException) - { - return false; + try + { + _startupService.SetEnabled(enabled); + return true; + } + catch (Exception exception) when (exception is UnauthorizedAccessException + or System.Security.SecurityException + or IOException + or InvalidOperationException) + { + return false; + } } } /// - /// Registers the logon task when the saved preference says startup is enabled but the task is gone, - /// which is what an upgrade from the old HKCU\Run entry leaves behind, or still carries an - /// older definition, which is what an upgrade from a version that let Task Scheduler terminate - /// ThermoTray leaves behind. + /// 校驗並修復開機啟動設定。 /// private void ReconcileStartupSetting() { - if (!_settings.StartWithWindows || _startupService.IsUpToDate() || TrySetStartupEnabled(true)) + if (!_settings.StartWithWindows) + { + return; + } + + var requestVersion = Volatile.Read(ref _startupRequestVersion); + var succeeded = false; + lock (_startupGate) + { + if (requestVersion != Volatile.Read(ref _startupRequestVersion) || !_settings.StartWithWindows) + { + return; + } + + if (_startupService.IsUpToDate()) + { + return; + } + + succeeded = TrySetStartupEnabledCore(true); + } + + if (succeeded || requestVersion != Volatile.Read(ref _startupRequestVersion)) { return; } Post(() => { + if (requestVersion != Volatile.Read(ref _startupRequestVersion) || !_settings.StartWithWindows) + { + return; + } + _settings.StartWithWindows = false; SaveSettings(); OnPropertyChanged(nameof(StartWithWindows)); @@ -529,6 +736,22 @@ private void ReconcileStartupSetting() }); } + private bool TrySetStartupEnabledCore(bool enabled) + { + try + { + _startupService.SetEnabled(enabled); + return true; + } + catch (Exception exception) when (exception is UnauthorizedAccessException + or System.Security.SecurityException + or IOException + or InvalidOperationException) + { + return false; + } + } + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); diff --git a/src/ThermoTray/MainWindow.xaml b/src/ThermoTray/MainWindow.xaml index f95e198..0615003 100644 --- a/src/ThermoTray/MainWindow.xaml +++ b/src/ThermoTray/MainWindow.xaml @@ -29,29 +29,84 @@ - + - + + + + + + + + + - + + + + + + + + - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ThermoTray/MainWindow.xaml.cs b/src/ThermoTray/MainWindow.xaml.cs index 0e91057..2e5dc0e 100644 --- a/src/ThermoTray/MainWindow.xaml.cs +++ b/src/ThermoTray/MainWindow.xaml.cs @@ -5,16 +5,29 @@ namespace ThermoTray; +/// +/// ThermoTray 主視窗後端程式碼。 +/// public partial class MainWindow : Window { + /// + /// 標記是否為真正的關閉請求(由選單「結束」觸發),而非點擊右上角 X 按鈕隱藏至系統匣。 + /// private bool _reallyClosing; + /// + /// 初始化 MainWindow 的新實例。 + /// public MainWindow() { InitializeComponent(); StateChanged += OnStateChanged; } + /// + /// 處理視窗關閉事件。若設定為「關閉時縮小至系統匣」,則攔截關閉並將視窗隱藏。 + /// + /// 包含取消選項的關閉事件引數。 protected override void OnClosing(CancelEventArgs e) { if (!_reallyClosing && DataContext is MainViewModel { HideWhenClosed: true }) @@ -30,6 +43,9 @@ protected override void OnClosing(CancelEventArgs e) base.OnClosing(e); } + /// + /// 處理 PawnIO 驅動程式下載超連結點擊事件,使用系統預設瀏覽器開啟連結。 + /// private void OnDriverLinkRequestNavigate(object sender, RequestNavigateEventArgs e) { e.Handled = true; @@ -41,12 +57,15 @@ private void OnDriverLinkRequestNavigate(object sender, RequestNavigateEventArgs } catch (Exception exception) when (exception is Win32Exception or InvalidOperationException) { - // No browser is registered, so show the address for the user to copy instead of crashing. + // 若系統未關聯預設瀏覽器,彈出提示對話方塊並顯示網址供使用者手動複製 var message = (DataContext as MainViewModel)?.T["OpenLinkError"] ?? string.Empty; System.Windows.MessageBox.Show(this, $"{message}{Environment.NewLine}{address}", Title); } } + /// + /// 處理視窗狀態改變事件。當使用者將視窗最小化時,自動將其隱藏至系統匣。 + /// private void OnStateChanged(object? sender, EventArgs e) { if (WindowState == WindowState.Minimized) diff --git a/src/ThermoTray/NativeMethods.cs b/src/ThermoTray/NativeMethods.cs index b81fa50..2d2d04b 100644 --- a/src/ThermoTray/NativeMethods.cs +++ b/src/ThermoTray/NativeMethods.cs @@ -2,17 +2,27 @@ namespace ThermoTray; +/// +/// 封裝原生 Win32 API 呼叫的內部靜態類別。 +/// internal static class NativeMethods { /// - /// Hands this process's right to take the foreground to another one. Without it the running - /// instance's own Activate() only flashes its taskbar button, because Windows refuses a - /// foreground change requested by a process the user did not just interact with. + /// 將本處理程序的焦點切換權限移交給指定的處理程序。 + /// 若未呼叫此 API,執行中的處理程序呼叫 Activate() 時僅會在工作列閃爍, + /// 因為 Windows 會阻擋非使用者當前互動處理程序的焦點切換請求。 /// + /// 目標處理程序 ID。 + /// 若成功授予權限傳回 true,否則傳回 false。 [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool AllowSetForegroundWindow(int dwProcessId); + /// + /// 將指定視窗帶入前景並將其啟動。 + /// + /// 目標視窗的控制項代碼 (Handle)。 + /// 若成功將視窗切換至前景傳回 true,否則傳回 false。 [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] internal static extern bool SetForegroundWindow(IntPtr hWnd); diff --git a/src/ThermoTray/SensorDriverStatus.cs b/src/ThermoTray/SensorDriverStatus.cs index a0b6ce6..d1a22cf 100644 --- a/src/ThermoTray/SensorDriverStatus.cs +++ b/src/ThermoTray/SensorDriverStatus.cs @@ -3,14 +3,23 @@ namespace ThermoTray; /// -/// Reports whether the kernel driver LibreHardwareMonitor 0.9.6 needs for direct CPU register -/// access is present. That release replaced WinRing0 with PawnIO, so on AMD Ryzen the -/// Tctl/Tdie sensor exists but stays at 0 until PawnIO is installed. +/// 回報 LibreHardwareMonitor 0.9.6 存取 CPU 暫存器所需的核心驅動程式 (PawnIO) 是否已安裝。 +/// 由於 LibreHardwareMonitor 以 PawnIO 取代 WinRing0,在 AMD Ryzen 等處理器上,若未安裝 PawnIO, +/// 雖然能找到 Tctl/Tdie 感測器,但讀值會恆為 0。 /// +/// PawnIO 驅動程式是否已安裝。 +/// PawnIO 驅動程式的版本字串。 public sealed record SensorDriverStatus(bool IsInstalled, string Version) { + /// + /// PawnIO 驅動程式的官方下載網址。 + /// public const string DownloadUrl = "https://pawnio.eu/"; + /// + /// 查詢系統中 PawnIO 驅動程式的安裝狀態與版本。 + /// + /// 包含安裝狀態與版本號的 實例。 public static SensorDriverStatus Query() { try @@ -21,7 +30,7 @@ public static SensorDriverStatus Query() } catch (Exception) { - // Any probe failure means the driver is not usable; never guess that it is present. + // 若探測過程發生任何例外,一律視為驅動程式不可用,切勿盲目假設其已安裝。 return new SensorDriverStatus(false, string.Empty); } } diff --git a/src/ThermoTray/SettingsService.cs b/src/ThermoTray/SettingsService.cs index 3257584..8fac9c7 100644 --- a/src/ThermoTray/SettingsService.cs +++ b/src/ThermoTray/SettingsService.cs @@ -3,13 +3,23 @@ namespace ThermoTray; +/// +/// 負責應用程式設定 (JSON 檔) 之載入與安全儲存的服務類別。 +/// public sealed class SettingsService { + /// + /// 設定檔存放路徑 (`%LOCALAPPDATA%\ThermoTray\settings.json`)。 + /// private static readonly string FilePath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "ThermoTray", "settings.json"); + /// + /// 載入應用程式設定。若檔案不存在或毀損,會自動退回使用預設設定值。 + /// + /// 載入的 實例。 public AppSettings Load() { try @@ -20,16 +30,17 @@ public AppSettings Load() } catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException) { - // An unreadable or corrupt preferences file must not stop temperature monitoring. + // 無法讀取或毀損的設定檔不應阻礙溫度檢測運作,退回預設值 return new AppSettings(); } } /// - /// Writes through a temporary file so an interrupted write cannot leave a half-written - /// preferences file behind. Throws or - /// when the profile is locked or read-only. + /// 將應用程式設定寫入檔案。使用暫存檔 (.tmp) 進行原子寫入,避免中斷造成檔案損毀。 /// + /// 要儲存的設定實例。 + /// 當檔案被鎖定時擲出。 + /// 當存取被拒絕時擲出。 public void Save(AppSettings settings) { Directory.CreateDirectory(Path.GetDirectoryName(FilePath)!); @@ -47,6 +58,10 @@ public void Save(AppSettings settings) } } + /// + /// 安全刪除檔案,忽略可能產生的權限或 IO 例外。 + /// + /// 要刪除的檔案路徑。 private static void Delete(string path) { try @@ -55,16 +70,41 @@ private static void Delete(string path) } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - // The leftover temporary file is harmless and the next save overwrites it. + // 殘留的暫存檔不會影響系統,下次儲存時會自動覆蓋 } } } +/// +/// 應用程式設定資料模型。 +/// public sealed class AppSettings { + /// + /// 取得或設定是否隨 Windows 開機自動啟動。 + /// public bool StartWithWindows { get; set; } + /// + /// 取得或設定關閉主視窗時是否縮小至系統工作列圖示而非直接結束程式。預設為 true。 + /// public bool HideWhenClosed { get; set; } = true; + /// + /// 取得或設定 UI 語言設定(如 "zh-TW" 或 "en")。預設為繁體中文。 + /// public string Language { get; set; } = Localizer.DefaultLanguage; + + public bool ShowCpuUsageInTray { get; set; } = true; + + public bool ShowCpuTemperatureInTray { get; set; } = true; + + public Dictionary GpuTraySettings { get; set; } = new(); +} + +public sealed class TrayDisplaySettings +{ + public bool ShowUsage { get; set; } = true; + + public bool ShowTemperature { get; set; } = true; } diff --git a/src/ThermoTray/StartupService.cs b/src/ThermoTray/StartupService.cs index 7221e0b..f8e6a85 100644 --- a/src/ThermoTray/StartupService.cs +++ b/src/ThermoTray/StartupService.cs @@ -8,8 +8,10 @@ namespace ThermoTray; /// -/// Registers automatic startup as a highest-run-level logon task. A plain HKCU\Run -/// entry cannot launch an application whose manifest requires administrator rights. +/// 負責設定 Windows 自動啟動的服務類別。 +/// 由於 ThermoTray 要求最高管理員權限 (requireAdministrator), +/// 一般的 HKCU\Run 登錄檔寫法會在開機時被 Windows 阻擋而無法執行, +/// 因此本服務透過工作排程器 (Task Scheduler) 建立具備最高權限 (HighestAvailable) 的登入觸發工作。 /// public sealed class StartupService { @@ -19,22 +21,26 @@ public sealed class StartupService private const int TimeoutMilliseconds = 10_000; /// - /// Written into the task's Source field and checked at every launch. Bump it whenever - /// changes, so an installation still carrying an older - /// definition is registered again instead of keeping settings this version no longer uses. + /// 寫入排程工作 Source 欄位中的識別標記。每當 XML 結構有重大更新時即遞增, + /// 應用程式開機或啟動時會檢查此標記,若舊版本排程缺少此標記則自動重建更新。 /// internal const string DefinitionMarker = "ThermoTray startup task (definition 2)"; /// - /// True when the logon task exists and carries this version's definition. A task written by an - /// earlier version counts as absent, so startup is registered again: those tasks were created from - /// schtasks switches alone and therefore kept its defaults, which Task Scheduler enforces by - /// terminating ThermoTray. See . + /// 檢查開機啟動排程工作是否存在且為最新版本定義。 + /// 舊版本透過 `schtasks` 命令列產生的預設排程會在改用電池或運行 3 天後強制關閉 ThermoTray,因此需判斷並自動更新。 /// + /// 若排程存在且包含最新標記傳回 true,否則傳回 false。 public bool IsUpToDate() => RunSchtasks($"/Query /TN {TaskName} /XML", out var definition) && definition.Contains(DefinitionMarker, StringComparison.Ordinal); + /// + /// 設定或取消開機自動啟動。 + /// + /// True 表示開啟自動啟動,False 表示關閉自動啟動。 + /// 當無法取得執行檔路徑時擲出。 + /// 當未具備管理員權限或無法建立排程時擲出。 public void SetEnabled(bool enabled) { if (!enabled) @@ -55,15 +61,19 @@ public void SetEnabled(bool enabled) RemoveRunValue(); } + /// + /// 嘗試建立高權限開機排程工作。將 XML 定義檔寫入臨時路徑後透過 `schtasks /Create /XML` 匯入。 + /// + /// ThermoTray 執行檔路徑。 + /// 若建立成功傳回 true,否則傳回 false。 private static bool TryCreateElevatedTask(string executablePath) { - // A random name rather than a fixed one, because the file is read back by an elevated - // Task Scheduler and must not be a path another process can predict and replace first. + // 使用隨機檔名建立臨時 XML 定義檔,防止預測性路徑替換漏洞 var definitionPath = Path.Combine(Path.GetTempPath(), $"ThermoTray-{Path.GetRandomFileName()}.xml"); try { - // schtasks reads the definition as UTF-16 and rejects the file outright otherwise. + // schtasks 要求 XML 定義檔必須為 UTF-16 編碼,否則拒絕解析 File.WriteAllText(definitionPath, BuildTaskDefinition(executablePath, GetCurrentUserId()), Encoding.Unicode); return RunSchtasks($"/Create /TN {TaskName} /XML \"{definitionPath}\" /F", out _); } @@ -80,14 +90,13 @@ or UnauthorizedAccessException } /// - /// The full definition ThermoTray registers, rather than the schtasks /Create switches that - /// wrote earlier versions. The settings that matter here have no switch, and the defaults schtasks - /// leaves in their place are wrong for an application meant to sit in the notification area - /// indefinitely: Task Scheduler stops the task after three days of uptime, refuses to start it while - /// a laptop is on battery, and hard-terminates it the moment the machine switches to battery power. - /// A tray icon that disappears when the charger is unplugged is indistinguishable from a crash, and - /// because the process is killed rather than faulted it leaves nothing in the event log to explain it. + /// 建立完整且正確的排程工作 XML 定義字串。 + /// 明確將 `DisallowStartIfOnBatteries`、`StopIfGoingOnBatteries` 與 `ExecutionTimeLimit` 設定為無限制/不中斷, + /// 避免預設行為導致筆記型電腦在拔除電源或執行超過 72 小時後被 Task Scheduler 強制結束處理程序。 /// + /// 執行檔路徑。 + /// 當前使用者 SID 或帳戶名稱。 + /// UTF-16 格式之 XML 排程定義內文。 internal static string BuildTaskDefinition(string executablePath, string userId) => $""" @@ -138,9 +147,9 @@ internal static string BuildTaskDefinition(string executablePath, string userId) """; /// - /// The account's SID rather than its name, so the definition survives a renamed account and does - /// not depend on the domain form this machine happens to report. + /// 取得當前使用者的 SID 字串(如 S-1-5-21...)。優先使用 SID 可防止使用者變更帳戶名稱後排程失效。 /// + /// SID 字串或 DOMAIN\User 格式字串。 private static string GetCurrentUserId() { try @@ -153,14 +162,20 @@ private static string GetCurrentUserId() } catch (SecurityException) { - // Fall through to the account name, which Task Scheduler also accepts. + // 退回使用 Domain\User 名稱 } return $"{Environment.UserDomainName}\\{Environment.UserName}"; } + /// + /// 對 XML 敏感字元進行安全轉義。 + /// private static string Escape(string value) => SecurityElement.Escape(value) ?? string.Empty; + /// + /// 清理產生的臨時 XML 定義檔。 + /// private static void TryDeleteDefinition(string definitionPath) { try @@ -169,12 +184,21 @@ private static void TryDeleteDefinition(string definitionPath) } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) { - // The task is already registered; a leftover temporary file is not worth failing over. + // 臨時檔殘留不會造成致命影響,忽略刪除失敗 } } + /// + /// 刪除既有的 ThermoTray 排程工作。 + /// private static void RemoveScheduledTask() => RunSchtasks($"/Delete /TN {TaskName} /F", out _); + /// + /// 呼叫系統 `schtasks.exe` 命令,並同步清空標準輸出與錯誤串流,防止管線緩衝區滿載造成死鎖。 + /// + /// 命令列引數。 + /// 輸出的標準文字內容。 + /// 若命令成功執行且 ExitCode 為 0 傳回 true,否則傳回 false。 private static bool RunSchtasks(string arguments, out string standardOutput) { standardOutput = string.Empty; @@ -194,8 +218,7 @@ private static bool RunSchtasks(string arguments, out string standardOutput) return false; } - // Both pipes must be drained concurrently. Leaving either one unread lets schtasks block - // forever on a full pipe buffer instead of exiting. + // 同步讀取 StandardOutput 與 StandardError,避免管線堵塞 var outputRead = process.StandardOutput.ReadToEndAsync(); var errorRead = process.StandardError.ReadToEndAsync(); @@ -205,10 +228,9 @@ private static bool RunSchtasks(string arguments, out string standardOutput) return false; } - // The parameterless overload also waits for the redirected streams to finish. + // 等待重定向串流讀取完畢 process.WaitForExit(); - // Observed so a failed read cannot resurface later as an unobserved task exception. _ = errorRead.Exception; if (outputRead.Exception is null) { @@ -223,7 +245,9 @@ private static bool RunSchtasks(string arguments, out string standardOutput) } } - /// Stops a schtasks call that outlived its timeout so it cannot linger as an orphan. + /// + /// 強制結束超時的 schtasks 處理程序。 + /// private static void Terminate(Process process) { try @@ -232,10 +256,13 @@ private static void Terminate(Process process) } catch (Exception) { - // The process already exited or cannot be stopped; either way the call has failed. + // 處理程序已退出或無法中止 } } + /// + /// 清除舊有的 `HKCU\Run` 登錄檔數值(移轉至 Task Scheduler 排程工作)。 + /// private static void RemoveRunValue() { using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true); diff --git a/src/ThermoTray/TemperatureFormatter.cs b/src/ThermoTray/TemperatureFormatter.cs index ea4d0c2..75b6edb 100644 --- a/src/ThermoTray/TemperatureFormatter.cs +++ b/src/ThermoTray/TemperatureFormatter.cs @@ -2,16 +2,22 @@ namespace ThermoTray; -/// Turns readings into the short text drawn inside a tray icon. +/// +/// 將溫度讀值轉換為在系統工作列圖示中繪製的簡短純數字字串。 +/// internal static class TemperatureFormatter { - /// Shown instead of digits while no trustworthy value exists. + /// + /// 當尚無可靠溫度讀值時顯示的占位字串。 + /// internal const string TrayPlaceholder = "--"; /// - /// Derives the whole-degree digits from the reading itself. Parsing them back out of the - /// displayed text would break wherever the current culture writes a comma decimal separator. + /// 從溫度讀值中擷取整數度數。 + /// 不直接解析格式化後的 UI 文字,以避免不同語系(如以逗號作為小數點 separator 的語系)造成解析錯誤。 /// + /// 溫度讀值。 + /// 代表整數度數的字串(如 "45"),若無讀值則傳回占位符 "--"。 internal static string ToTrayDigits(TemperatureReading reading) => reading.Celsius is decimal celsius ? decimal.Truncate(celsius).ToString(CultureInfo.InvariantCulture) : TrayPlaceholder; diff --git a/src/ThermoTray/TemperatureReading.cs b/src/ThermoTray/TemperatureReading.cs index 067c9ff..7f74d69 100644 --- a/src/ThermoTray/TemperatureReading.cs +++ b/src/ThermoTray/TemperatureReading.cs @@ -1,21 +1,68 @@ namespace ThermoTray; -public readonly record struct TemperatureReading(decimal? Celsius, string Source) +/// +/// 表示單一硬體感測器的溫度讀值與來源名稱。 +/// +/// 攝氏溫度數值(若無法取得則為 null)。 +/// 提供此溫度的硬體感測器名稱。 +public readonly record struct TemperatureReading(decimal? Celsius, string Source, string DeviceName = "") { - public static TemperatureReading Unavailable { get; } = new(null, string.Empty); + /// + /// 代表無法存取或無效的溫度讀值。 + /// + public static TemperatureReading Unavailable { get; } = new(null, string.Empty, string.Empty); + /// + /// 取得一個值,表示溫度讀值是否可用且有效。 + /// public bool IsAvailable => Celsius.HasValue; } -public readonly record struct UtilizationReading(decimal? Percent, string Source) +/// +/// 表示單一硬體元件的使用率讀值與來源名稱。 +/// +/// 使用率百分比數值(若無法取得則為 null)。 +/// 提供此使用率的硬體感測器名稱。 +public readonly record struct UtilizationReading(decimal? Percent, string Source, string DeviceName = "") { - public static UtilizationReading Unavailable { get; } = new(null, string.Empty); + /// + /// 代表無法存取或無效的使用率讀值。 + /// + public static UtilizationReading Unavailable { get; } = new(null, string.Empty, string.Empty); + /// + /// 取得一個值,表示使用率讀值是否可用且有效。 + /// public bool IsAvailable => Percent.HasValue; } +/// +/// 表示一張實體 GPU 的完整讀值。溫度與使用率都屬於同一個硬體裝置, +/// 不會再從不同 GPU 各自挑選後混合顯示。 +/// +/// 拓撲掃描時產生的穩定裝置識別碼。 +/// LibreHardwareMonitor 回報的 GPU 裝置名稱。 +/// 該 GPU 的溫度讀值。 +/// 該 GPU 的使用率讀值。 +public readonly record struct GpuReading( + string Id, + string Name, + TemperatureReading Temperature, + UtilizationReading Usage) +{ + /// + /// 取得一個值,表示該 GPU 至少有一種可用的即時讀值。 + /// + public bool IsAvailable => Temperature.IsAvailable || Usage.IsAvailable; +} + +/// +/// 包含 CPU 與所有 GPU 溫度及使用率的完整硬體快照結構。 +/// +/// CPU 溫度讀值。 +/// CPU 使用率讀值。 +/// 每張 GPU 各自配對的溫度與使用率讀值。 public readonly record struct HardwareSnapshot( TemperatureReading CpuTemperature, - TemperatureReading GpuTemperature, UtilizationReading CpuUsage, - UtilizationReading GpuUsage); + IReadOnlyList Gpus); diff --git a/src/ThermoTray/TrayIconLayout.cs b/src/ThermoTray/TrayIconLayout.cs index 5ce0c5d..cb0b45b 100644 --- a/src/ThermoTray/TrayIconLayout.cs +++ b/src/ThermoTray/TrayIconLayout.cs @@ -3,32 +3,38 @@ namespace ThermoTray; /// -/// Works out how big a tray icon should be drawn and where its two lines of digits go. It is separate -/// from the drawing so the geometry can be tested on its own. +/// 計算系統工作列圖示繪製區域與兩行數字(使用率與溫度)位置的佈局計算類別。 +/// 與實際繪製邏輯分離,便於進行單元測試與邊界幾何驗證。 /// internal static class TrayIconLayout { - /// Used when Windows reports an implausible notification-area icon size. + /// + /// 當 Windows 回報不合理的系統匣圖示尺寸時採用的最小圖示尺寸(像素)。 + /// internal const int MinimumIconSize = 16; /// - /// The icon is drawn at this multiple of its final size and then resampled down. Filling a glyph - /// outline straight into a 16-pixel bitmap rounds every stroke to whole pixels and leaves the digits - /// visibly uneven, whereas drawing twice as large and averaging gives each stroke its true weight. + /// 超高採樣倍率(Supersampling)。圖示以最終尺寸的倍數進行高解析度繪製後再縮放, + /// 可顯著改善小圖示(如 16x16 像素)字型筆劃的平滑度與反鋸齒效果。 /// internal const int SupersampleFactor = 2; + /// + /// 取得超高採樣後的畫布尺寸。 + /// + /// 原始圖示尺寸。 + /// 畫布總像素尺寸。 internal static int GetCanvasSize(int iconSize) => iconSize * SupersampleFactor; /// - /// Splits the canvas into an upper line for utilization and a lower one for temperature. Both lines - /// stay inside the canvas and never touch, which is what keeps the digits whole: text drawn into a - /// rectangle that reaches past the bitmap, or that is shorter than the glyphs, is silently cut off. + /// 將畫布分割為上方(使用率)與下方(溫度)兩行文字區域。 + /// 確保兩行區域完全在畫布範圍內且不重疊,防止繪製時字體遭到裁切。 /// + /// 畫布總尺寸。 + /// 包含使用率區域與溫度區域的矩形邊界元組。 internal static (RectangleF Usage, RectangleF Temperature) GetLines(int canvasSize) { - // One pixel at the tray's own scale: enough to separate the lines and hold the digits off the - // edge, without spending height that the digits themselves could use. + // 依照畫布尺寸計算 1 像素比例的邊界留白,區隔上下行並保持邊緣適當距離 var margin = Math.Max(1f, (float)canvasSize / 64f); var lineHeight = (canvasSize - margin) / 2f; var lineWidth = canvasSize - (2f * margin); diff --git a/src/ThermoTray/TrayIconService.cs b/src/ThermoTray/TrayIconService.cs index 4e9d2f1..f128cc7 100644 --- a/src/ThermoTray/TrayIconService.cs +++ b/src/ThermoTray/TrayIconService.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Collections.Specialized; using System.Drawing; using System.Drawing.Drawing2D; using System.Runtime.InteropServices; @@ -6,22 +7,25 @@ namespace ThermoTray; +/// +/// 負責管理 Windows 系統工作列圖示 (NotifyIcon) 之繪製、更新、選單與事件處理的服務類別。 +/// 為 CPU 與 GPU 分別提供獨立的雙行(使用率與溫度)圖示。 +/// public sealed class TrayIconService : IDisposable { /// - /// Em size for the glyph outlines. The outline is scaled to whatever line it has to fill, so this - /// only decides how much detail that outline carries into the scaling. + /// 字型向量外框的 Em 大小。外框會被自動縮放以精確填滿文字繪製區域。 /// private const float OutlineEmSize = 64f; private static readonly FontFamily IconFontFamily = new("Segoe UI"); - /// Typographic layout adds no padding around the glyphs, so the outline is the ink itself. + /// + /// 使用 GenericTypographic 格式,取消字體預設內距,以真實字形外框作為繪製邊界。 + /// private static readonly StringFormat OutlineFormat = StringFormat.GenericTypographic; - // Every drawing object above and below owns a GDI+ handle and outlives each icon drawn with it. An - // icon is redrawn whenever its digits change, so creating them per redraw would churn handles all - // day for a fixed and very small set of objects. Only the UI thread touches them. + // GDI+ 畫筆快取物件,避免每次紅繪時重複建立與銷毀造成控制代碼浪費 private static readonly SolidBrush UsageBrush = new(Color.FromArgb(245, 247, 250)); private static readonly SolidBrush CpuBrush = new(Color.FromArgb(85, 214, 190)); private static readonly SolidBrush GpuBrush = new(Color.FromArgb(116, 176, 255)); @@ -29,26 +33,34 @@ public sealed class TrayIconService : IDisposable private readonly MainViewModel _viewModel; private readonly Action _showMainWindow; private readonly Action _exitApplication; - private readonly Forms.NotifyIcon _cpuNotifyIcon; - private readonly Forms.NotifyIcon _gpuNotifyIcon; + private Forms.NotifyIcon _cpuNotifyIcon; + private readonly List _gpuIcons = []; private string? _cpuIconKey; - private string? _gpuIconKey; private bool _disposed; + /// + /// 初始化 TrayIconService 的新實例。 + /// + /// 提供狀態資料的 View Model。 + /// 顯示主視窗的回調委派。 + /// 結束應用程式的回調委派。 public TrayIconService(MainViewModel viewModel, Action showMainWindow, Action exitApplication) { _viewModel = viewModel; _showMainWindow = showMainWindow; _exitApplication = exitApplication; - // The notification area puts the most recently registered icon leftmost, so register - // GPU first to end up with CPU on the left and GPU on the right. - _gpuNotifyIcon = CreateNotifyIcon(); + _cpuNotifyIcon = CreateNotifyIcon(); _viewModel.PropertyChanged += OnViewModelPropertyChanged; + ((INotifyCollectionChanged)_viewModel.Gpus).CollectionChanged += OnGpuCollectionChanged; + RebuildGpuIcons(restoreCpuOrder: false); UpdateCpuIcon(); - UpdateGpuIcon(); + UpdateGpuIcons(); } + /// + /// 建立 NotifyIcon 控制項並綁定雙擊事件與快顯功能表。 + /// private Forms.NotifyIcon CreateNotifyIcon() { var icon = new Forms.NotifyIcon @@ -60,6 +72,9 @@ private Forms.NotifyIcon CreateNotifyIcon() return icon; } + /// + /// 建立圖示右鍵快顯功能表 (Open / Exit)。 + /// private Forms.ContextMenuStrip BuildMenu() { var menu = new Forms.ContextMenuStrip(); @@ -69,35 +84,106 @@ private Forms.ContextMenuStrip BuildMenu() return menu; } + /// + /// 監聽 ViewModel 屬性變更,當讀值或語言改變時更新對應圖示。 + /// private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e) { - // A null or empty name is the conventional "everything changed" signal. var everythingChanged = string.IsNullOrEmpty(e.PropertyName); if (everythingChanged || e.PropertyName is nameof(MainViewModel.CpuTemperature) or nameof(MainViewModel.CpuTrayDigits) or nameof(MainViewModel.CpuUsage) - or nameof(MainViewModel.CpuUsageTrayDigits)) + or nameof(MainViewModel.CpuUsageTrayDigits) + or nameof(MainViewModel.CpuDeviceName) + or nameof(MainViewModel.ShowCpuUsageInTray) + or nameof(MainViewModel.ShowCpuTemperatureInTray)) { UpdateCpuIcon(); } - if (everythingChanged || e.PropertyName is nameof(MainViewModel.GpuTemperature) - or nameof(MainViewModel.GpuTrayDigits) - or nameof(MainViewModel.GpuUsage) - or nameof(MainViewModel.GpuUsageTrayDigits) - or nameof(MainViewModel.IsGpuPresent)) + if (everythingChanged || e.PropertyName is nameof(MainViewModel.IsGpuPresent)) { - UpdateGpuIcon(); + UpdateGpuIcons(); } if (everythingChanged) { ReplaceMenu(_cpuNotifyIcon); - ReplaceMenu(_gpuNotifyIcon); + foreach (var entry in _gpuIcons) + { + ReplaceMenu(entry.NotifyIcon); + } } } + /// + /// 當 GPU 清單增減時重建對應的獨立系統匣圖示。 + /// + private void OnGpuCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) + { + if (_disposed) + { + return; + } + + RebuildGpuIcons(restoreCpuOrder: true); + } + + /// + /// 當單一 GPU 的使用率、溫度或語言標籤更新時刷新該 GPU 圖示。 + /// + private void OnGpuPropertyChanged(object? sender, PropertyChangedEventArgs e) + { + if (_disposed || sender is not GpuViewModel gpu) + { + return; + } + + foreach (var entry in _gpuIcons) + { + if (ReferenceEquals(entry.Gpu, gpu)) + { + UpdateGpuIcon(entry); + return; + } + } + } + + /// + /// 依目前 GPU 清單建立每張 GPU 的獨立圖示。 + /// + private void RebuildGpuIcons(bool restoreCpuOrder) + { + foreach (var entry in _gpuIcons) + { + entry.Gpu.PropertyChanged -= OnGpuPropertyChanged; + DisposeNotifyIcon(entry.NotifyIcon); + } + + _gpuIcons.Clear(); + foreach (var gpu in _viewModel.Gpus) + { + gpu.PropertyChanged += OnGpuPropertyChanged; + _gpuIcons.Add(new GpuTrayIcon(gpu, CreateNotifyIcon())); + } + + if (restoreCpuOrder && _gpuIcons.Count > 0) + { + // Windows 通常把較晚註冊的圖示放在左側;GPU 清單初次出現後重新註冊 CPU, + // 讓預設順序維持 CPU 在左、各張 GPU 依序在右(使用者手動排列仍由 Windows 記憶)。 + var oldCpuIcon = _cpuNotifyIcon; + _cpuNotifyIcon = CreateNotifyIcon(); + DisposeNotifyIcon(oldCpuIcon); + } + + UpdateCpuIcon(); + UpdateGpuIcons(); + } + + /// + /// 更新右鍵功能表項目(如切換語言時更新文字)。 + /// private void ReplaceMenu(Forms.NotifyIcon notifyIcon) { var oldMenu = notifyIcon.ContextMenuStrip; @@ -105,6 +191,9 @@ private void ReplaceMenu(Forms.NotifyIcon notifyIcon) oldMenu?.Dispose(); } + /// + /// 更新 CPU 圖示與 Tooltip 提示文字。 + /// private void UpdateCpuIcon() { if (_disposed) @@ -112,54 +201,124 @@ private void UpdateCpuIcon() return; } + var showUsage = _viewModel.ShowCpuUsageInTray; + var showTemperature = _viewModel.ShowCpuTemperatureInTray; + _cpuNotifyIcon.Visible = showUsage || showTemperature; + if (!_cpuNotifyIcon.Visible) + { + return; + } + var iconSize = GetTrayIconSize(); - var usageDigits = _viewModel.CpuUsageTrayDigits; - var temperatureDigits = _viewModel.CpuTrayDigits; + var usageDigits = showUsage ? _viewModel.CpuUsageTrayDigits : string.Empty; + var temperatureDigits = showTemperature ? _viewModel.CpuTrayDigits : string.Empty; - // The size belongs in the key so that a change of display scale redraws at the new size. - var iconKey = $"{iconSize}|{usageDigits}|{temperatureDigits}"; + var iconKey = $"{iconSize}|{showUsage}|{usageDigits}|{showTemperature}|{temperatureDigits}"; if (!string.Equals(iconKey, _cpuIconKey, StringComparison.Ordinal)) { ReplaceIcon(_cpuNotifyIcon, usageDigits, temperatureDigits, CpuBrush, iconSize); _cpuIconKey = iconKey; } - _cpuNotifyIcon.Text = BuildTooltip("CpuUsage", _viewModel.CpuUsage, "CpuTemperature", _viewModel.CpuTemperature); + _cpuNotifyIcon.Text = BuildTooltip( + _viewModel.CpuDeviceName, + showUsage, + _viewModel.T["CpuUsage"], + _viewModel.CpuUsage, + showTemperature, + _viewModel.T["CpuTemperature"], + _viewModel.CpuTemperature); } - private void UpdateGpuIcon() + /// + /// 更新所有 GPU 圖示與各自的 Tooltip 提示文字。 + /// + private void UpdateGpuIcons() { if (_disposed) { return; } - _gpuNotifyIcon.Visible = _viewModel.IsGpuPresent; - if (!_viewModel.IsGpuPresent) + foreach (var entry in _gpuIcons) + { + UpdateGpuIcon(entry); + } + } + + /// + /// 更新單一 GPU 圖示與 Tooltip 提示文字。 + /// + private void UpdateGpuIcon(GpuTrayIcon entry) + { + var showUsage = entry.Gpu.ShowUsageInTray; + var showTemperature = entry.Gpu.ShowTemperatureInTray; + entry.NotifyIcon.Visible = _viewModel.IsGpuPresent && (showUsage || showTemperature); + if (!entry.NotifyIcon.Visible) { return; } var iconSize = GetTrayIconSize(); - var usageDigits = _viewModel.GpuUsageTrayDigits; - var temperatureDigits = _viewModel.GpuTrayDigits; + var usageDigits = showUsage ? entry.Gpu.UsageTrayDigits : string.Empty; + var temperatureDigits = showTemperature ? entry.Gpu.TemperatureTrayDigits : string.Empty; - var iconKey = $"{iconSize}|{usageDigits}|{temperatureDigits}"; - if (!string.Equals(iconKey, _gpuIconKey, StringComparison.Ordinal)) + var iconKey = $"{iconSize}|{showUsage}|{usageDigits}|{showTemperature}|{temperatureDigits}"; + if (!string.Equals(iconKey, entry.IconKey, StringComparison.Ordinal)) { - ReplaceIcon(_gpuNotifyIcon, usageDigits, temperatureDigits, GpuBrush, iconSize); - _gpuIconKey = iconKey; + ReplaceIcon(entry.NotifyIcon, usageDigits, temperatureDigits, GpuBrush, iconSize); + entry.IconKey = iconKey; } - _gpuNotifyIcon.Text = BuildTooltip("GpuUsage", _viewModel.GpuUsage, "GpuTemperature", _viewModel.GpuTemperature); + entry.NotifyIcon.Text = BuildGpuTooltip(entry.Gpu, showUsage, showTemperature); + } + + /// + /// 組合圖示提示(Tooltip)文字。 + /// + private static string BuildTooltip( + string deviceName, + bool showUsage, + string usageLabel, + string usage, + bool showTemperature, + string temperatureLabel, + string temperature) + { + var tooltip = string.IsNullOrWhiteSpace(deviceName) ? "CPU" : deviceName; + if (showUsage) + { + tooltip += $" | {usageLabel}: {usage}"; + } + + if (showTemperature) + { + tooltip += $" | {temperatureLabel}: {temperature}"; + } + + return TrimTooltip(tooltip); + } + + /// + /// 組合包含 GPU 序號與裝置名稱的獨立 Tooltip,避免雙 GPU 時無法辨識圖示所屬裝置。 + /// + private static string BuildGpuTooltip(GpuViewModel gpu, bool showUsage, bool showTemperature) + { + return BuildTooltip( + $"{gpu.DisplayName}: {gpu.DeviceName}", + showUsage, + gpu.UsageLabel, + gpu.Usage, + showTemperature, + gpu.TemperatureLabel, + gpu.Temperature); } - private string BuildTooltip(string usageLabel, string usage, string temperatureLabel, string temperature) => - $"{_viewModel.T[usageLabel]}: {usage} | {_viewModel.T[temperatureLabel]}: {temperature}"; + private static string TrimTooltip(string tooltip) => + tooltip.Length <= 127 ? tooltip : $"{tooltip[..124]}..."; /// - /// The notification area's own icon metric, which follows the display scale. Drawing for this size - /// rather than a fixed one is what keeps Windows from resampling the finished icon a second time. + /// 依據系統縮放比例取得工作列圖示的目標像素尺寸。 /// private static int GetTrayIconSize() { @@ -168,6 +327,9 @@ private static int GetTrayIconSize() return side >= TrayIconLayout.MinimumIconSize ? side : TrayIconLayout.MinimumIconSize; } + /// + /// 重新生成 Icon 並替換既有圖示,並妥善釋放舊 Icon 物件。 + /// private static void ReplaceIcon( Forms.NotifyIcon notifyIcon, string usageDigits, @@ -180,17 +342,29 @@ private static void ReplaceIcon( oldIcon?.Dispose(); } + /// + /// 建立包含位元圖與 Win32 Icon 控制代碼的硬體圖示,確保原生 HICON 被確實銷毀。 + /// private static Icon CreateHardwareIcon(string usageDigits, string temperatureDigits, Brush temperatureBrush, int iconSize) { using var bitmap = CreateIconBitmap(usageDigits, temperatureDigits, temperatureBrush, iconSize); var iconHandle = bitmap.GetHicon(); - using var temporaryIcon = Icon.FromHandle(iconHandle); - var icon = (Icon)temporaryIcon.Clone(); - _ = DestroyIcon(iconHandle); - return icon; + + try + { + using var temporaryIcon = Icon.FromHandle(iconHandle); + return (Icon)temporaryIcon.Clone(); + } + finally + { + // Clone 建立獨立 Icon 物件後,必須強制銷毀原生的 Win32 HICON控制代碼,防止記憶體與控制代碼洩漏 + _ = DestroyIcon(iconHandle); + } } - /// Draws unitless digits, utilization above temperature, as large as the icon allows. + /// + /// 繪製包含使用率(上方)與溫度(下方)的雙行文字 Icon 位元圖。 + /// internal static Bitmap CreateIconBitmap(string usageDigits, string temperatureDigits, Brush temperatureBrush, int iconSize) { var canvasSize = TrayIconLayout.GetCanvasSize(iconSize); @@ -218,17 +392,13 @@ internal static Bitmap CreateIconBitmap(string usageDigits, string temperatureDi } /// - /// Scales the glyph outline itself so the digits fill their line exactly. Sizing a font instead can - /// only bound the advance width, which says nothing about how tall the digits are or whether they - /// still fit once the smallest allowed size is reached, and anything that does not fit is cut off - /// rather than shrunk: a reading of 100 was drawn, and read, as 10. + /// 精確縮放向量字形路徑 (GraphicsPath),使其完美填滿目標行矩形範圍,防止數字超出邊界或被裁切。 /// private static void DrawDigits(Graphics graphics, string digits, Brush brush, RectangleF line) { using var path = new GraphicsPath(); path.AddString(digits, IconFontFamily, (int)FontStyle.Bold, OutlineEmSize, PointF.Empty, OutlineFormat); - // A string of nothing but spaces, and a font missing every glyph, both produce an empty outline. var ink = path.GetBounds(); if (ink.Width <= 0f || ink.Height <= 0f) { @@ -245,6 +415,9 @@ private static void DrawDigits(Graphics graphics, string digits, Brush brush, Re graphics.FillPath(brush, path); } + /// + /// 釋放系統匣圖示資源與解綁事件。 + /// public void Dispose() { if (_disposed) @@ -254,10 +427,20 @@ public void Dispose() _disposed = true; _viewModel.PropertyChanged -= OnViewModelPropertyChanged; + ((INotifyCollectionChanged)_viewModel.Gpus).CollectionChanged -= OnGpuCollectionChanged; DisposeNotifyIcon(_cpuNotifyIcon); - DisposeNotifyIcon(_gpuNotifyIcon); + foreach (var entry in _gpuIcons) + { + entry.Gpu.PropertyChanged -= OnGpuPropertyChanged; + DisposeNotifyIcon(entry.NotifyIcon); + } + + _gpuIcons.Clear(); } + /// + /// 隱藏並處置指定的 NotifyIcon 物件。 + /// private static void DisposeNotifyIcon(Forms.NotifyIcon notifyIcon) { notifyIcon.Visible = false; @@ -266,6 +449,18 @@ private static void DisposeNotifyIcon(Forms.NotifyIcon notifyIcon) notifyIcon.Dispose(); } + /// + /// 保存單一 GPU 的 ViewModel、NotifyIcon 與目前圖示快取鍵。 + /// + private sealed class GpuTrayIcon(GpuViewModel gpu, Forms.NotifyIcon notifyIcon) + { + public GpuViewModel Gpu { get; } = gpu; + + public Forms.NotifyIcon NotifyIcon { get; } = notifyIcon; + + public string? IconKey { get; set; } + } + [DllImport("user32.dll", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool DestroyIcon(IntPtr hIcon); diff --git a/src/ThermoTray/UtilizationFormatter.cs b/src/ThermoTray/UtilizationFormatter.cs index 27995af..a295b74 100644 --- a/src/ThermoTray/UtilizationFormatter.cs +++ b/src/ThermoTray/UtilizationFormatter.cs @@ -2,15 +2,21 @@ namespace ThermoTray; -/// Turns utilization readings into short display text and tray-icon digits. +/// +/// 將硬體使用率讀值轉換為在系統工作列圖示中繪製的簡短純數字字串。 +/// internal static class UtilizationFormatter { - /// Shown instead of digits while no trustworthy utilization value exists. + /// + /// 當尚無可靠使用率讀值時顯示的占位字串。 + /// internal const string TrayPlaceholder = "--"; /// - /// Derives whole-percentage digits from the reading itself, independent of the current culture. + /// 從使用率讀值中擷取整數百分比數字。獨立於目前語系設定。 /// + /// 使用率讀值。 + /// 代表整數百分比的字串(如 "85"),若無讀值則傳回占位符 "--"。 internal static string ToTrayDigits(UtilizationReading reading) => reading.Percent is decimal percent ? decimal.Truncate(percent).ToString(CultureInfo.InvariantCulture) : TrayPlaceholder; diff --git a/tests/ThermoTray.Tests/InstanceCoordinationTests.cs b/tests/ThermoTray.Tests/InstanceCoordinationTests.cs index 8bd4b8e..f2d472b 100644 --- a/tests/ThermoTray.Tests/InstanceCoordinationTests.cs +++ b/tests/ThermoTray.Tests/InstanceCoordinationTests.cs @@ -4,44 +4,59 @@ namespace ThermoTray.Tests; +/// +/// 版本比較與處置決策單元測試。 +/// public sealed class InstanceDecisionTests { - /// The ordinary second launch: same build, so it just raises the window that exists. + /// + /// 驗證相同版本時採無聲 Handover (ShowRunning)。 + /// [Fact] public void Decide_HandsOverToTheSameVersion() => Assert.Equal( InstanceAction.ShowRunning, InstanceProtocol.Decide(new Version(1, 1, 3, 0), new Version(1, 1, 3, 0))); - /// Launching an upgrade has to be able to take the tray from the version it replaces. + /// + /// 驗證新版啟動且已存在舊版時,建議替換舊版 (ReplaceRunning)。 + /// [Fact] public void Decide_OffersToReplaceAnOlderVersion() => Assert.Equal( InstanceAction.ReplaceRunning, InstanceProtocol.Decide(new Version(1, 1, 2, 0), new Version(1, 1, 3, 0))); - /// A stale shortcut must not silently downgrade what the user is running. + /// + /// 驗證舊版啟動且已存在新版時,建議保留新版並顯示新版視窗 (ShowNewerRunning)。 + /// [Fact] public void Decide_KeepsTheNewerRunningVersion() => Assert.Equal( InstanceAction.ShowNewerRunning, InstanceProtocol.Decide(new Version(1, 1, 3, 0), new Version(1, 1, 2, 0))); - /// Only three components name a release, so the build's fourth one is not a difference. + /// + /// 驗證比較版本時忽略第四位的 Revision。 + /// [Fact] public void Decide_IgnoresTheFourthComponent() => Assert.Equal( InstanceAction.ShowRunning, InstanceProtocol.Decide(new Version(1, 1, 3), new Version(1, 1, 3, 0))); - /// A numeric comparison, not a textual one: 1.1.10 is newer than 1.1.9, not older. + /// + /// 驗證版號為數值比較(如 1.1.10 新於 1.1.9)。 + /// [Fact] public void Decide_ComparesNumerically() => Assert.Equal( InstanceAction.ReplaceRunning, InstanceProtocol.Decide(new Version(1, 1, 9, 0), new Version(1, 1, 10, 0))); - /// With nothing to compare, taking the tray from a running instance is not justified. + /// + /// 驗證無法解析或其中一方版本為 null 時傳回安全的 ShowRunning。 + /// [Fact] public void Decide_HandsOverWhenAVersionIsUnknown() { @@ -50,8 +65,14 @@ public void Decide_HandsOverWhenAVersionIsUnknown() } } +/// +/// 身份識別字串格式化與解析單元測試。 +/// public sealed class InstanceIdentityTests { + /// + /// 驗證身份字串序列化與反序列化一致。 + /// [Fact] public void Identity_SurvivesTheRoundTrip() { @@ -62,7 +83,9 @@ public void Identity_SurvivesTheRoundTrip() Assert.Equal(4242, processId); } - /// An assembly with no version still has to identify itself as ThermoTray. + /// + /// 驗證版本號為 null 時仍可產出與解析包含 UnknownVersion 的身份字串。 + /// [Fact] public void Identity_CarriesAnUnknownVersion() { @@ -71,7 +94,9 @@ public void Identity_CarriesAnUnknownVersion() Assert.Equal(7, processId); } - /// Anything else that opens the pipe must not be mistaken for a running instance. + /// + /// 驗證非規範格式字串被拒絕。 + /// [Theory] [InlineData(null)] [InlineData("")] @@ -85,14 +110,16 @@ public void Identity_RejectsAnythingElse(string? line) => Assert.False(InstanceProtocol.TryParseIdentity(line, out _, out _)); } +/// +/// 具名管道實體通訊 ( / ) 單元測試。 +/// public sealed class InstanceHandoverTests { private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan CallbackTimeout = TimeSpan.FromSeconds(5); /// - /// The whole exchange over a real named pipe: a launch learns who is running and asks for that - /// instance's window. Everything the startup path decides depends on this answer being real. + /// 驗證客戶端能成功連接管道伺服器、讀取執行中版本並發送 SHOW 請求。 /// [Fact] public void AConnectingLaunchLearnsTheRunningVersionAndRaisesItsWindow() @@ -111,7 +138,9 @@ public void AConnectingLaunchLearnsTheRunningVersionAndRaisesItsWindow() Assert.True(shown.Wait(CallbackTimeout)); } - /// The upgrade path: the older instance is asked to leave and acknowledges before it does. + /// + /// 驗證發送 EXIT 請求時,伺服器發送 OK 確認後觸發退出委派。 + /// [Fact] public void AReplacingLaunchIsAcknowledgedBeforeTheRunningInstanceExits() { @@ -128,8 +157,7 @@ public void AReplacingLaunchIsAcknowledgedBeforeTheRunningInstanceExits() } /// - /// Each exchange closes its connection, so the listener has to come back for the next launch. - /// A one-shot server would leave every later launch unable to reach the running instance. + /// 驗證管道伺服器在單次通訊結束後能持續監聽回應後續請求。 /// [Fact] public void TheRunningInstanceKeepsAnsweringLaterLaunches() @@ -158,14 +186,15 @@ public void TheRunningInstanceKeepsAnsweringLaterLaunches() } /// - /// Nothing is listening under this name, which is also what a build older than the pipe looks - /// like. The caller has to be told so it can fall back instead of waiting forever. + /// 驗證連接不存在的管道時傳回 null。 /// [Fact] public void AnUnreachableInstanceReportsNoClient() => Assert.Null(InstanceClient.TryConnect(UniquePipeName(), TimeSpan.FromMilliseconds(500))); - /// Shutdown has to release the name, or the replacing instance would never be reachable. + /// + /// 驗證處置 (Dispose) 伺服器後,新連線無法連通。 + /// [Fact] public void ADisposedServerStopsAnsweringConnections() { @@ -183,7 +212,6 @@ private static TestServer StartServer(Version version, Action shown, Action exit return new TestServer(pipeName, server); } - /// A pipe name is machine wide, so tests must not collide with each other or a real instance. private static string UniquePipeName() => "ThermoTray.Tests." + Guid.NewGuid().ToString("N"); private sealed class TestServer(string pipeName, InstanceServer server) : IDisposable @@ -194,11 +222,13 @@ private sealed class TestServer(string pipeName, InstanceServer server) : IDispo } } +/// +/// 驗證 Inno Setup 安裝腳本與程式碼間全域互斥鎖名稱一致性的單元測試。 +/// public sealed class SetupMutexContractTests { /// - /// The installer can only see a running ThermoTray through this exact name. A rename on either - /// side would silently bring back overwriting a locked executable. + /// 驗證 `installer/ThermoTray.iss` 中的 AppMutex 數值與程式碼中的 `InstanceCoordinator.SetupMutexName` 完全吻合。 /// [Fact] public void TheInstallerWaitsOnTheMutexTheApplicationCreates() diff --git a/tests/ThermoTray.Tests/LocalizerTests.cs b/tests/ThermoTray.Tests/LocalizerTests.cs index 2f086a2..16a2e9a 100644 --- a/tests/ThermoTray.Tests/LocalizerTests.cs +++ b/tests/ThermoTray.Tests/LocalizerTests.cs @@ -2,20 +2,35 @@ namespace ThermoTray.Tests; +/// +/// 多語系服務單元測試。 +/// public sealed class LocalizerTests { + /// + /// 驗證預設語言 (zh-TW) 索引鍵能正確傳回繁體中文文字。 + /// [Fact] public void Indexer_ReturnsTraditionalChinese_ForTheDefaultLanguage() => Assert.Equal("無法取得", new Localizer(Localizer.DefaultLanguage)["Unavailable"]); + /// + /// 驗證英文語系 (en-US) 索引鍵能正確傳回英文文字。 + /// [Fact] public void Indexer_ReturnsEnglish_ForEnglish() => Assert.Equal("Unavailable", new Localizer(Localizer.EnglishLanguage)["Unavailable"]); + /// + /// 驗證當 Key 不存在時傳回 Key 原字串。 + /// [Fact] public void Indexer_ReturnsTheKey_WhenNoTranslationExists() => Assert.Equal("MissingKey", new Localizer(Localizer.EnglishLanguage)["MissingKey"]); + /// + /// 驗證未支援的語言代碼會自動退回使用預設語言 (zh-TW)。 + /// [Theory] [InlineData("de-DE")] [InlineData("")] @@ -23,10 +38,16 @@ public void Indexer_ReturnsTheKey_WhenNoTranslationExists() => public void Constructor_FallsBackToTheDefault_ForAnUnsupportedLanguage(string language) => Assert.Equal(Localizer.DefaultLanguage, new Localizer(language).Language); + /// + /// 驗證語言代碼比較不分大小寫。 + /// [Fact] public void Constructor_AcceptsEnglishRegardlessOfCasing() => Assert.Equal(Localizer.EnglishLanguage, new Localizer("EN-us").Language); + /// + /// 驗證呼叫 設定未支援語言時自動標準化為預設語言。 + /// [Fact] public void SetLanguage_NormalisesUnsupportedValues() { diff --git a/tests/ThermoTray.Tests/MainWindowLayoutTests.cs b/tests/ThermoTray.Tests/MainWindowLayoutTests.cs index b6793f9..2c1142a 100644 --- a/tests/ThermoTray.Tests/MainWindowLayoutTests.cs +++ b/tests/ThermoTray.Tests/MainWindowLayoutTests.cs @@ -8,9 +8,8 @@ namespace ThermoTray.Tests; /// -/// A card is a rounded , and a rounded border clips whatever does not fit inside -/// it. The window therefore has to take its height from its content: any fixed height silently cuts -/// the last line off the cards on the first system font, display scale, or translation needing more room. +/// 主視窗 WPF UI 幾何佈局與元件視覺呈現單元測試。 +/// 確保卡片內容文字、標題版本號與底部設定選項均正常顯示且未遭裁切。 /// public sealed class MainWindowLayoutTests : IClassFixture { @@ -18,17 +17,31 @@ public sealed class MainWindowLayoutTests : IClassFixture public MainWindowLayoutTests(MainWindowFixture fixture) => _fixture = fixture; + /// + /// 驗證 CPU 與 GPU 卡片元件內容完整顯示,末行文字未被 Border 裁切。 + /// [Fact] public void TheCardsShowTheirWholeContents() => _fixture.Invoke(window => { AssertContentFits(window.CpuCard, "CPU"); - AssertContentFits(window.GpuCard, "GPU"); + Assert.Equal(2, window.GpuCards.Items.Count); + Assert.True(window.GpuCards.ActualHeight > 0, "the independent GPU cards render nothing"); + }); + + [Fact] + public void TheCpuDeviceNameIsTheFirstCardLineAndEachMetricHasATrayToggle() => + _fixture.Invoke(window => + { + var stack = (StackPanel)window.CpuCard.Child; + + Assert.Equal("AMD Ryzen 9 5900HS with Radeon Graphics", ((TextBlock)stack.Children[0]).Text); + Assert.IsType(((Grid)stack.Children[1]).Children[1]); + Assert.IsType(((Grid)stack.Children[3]).Children[1]); }); /// - /// Growing the cards must not push the settings underneath them out of the window, which is the - /// other way a content-sized layout can lose something without any visible sign that it did. + /// 驗證卡片下方的設定選項面板完整留在視窗內部,未被擠出視窗底部邊界。 /// [Fact] public void TheSettingsBelowTheCardsStayInTheWindow() => @@ -44,17 +57,14 @@ public void TheSettingsBelowTheCardsStayInTheWindow() => }); /// - /// The height has to follow the content. Without this the window keeps whatever height it was given - /// and the cards absorb the shortfall by clipping, which is how a device name lost its last line. + /// 驗證視窗 SizeToContent 設定為 Height(視窗高度隨內容動態調配)。 /// [Fact] public void TheWindowTakesItsHeightFromItsContent() => _fixture.Invoke(window => Assert.Equal(SizeToContent.Height, window.SizeToContent)); /// - /// The version has to be on screen rather than merely bound. It sits beside the title in a - /// horizontal row, which is a layout that can push it past the right edge, and a run of text that - /// renders nothing still reports a position like any other. + /// 驗證標題列顯示正確的產品版本號,且渲染寬度大於 0 並未超出右側邊界。 /// [Fact] public void TheHeaderShowsTheProductVersion() => @@ -72,9 +82,7 @@ public void TheHeaderShowsTheProductVersion() => }); /// - /// The last child is the device name, the longest and least predictable text on the card. Where it - /// ends up is measured against the card rather than eyeballed, because a clipped card still reports - /// a sensible size; only the text's own position gives it away. + /// 檢查卡片最後一行文字控制項是否完全落在 Border 內距允許範圍內。 /// private static void AssertContentFits(Border card, string which) { @@ -90,14 +98,12 @@ private static void AssertContentFits(Border card, string which) } /// -/// Holds the one WPF an AppDomain is allowed, plus the real window built on -/// its own UI thread, so every layout test shares them. +/// 為 WPF 視窗 UI 測試提供單一 AppDomain UI 執行緒 (STA) 與 MainWindow 實例的測試固件。 /// public sealed class MainWindowFixture : IDisposable { - /// As long as any real processor reports, so the cards have to cope with the longest case. - private const string LongCpuName = "AMD Ryzen 9 5900HS with Radeon Graphics • Core (Tctl/Tdie)"; - private const string LongGpuName = "NVIDIA GeForce RTX 3060 Laptop GPU • GPU Core"; + private const string LongCpuName = "AMD Ryzen 9 5900HS with Radeon Graphics"; + private const string LongCpuSensorName = "Core (Tctl/Tdie)"; private readonly Dispatcher _dispatcher; private readonly MainWindow _window; @@ -126,7 +132,6 @@ public MainWindowFixture() } finally { - // Set from a finally so a failure up there cannot leave the constructor waiting forever. ready.Set(); } @@ -153,6 +158,9 @@ public MainWindowFixture() _application = application!; } + /// + /// 在 UI 執行緒分派執行斷言。 + /// public void Invoke(Action assert) { Exception? failure = null; @@ -184,21 +192,23 @@ public void Dispose() _dispatcher.InvokeShutdown(); } + /// + /// 建立填入長硬體名稱測試資料的隱藏測試視窗。 + /// private static MainWindow CreateWindow() { var viewModel = new MainViewModel(new HardwareSensorService(), new SettingsService(), new StartupService()); SetField(viewModel, "_cpuUsage", "3.8%"); SetField(viewModel, "_cpuTemperature", "61.8 °C"); - SetField(viewModel, "_cpuSource", LongCpuName); - SetField(viewModel, "_gpuUsage", "16%"); - SetField(viewModel, "_gpuTemperature", "51 °C"); - SetField(viewModel, "_gpuSource", LongGpuName); + SetField(viewModel, "_cpuDeviceName", LongCpuName); + SetField(viewModel, "_cpuSource", LongCpuSensorName); + AddGpu(viewModel, "gpu-0", 0, "NVIDIA GeForce RTX 3060 Laptop GPU", 16, 51); + AddGpu(viewModel, "gpu-1", 1, "AMD Radeon(TM) Graphics", 5, 52); var window = new MainWindow { DataContext = viewModel, ShowInTaskbar = false, - // Far enough off screen that the test never flashes a window at whoever is watching. Left = -20000, Top = -20000, }; @@ -212,4 +222,21 @@ private static void SetField(object target, string field, string value) => target.GetType() .GetField(field, BindingFlags.Instance | BindingFlags.NonPublic)! .SetValue(target, value); + + private static void AddGpu(MainViewModel viewModel, string id, int index, string name, decimal usage, decimal temperature) + { + var gpu = new GpuViewModel(id); + gpu.Apply( + new GpuReading( + id, + name, + new TemperatureReading(temperature, $"{name} • GPU Core"), + new UtilizationReading(usage, $"{name} • GPU Core")), + index, + "GPU 使用率", + "GPU 溫度", + reading => reading.Celsius is decimal celsius ? $"{celsius:0.#} °C" : "無法取得", + reading => reading.Percent is decimal percent ? $"{percent:0.#}%" : "無法取得"); + viewModel.GpuItems.Add(gpu); + } } diff --git a/tests/ThermoTray.Tests/PollingIntervalTests.cs b/tests/ThermoTray.Tests/PollingIntervalTests.cs index 8513338..bc0d9b2 100644 --- a/tests/ThermoTray.Tests/PollingIntervalTests.cs +++ b/tests/ThermoTray.Tests/PollingIntervalTests.cs @@ -2,18 +2,29 @@ namespace ThermoTray.Tests; +/// +/// 採樣週期與頻率設定單元測試。 +/// public sealed class PollingIntervalTests { + /// + /// 驗證當主視窗為可見狀態時,採樣週期為 1 秒。 + /// [Fact] public void GetPollingInterval_SamplesEverySecond_WhileTheWindowIsVisible() => Assert.Equal(TimeSpan.FromSeconds(1), MainViewModel.GetPollingInterval(isWindowVisible: true)); + /// + /// 驗證當主視窗隱藏至系統匣時,採樣頻率會降低以節省資源。 + /// [Fact] public void GetPollingInterval_SamplesLessOften_WhileHiddenInTheTray() => Assert.True(MainViewModel.GetPollingInterval(isWindowVisible: false) > MainViewModel.GetPollingInterval(isWindowVisible: true)); - /// A tray icon shows whole degrees, so its refresh must still be well inside a second-scale change. + /// + /// 驗證隱藏狀態下的採樣間隔仍保持在合理回應範圍內 (<= 5 秒)。 + /// [Fact] public void GetPollingInterval_StaysResponsive_WhileHiddenInTheTray() => Assert.True(MainViewModel.GetPollingInterval(isWindowVisible: false) <= TimeSpan.FromSeconds(5)); diff --git a/tests/ThermoTray.Tests/SensorSelectionTests.cs b/tests/ThermoTray.Tests/SensorSelectionTests.cs index 99d7896..ba74fe0 100644 --- a/tests/ThermoTray.Tests/SensorSelectionTests.cs +++ b/tests/ThermoTray.Tests/SensorSelectionTests.cs @@ -3,10 +3,16 @@ namespace ThermoTray.Tests; +/// +/// 感測器名稱權重評分單元測試。 +/// public sealed class PreferredRankTests { private static readonly string[] CpuPreferredNames = ["Tctl/Tdie", "Package", "CPU Package", "Core Average"]; + /// + /// 驗證子字串對應與權重計算。 + /// [Theory] [InlineData("Core (Tctl/Tdie)", 0)] [InlineData("CPU Package", 1)] @@ -14,21 +20,36 @@ public sealed class PreferredRankTests public void GetPreferredRank_MatchesOnSubstring(string sensorName, int expected) => Assert.Equal(expected, HardwareSensorService.GetPreferredRank(sensorName, CpuPreferredNames)); + /// + /// 驗證名稱匹配不區分大小寫。 + /// [Fact] public void GetPreferredRank_IsCaseInsensitive() => Assert.Equal(0, HardwareSensorService.GetPreferredRank("core (tctl/tdie)", CpuPreferredNames)); + /// + /// 驗證無匹配項時傳回 int.MaxValue。 + /// [Fact] public void GetPreferredRank_ReturnsMaxValue_WhenNothingMatches() => Assert.Equal(int.MaxValue, HardwareSensorService.GetPreferredRank("Core #3", CpuPreferredNames)); + /// + /// 驗證多項對應時優先使用較靠前的偏好名稱。 + /// [Fact] public void GetPreferredRank_PrefersTheEarlierEntry_WhenSeveralMatch() => Assert.Equal(1, HardwareSensorService.GetPreferredRank("CPU Package", CpuPreferredNames)); } +/// +/// 溫度讀值有效性驗證單元測試。 +/// public sealed class UsableTemperatureTests { + /// + /// 驗證範圍內數值可被接受。 + /// [Theory] [InlineData(1f)] [InlineData(62.125f)] @@ -36,14 +57,23 @@ public sealed class UsableTemperatureTests public void IsUsableTemperature_AcceptsValuesInsideTheRange(float celsius) => Assert.True(HardwareSensorService.IsUsableTemperature(SensorType.Temperature, celsius, 1, 125)); + /// + /// 驗證拒絕 0°C 占位數值。 + /// [Fact] public void IsUsableTemperature_RejectsZero() => Assert.False(HardwareSensorService.IsUsableTemperature(SensorType.Temperature, 0f, 1, 125)); + /// + /// 驗證拒絕 null 讀值。 + /// [Fact] public void IsUsableTemperature_RejectsNull() => Assert.False(HardwareSensorService.IsUsableTemperature(SensorType.Temperature, null, 1, 125)); + /// + /// 驗證拒絕 NaN 或正負無窮大數值。 + /// [Theory] [InlineData(float.NaN)] [InlineData(float.PositiveInfinity)] @@ -51,19 +81,31 @@ public void IsUsableTemperature_RejectsNull() => public void IsUsableTemperature_RejectsNonFiniteValues(float celsius) => Assert.False(HardwareSensorService.IsUsableTemperature(SensorType.Temperature, celsius, 1, 125)); + /// + /// 驗證拒絕超出範圍的溫度數值(如低於 1°C 或高於 125°C)。 + /// [Theory] [InlineData(-5f)] [InlineData(126f)] public void IsUsableTemperature_RejectsValuesOutsideTheRange(float celsius) => Assert.False(HardwareSensorService.IsUsableTemperature(SensorType.Temperature, celsius, 1, 125)); + /// + /// 驗證拒絕非溫度類型的感測器。 + /// [Fact] public void IsUsableTemperature_RejectsNonTemperatureSensors() => Assert.False(HardwareSensorService.IsUsableTemperature(SensorType.Load, 50f, 1, 125)); } +/// +/// 使用率讀值有效性驗證單元測試。 +/// public sealed class UsableUtilizationTests { + /// + /// 驗證範圍內使用率 (0% ~ 100%) 可被接受(允許 0% 閒置讀值)。 + /// [Theory] [InlineData(0f)] [InlineData(42.5f)] @@ -71,10 +113,16 @@ public sealed class UsableUtilizationTests public void IsUsableUtilization_AcceptsValuesInsideTheRange(float percent) => Assert.True(HardwareSensorService.IsUsableUtilization(SensorType.Load, percent)); + /// + /// 驗證拒絕 null 使用率。 + /// [Fact] public void IsUsableUtilization_RejectsNull() => Assert.False(HardwareSensorService.IsUsableUtilization(SensorType.Load, null)); + /// + /// 驗證拒絕非有限數值。 + /// [Theory] [InlineData(float.NaN)] [InlineData(float.PositiveInfinity)] @@ -82,23 +130,38 @@ public void IsUsableUtilization_RejectsNull() => public void IsUsableUtilization_RejectsNonFiniteValues(float percent) => Assert.False(HardwareSensorService.IsUsableUtilization(SensorType.Load, percent)); + /// + /// 驗證拒絕超出 0%~100% 範圍的數值。 + /// [Theory] [InlineData(-0.1f)] [InlineData(100.1f)] public void IsUsableUtilization_RejectsValuesOutsideTheRange(float percent) => Assert.False(HardwareSensorService.IsUsableUtilization(SensorType.Load, percent)); + /// + /// 驗證拒絕非 Load 類型的感測器。 + /// [Fact] public void IsUsableUtilization_RejectsNonLoadSensors() => Assert.False(HardwareSensorService.IsUsableUtilization(SensorType.Temperature, 50f)); } +/// +/// GPU 感測器優先順序評分單元測試。 +/// public sealed class GpuSensorRankTests { + /// + /// 驗證任何有效候選者均優於 None。 + /// [Fact] public void AnyCandidate_BeatsNone() => Assert.True(Rank(nameRank: int.MaxValue, GpuSensorRank.IntegratedPriority, sequence: 9).IsBetterThan(GpuSensorRank.None)); + /// + /// 驗證感測器名稱匹配優先於硬體類別(內顯 vs 獨顯)。 + /// [Fact] public void SensorNameOutranksHardwareType() { @@ -109,6 +172,9 @@ public void SensorNameOutranksHardwareType() Assert.False(unnamedDiscrete.IsBetterThan(namedIntegrated)); } + /// + /// 驗證名稱權重相同時,獨顯優先於內顯。 + /// [Fact] public void DiscreteOutranksIntegrated_AtEqualSensorName() { @@ -119,6 +185,9 @@ public void DiscreteOutranksIntegrated_AtEqualSensorName() Assert.False(integrated.IsBetterThan(discrete)); } + /// + /// 驗證其他條件相同時,先偵測到的感測器(較早的探索順序)勝出。 + /// [Fact] public void DiscoveryOrderBreaksRemainingTies() { @@ -129,6 +198,9 @@ public void DiscoveryOrderBreaksRemainingTies() Assert.False(second.IsBetterThan(first)); } + /// + /// 驗證同等 Rank 比較傳回 false。 + /// [Fact] public void EqualRanksDoNotDisplaceEachOther() { diff --git a/tests/ThermoTray.Tests/StartupServiceTests.cs b/tests/ThermoTray.Tests/StartupServiceTests.cs index 55e89e1..bbfdfef 100644 --- a/tests/ThermoTray.Tests/StartupServiceTests.cs +++ b/tests/ThermoTray.Tests/StartupServiceTests.cs @@ -5,9 +5,8 @@ namespace ThermoTray.Tests; /// -/// Guards the logon-task definition. Every assertion here stands for a way Task Scheduler used to -/// terminate ThermoTray while it sat in the notification area, which looked like a random crash and -/// left nothing in the event log because the process was killed rather than faulted. +/// 開機啟動工作排程器 (Task Scheduler) XML 定義檔單元測試。 +/// 確保排程設定防範拔除電源或 uptime 超過 72 小時被 Task Scheduler 強制結束處理程序的情況。 /// public sealed class StartupServiceTests { @@ -15,16 +14,23 @@ public sealed class StartupServiceTests private const string UserId = "S-1-5-21-1-2-3-1001"; private static readonly XNamespace TaskNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task"; + /// + /// 驗證生成的字串為格式合法的 XML。 + /// [Fact] public void BuildTaskDefinition_IsWellFormedXml() => Assert.Equal("Task", Parse().Name.LocalName); - /// schtasks reads the definition as UTF-16, so the declaration has to say so. + /// + /// 驗證 XML 開頭宣告包含 `encoding="UTF-16"`(schtasks 的必要規範)。 + /// [Fact] public void BuildTaskDefinition_DeclaresTheEncodingSchtasksExpects() => Assert.StartsWith("""""", Definition(), StringComparison.Ordinal); - /// A laptop that switches to battery must not lose its tray icons. + /// + /// 驗證關閉所有可能中斷排程執行的設定 (DisallowStartIfOnBatteries, StopIfGoingOnBatteries, AllowHardTerminate, RunOnlyIfIdle)。 + /// [Theory] [InlineData("DisallowStartIfOnBatteries")] [InlineData("StopIfGoingOnBatteries")] @@ -33,16 +39,23 @@ public void BuildTaskDefinition_DeclaresTheEncodingSchtasksExpects() => public void BuildTaskDefinition_TurnsOffEverySettingThatCanStopTheTask(string setting) => Assert.Equal("false", Setting(setting)); - /// Zero means no limit; the schtasks default stopped the task after three days of uptime. + /// + /// 驗證 ExecutionTimeLimit 設定為 "PT0S"(代表無時間限制),避免預設 72 小時後終止。 + /// [Fact] public void BuildTaskDefinition_PlacesNoTimeLimitOnHowLongThermoTrayRuns() => Assert.Equal("PT0S", Setting("ExecutionTimeLimit")); - /// Idle settings only apply to an idle-only task, but they are set so no upgrade path can re-enable them. + /// + /// 驗證 StopOnIdleEnd 設定為 false。 + /// [Fact] public void BuildTaskDefinition_DoesNotStopTheTaskWhenTheMachineStopsBeingIdle() => Assert.Equal("false", Parse().Descendants(TaskNamespace + "StopOnIdleEnd").Single().Value); + /// + /// 驗證 Principal 設定為當前使用者互動登入時以 HighestAvailable 權限執行。 + /// [Fact] public void BuildTaskDefinition_RunsElevatedAsTheCurrentUserAtLogon() { @@ -54,6 +67,9 @@ public void BuildTaskDefinition_RunsElevatedAsTheCurrentUserAtLogon() Assert.Equal(UserId, Parse().Descendants(TaskNamespace + "LogonTrigger").Single().Element(TaskNamespace + "UserId")?.Value); } + /// + /// 驗證 Exec Action 帶有 `--minimized` 參數。 + /// [Fact] public void BuildTaskDefinition_StartsTheExecutableMinimised() { @@ -63,12 +79,16 @@ public void BuildTaskDefinition_StartsTheExecutableMinimised() Assert.Equal("--minimized", exec.Element(TaskNamespace + "Arguments")?.Value); } - /// The marker is how an installation carrying an older definition is recognised and rewritten. + /// + /// 驗證 Source 包含版本標記字串。 + /// [Fact] public void BuildTaskDefinition_CarriesTheDefinitionMarkerLaunchLooksFor() => Assert.Equal(StartupService.DefinitionMarker, Parse().Descendants(TaskNamespace + "Source").Single().Value); - /// An installation directory may legitimately contain XML metacharacters. + /// + /// 驗證路徑包含特殊字元時有進行安全轉義。 + /// [Fact] public void BuildTaskDefinition_EscapesThePathInsteadOfBreakingTheDocument() { @@ -79,10 +99,7 @@ public void BuildTaskDefinition_EscapesThePathInsteadOfBreakingTheDocument() } /// - /// Hands the definition to Task Scheduler's own parser, which is the only thing that decides whether - /// schtasks /Create /XML will accept it: well-formed XML is not enough, because the service - /// also validates element order and values against its schema. Setting the text validates it and - /// nothing else, so no task is registered by this test. + /// 使用 Windows 原生 COM 物件 `Schedule.Service` 驗證排程 XML 格式可被 Task Scheduler 解構解析。 /// [Fact] public void BuildTaskDefinition_IsAcceptedByTaskScheduler() diff --git a/tests/ThermoTray.Tests/TemperatureFormatterTests.cs b/tests/ThermoTray.Tests/TemperatureFormatterTests.cs index ce30cf5..20631f4 100644 --- a/tests/ThermoTray.Tests/TemperatureFormatterTests.cs +++ b/tests/ThermoTray.Tests/TemperatureFormatterTests.cs @@ -3,12 +3,21 @@ namespace ThermoTray.Tests; +/// +/// 溫度位元字串轉換單元測試。 +/// public sealed class TemperatureFormatterTests { + /// + /// 驗證讀值無法取得時傳回占位符 "--"。 + /// [Fact] public void ToTrayDigits_ReturnsPlaceholder_WhenReadingIsUnavailable() => Assert.Equal("--", TemperatureFormatter.ToTrayDigits(TemperatureReading.Unavailable)); + /// + /// 驗證溫度小數數值會被截斷為整數位元數字。 + /// [Theory] [InlineData(45.0, "45")] [InlineData(45.6, "45")] @@ -18,6 +27,9 @@ public void ToTrayDigits_ReturnsPlaceholder_WhenReadingIsUnavailable() => public void ToTrayDigits_TruncatesToWholeDegrees(double celsius, string expected) => Assert.Equal(expected, TemperatureFormatter.ToTrayDigits(Reading(celsius))); + /// + /// 驗證數字轉字串過程獨立於當前語系設定,不受小數點符號 (comma vs dot) 影響。 + /// [Theory] [InlineData("de-DE")] [InlineData("fr-FR")] diff --git a/tests/ThermoTray.Tests/TrayDisplaySettingsTests.cs b/tests/ThermoTray.Tests/TrayDisplaySettingsTests.cs new file mode 100644 index 0000000..110a3ad --- /dev/null +++ b/tests/ThermoTray.Tests/TrayDisplaySettingsTests.cs @@ -0,0 +1,58 @@ +using Xunit; + +namespace ThermoTray.Tests; + +public sealed class TrayDisplaySettingsTests +{ + [Fact] + public void NewSettingsShowEveryMetricByDefault() + { + var settings = new AppSettings(); + + Assert.True(settings.ShowCpuUsageInTray); + Assert.True(settings.ShowCpuTemperatureInTray); + Assert.Empty(settings.GpuTraySettings); + } + + [Fact] + public void GpuViewModelUsesAndUpdatesItsPersistedChoices() + { + var settings = new TrayDisplaySettings + { + ShowUsage = false, + ShowTemperature = true, + }; + var saveCount = 0; + var gpu = new GpuViewModel("gpu-0"); + + gpu.ConfigureTraySettings(settings, () => saveCount++); + + Assert.False(gpu.ShowUsageInTray); + Assert.True(gpu.ShowTemperatureInTray); + + gpu.ShowTemperatureInTray = false; + + Assert.False(settings.ShowTemperature); + Assert.Equal(1, saveCount); + } + + [Fact] + public void GpuSourceShowsOnlyTheSensorNameWhenDeviceNameIsAlreadyDisplayed() + { + var gpu = new GpuViewModel("gpu-0"); + + gpu.Apply( + new GpuReading( + "gpu-0", + "AMD Radeon(TM) Graphics", + new TemperatureReading(47, "AMD Radeon(TM) Graphics \u2022 GPU Core"), + new UtilizationReading(3, "AMD Radeon(TM) Graphics \u2022 GPU Core")), + 0, + "GPU usage", + "GPU temperature", + reading => $"{reading.Celsius:0.#} °C", + reading => $"{reading.Percent:0.#}%"); + + Assert.Equal("GPU Core", gpu.Source); + } +} diff --git a/tests/ThermoTray.Tests/TrayIconLayoutTests.cs b/tests/ThermoTray.Tests/TrayIconLayoutTests.cs index 257846e..5f43037 100644 --- a/tests/ThermoTray.Tests/TrayIconLayoutTests.cs +++ b/tests/ThermoTray.Tests/TrayIconLayoutTests.cs @@ -2,11 +2,19 @@ namespace ThermoTray.Tests; +/// +/// 系統匣圖示幾何佈局計算單元測試。 +/// public sealed class TrayIconLayoutTests { - /// Notification-area icon sizes for the display scales Windows offers. + /// + /// 各種 DPI 縮放比例下常見的系統匣圖示尺寸。 + /// public static TheoryData IconSizes => new(16, 20, 24, 32, 40, 48); + /// + /// 驗證上下兩行區域完全在畫布範圍內。 + /// [Theory] [MemberData(nameof(IconSizes))] public void GetLines_KeepsBothLinesInsideTheCanvas(int iconSize) @@ -23,6 +31,9 @@ public void GetLines_KeepsBothLinesInsideTheCanvas(int iconSize) } } + /// + /// 驗證兩行區域完全區隔不重疊。 + /// [Theory] [MemberData(nameof(IconSizes))] public void GetLines_SeparatesTheTwoLines(int iconSize) @@ -33,6 +44,9 @@ public void GetLines_SeparatesTheTwoLines(int iconSize) Assert.True(temperature.Top >= usage.Bottom, "the temperature line overlaps the utilization line"); } + /// + /// 驗證兩行區域具有相同的形狀與寬高。 + /// [Theory] [MemberData(nameof(IconSizes))] public void GetLines_GivesTheTwoLinesTheSameShape(int iconSize) @@ -46,8 +60,7 @@ public void GetLines_GivesTheTwoLinesTheSameShape(int iconSize) } /// - /// The digits are only as tall as the line they are scaled into, so most of the icon has to reach - /// them. A layout that spent its height on padding is what made the readings hard to make out. + /// 驗證絕大部分畫布高度均分配給文字繪製(使用率超過 90%)。 /// [Theory] [MemberData(nameof(IconSizes))] @@ -60,6 +73,9 @@ public void GetLines_SpendsNearlyAllTheHeightOnDigits(int iconSize) Assert.True(usage.Width / canvasSize >= 0.9f); } + /// + /// 驗證畫布放大倍率大於 1(Supersampling 超高採樣)。 + /// [Fact] public void GetCanvasSize_DrawsLargerThanTheIconSoStrokesCanBeAveragedDown() => Assert.True(TrayIconLayout.GetCanvasSize(TrayIconLayout.MinimumIconSize) > TrayIconLayout.MinimumIconSize); diff --git a/tests/ThermoTray.Tests/TrayIconRenderingTests.cs b/tests/ThermoTray.Tests/TrayIconRenderingTests.cs index 0a2f41e..67c23d9 100644 --- a/tests/ThermoTray.Tests/TrayIconRenderingTests.cs +++ b/tests/ThermoTray.Tests/TrayIconRenderingTests.cs @@ -4,16 +4,15 @@ namespace ThermoTray.Tests; /// -/// Guards what the tray icon actually puts on screen. The readings it shows are the whole point of the -/// application, so a digit that is dropped or cut in half is a correctness problem, not a cosmetic one. +/// 位元圖繪製視覺效果單元測試。 +/// 確保數字不會被截斷或毀損。 /// public sealed class TrayIconRenderingTests { private const int IconSize = 16; /// - /// A three-digit reading used to be drawn into a rectangle it could not fit, and the part that did - /// not fit was cut off rather than scaled down, so 100 appeared as 10. + /// 驗證三位數字 ("100") 的繪製像素分佈與二位數字 ("10") 不同,確保三位數字不被截斷為二位數。 /// [Fact] public void CreateIconBitmap_DrawsThreeDigitsDifferentlyFromTwo() @@ -24,6 +23,9 @@ public void CreateIconBitmap_DrawsThreeDigitsDifferentlyFromTwo() Assert.NotEqual(Describe(three), Describe(two)); } + /// + /// 驗證上下兩行(使用率與溫度)均包含實際繪製的墨水像素。 + /// [Theory] [InlineData("1", "49")] [InlineData("100", "89")] @@ -38,8 +40,7 @@ public void CreateIconBitmap_DrawsBothLines(string usageDigits, string temperatu } /// - /// Each line is scaled to fill its half of the icon, so a reading that covers only a sliver of its - /// half means the digits were clipped or shrunk away rather than fitted. + /// 驗證數字自動縮放後填滿絕大部分行高度,不致因縮小過度而無法辨識。 /// [Theory] [InlineData("1", "49")] @@ -53,7 +54,9 @@ public void CreateIconBitmap_FillsMostOfEachLine(string usageDigits, string temp Assert.True(InkRowCount(bitmap, IconSize / 2, IconSize) >= (IconSize / 2) - 1, "the temperature digits are too short"); } - /// The digits have to stay inside the icon; anything drawn outside it is simply lost. + /// + /// 驗證輸出的位元圖像素尺寸精確符合請求的尺寸。 + /// [Fact] public void CreateIconBitmap_UsesTheRequestedSize() { @@ -83,7 +86,9 @@ private static int CountInk(Bitmap bitmap, int topRow, int bottomRow) return count; } - /// Rows carrying any ink, which is how tall the digits ended up rather than how wide. + /// + /// 計算包含非透明像素的總行數。 + /// private static int InkRowCount(Bitmap bitmap, int topRow, int bottomRow) { var rows = 0; diff --git a/tests/ThermoTray.Tests/UtilizationFormatterTests.cs b/tests/ThermoTray.Tests/UtilizationFormatterTests.cs index f9eef1b..1927617 100644 --- a/tests/ThermoTray.Tests/UtilizationFormatterTests.cs +++ b/tests/ThermoTray.Tests/UtilizationFormatterTests.cs @@ -3,12 +3,21 @@ namespace ThermoTray.Tests; +/// +/// 使用率位元字串轉換單元測試。 +/// public sealed class UtilizationFormatterTests { + /// + /// 驗證使用率讀值無法取得時傳回占位符 "--"。 + /// [Fact] public void ToTrayDigits_ReturnsPlaceholder_WhenReadingIsUnavailable() => Assert.Equal("--", UtilizationFormatter.ToTrayDigits(UtilizationReading.Unavailable)); + /// + /// 驗證使用率數值被截斷為整數百分比。 + /// [Theory] [InlineData(0.0, "0")] [InlineData(42.5, "42")] @@ -17,6 +26,9 @@ public void ToTrayDigits_ReturnsPlaceholder_WhenReadingIsUnavailable() => public void ToTrayDigits_TruncatesToWholePercentagePoints(double percent, string expected) => Assert.Equal(expected, UtilizationFormatter.ToTrayDigits(Reading(percent))); + /// + /// 驗證轉字串獨立於當前語系設定。 + /// [Theory] [InlineData("de-DE")] [InlineData("fr-FR")] diff --git a/tests/ThermoTray.Tests/VersionFormattingTests.cs b/tests/ThermoTray.Tests/VersionFormattingTests.cs index 80b5fae..99a96df 100644 --- a/tests/ThermoTray.Tests/VersionFormattingTests.cs +++ b/tests/ThermoTray.Tests/VersionFormattingTests.cs @@ -2,24 +2,35 @@ namespace ThermoTray.Tests; +/// +/// 版本字串格式化單元測試。 +/// public sealed class VersionFormattingTests { - /// The build appends a fourth component that no release is ever named after. + /// + /// 驗證格式化時會捨棄第四位組件 (Revision)。 + /// [Fact] public void FormatVersion_DropsTheFourthComponent() => Assert.Equal("v1.1.2", MainViewModel.FormatVersion(new Version(1, 1, 2, 0))); - /// An absent component is -1, which must not reach the window as "v1.1.-1". + /// + /// 驗證缺失的 Component (-1) 會自動補 0。 + /// [Fact] public void FormatVersion_ReadsAMissingComponentAsZero() => Assert.Equal("v1.1.0", MainViewModel.FormatVersion(new Version(1, 1))); - /// The assembly can carry no version at all; the header then shows nothing rather than "v". + /// + /// 驗證傳入 null 版本時傳回空字串。 + /// [Fact] public void FormatVersion_ShowsNothingWithoutAVersion() => Assert.Equal(string.Empty, MainViewModel.FormatVersion(null)); - /// What the window binds to has to be the version this build was actually stamped with. + /// + /// 驗證與實際當前組件版本號一致。 + /// [Fact] public void FormatVersion_MatchesTheAssemblyTheApplicationRunsFrom() {