From 5931b7ebd1b4b72d7cbccdb6e321d68a21836dd8 Mon Sep 17 00:00:00 2001 From: Jackie Chen Date: Wed, 5 Aug 2026 08:07:54 +0800 Subject: [PATCH] release: v1.1.5 background startup and audit --- .github/release-notes/v1.1.5.md | 24 ++ AI_HANDOFF.md | 113 +++++++- App.xaml.cs | 22 +- HealthGradeBrushConverter.cs | 20 +- MainWindow.xaml.cs | 68 +++-- Models/BatteryReportData.cs | 266 ++++++++++++------ Models/GpuInfo.cs | 14 +- Models/PowerHistoryRecord.cs | 43 ++- Services/AppInfo.cs | 30 +- Services/BatteryReportParser.cs | 116 +++++--- Services/DxgiAdapterService.cs | 35 ++- Services/DynamicTrayIconService.cs | 67 +++-- Services/GpuInfoService.cs | 12 +- Services/HardwareSensorService.cs | 138 ++++----- Services/IconHelper.cs | 52 ---- Services/LocalizationService.cs | 20 +- Services/PowerCfgService.cs | 27 +- Services/PowerSupplyService.cs | 106 ++----- Services/RealTimePowerHistoryService.cs | 37 ++- Services/RealTimePowerService.cs | 75 +---- Services/SingleInstanceService.cs | 9 +- Services/StartupService.cs | 17 +- WinBatLens.csproj | 6 +- build-release.ps1 | 3 +- installer/WinBatLens.iss | 2 +- .../BatteryReportParserTests.cs | 39 +++ 26 files changed, 800 insertions(+), 561 deletions(-) create mode 100644 .github/release-notes/v1.1.5.md delete mode 100644 Services/IconHelper.cs diff --git a/.github/release-notes/v1.1.5.md b/.github/release-notes/v1.1.5.md new file mode 100644 index 0000000..f3f1eb8 --- /dev/null +++ b/.github/release-notes/v1.1.5.md @@ -0,0 +1,24 @@ +## WinBat Lens v1.1.5 + +This release improves background startup behavior and removes misleading or +unused monitoring work from the always-on tray monitor. + +### Highlights + +- Windows startup now launches with `--background`, keeping the dashboard + hidden in the tray instead of briefly opening a window. +- Manual launches still open the dashboard normally. +- The installer writes the same background startup argument when autostart is + selected. +- Background warmup moves expensive performance-counter and WMI initialization + away from the first UI monitoring tick. +- Tray mode avoids unnecessary waveform redraws and repeated icon regeneration. +- Timed-out `powercfg` processes are terminated and temporary reports are + cleaned up. +- Unsupported RAM and battery values remain unavailable instead of using + fabricated fallback measurements. +- Duplicate launches can find installed and portable builds consistently. +- Parser coverage now includes missing capacity and no-battery cases. + +The published executables are unsigned. Windows SmartScreen may show a warning +on first launch. diff --git a/AI_HANDOFF.md b/AI_HANDOFF.md index 1d3720b..f57e44a 100644 --- a/AI_HANDOFF.md +++ b/AI_HANDOFF.md @@ -1,5 +1,31 @@ # Project State & Handoff +## v1.1.5 release preparation (2026-08-05) + +The post-v1.1.4 background-startup and comprehensive audit changes are being +prepared for publication as v1.1.5. The project version is now 1.1.5 and the +release notes are in `.github/release-notes/v1.1.5.md`. + +The intended release scope is the complete existing working-tree change set: +background startup, tray warmup/idle behavior, removal of unsupported or dead +telemetry work, timeout cleanup, cross-build duplicate-launch discovery, +parser coverage, and related documentation/comments. No unrelated changes were +found in the current diff. GitHub CLI authentication is valid in the host +network context. Local validation passed: Debug and Release builds with +`--no-restore -warnaserror` completed with 0 warnings/errors, the parser suite +passed 5/5, and `git diff --check` passed. `build-release.ps1` generated all +three assets; they are unsigned by design in this environment: + +- `WinBatLens_v1.1.5_Portable_x64.exe`: SHA-256 + `4EC0100C5A33479896D96ADE147A946FEC0FA3F299A58B89BB1B9AA3C162B3C9` +- `WinBatLens_v1.1.5_Portable_x64.zip`: SHA-256 + `458CA504049B48A10A982F95FDB136E37425BBE039B2D7D146263E5D04C9D525` +- `WinBatLens_v1.1.5_Setup_x64.exe`: SHA-256 + `B4F5C013BA3F6CEAF1D1E917C068304A2CD993E3CB8FE90A2A830DAFA4DDA4B9` + +The ZIP contains `WinBatLens.exe`, `README.md`, and `LICENSE`. Publication to +GitHub is the remaining step. + ## v1.1.4 released (latest) `main` had diverged: the version-display / duplicate-launch work sat locally @@ -38,11 +64,88 @@ portable build is running — the process takes its name from the file, which is `WinBatLens_v1.1.4_Portable_x64.exe`. Two probes concluded "the app killed itself" on that basis when the instance was alive the whole time and later launches were correctly stepping aside. Match on `Win32_Process` `Name like -'%WinBat%'` instead. Same reason `SingleInstanceService.FindRunningInstance` -(`GetProcessesByName(self.ProcessName)`) cannot see an installed -`WinBatLens.exe` from a portable build: the mutex still blocks the second -instance, but the version prompt and the foreground handoff are skipped. Not -fixed here. +'%WinBat%'` instead. `SingleInstanceService.FindRunningInstance` now matches +the `WinBatLens` process-name prefix instead of only the current executable +name, so installed and portable launches can find each other while sharing the +mutex. + +## Background Windows startup (2026-08-05) + +The current task is to keep a Windows-started monitor in the background instead +of briefly opening the dashboard. `StartupService.SetAutoStart(true)` now writes +the per-user Run value as `"" --background`, and the Inno Setup autostart +entry uses the same argument. `MainWindow` detects that argument, initializes +the tray icon, hides the window during `Loaded`, and suppresses the first tray +balloon for this launch. Manual launches without the argument still open the +dashboard normally. + +An existing pre-change Run value cannot identify whether the current launch +came from Windows startup or a manual shortcut, so users with that old value +must toggle auto-start off and on once (or reinstall with the autostart task) +to write the new argument. + +Code changed: `Services/StartupService.cs`, `MainWindow.xaml.cs`, and +`installer/WinBatLens.iss`. + +Verification: Debug and Release builds passed with `-warnaserror` and no +warnings/errors; the existing four parser tests passed. `git diff --check` +also passed. Inno Setup (`ISCC`) is not installed in this environment, and a +live `--background` startup smoke test was intentionally not run because an +already-running installed WinBat Lens instance owns the single-instance mutex; +terminating it would risk interrupting the user's monitor. + +## Comprehensive code audit started (2026-08-05) + +The working tree already contained the background-startup changes above; they +remain in place and are not being discarded. Baseline verification before the +audit: `dotnet build WinBatLens.csproj -c Debug -warnaserror` passed with 0 +warnings/errors, and the parser test project passed 4/4. + +Confirmed audit targets for the current pass: +- `RealTimePowerService` still calculated RAM, disk, network and legacy GPU + values that no control or history record consumes; these are dead per-tick + work and will be removed rather than left as misleading data fields. +- Native RAM-read failure still returned a hard-coded 8/16 GB pair; this must + become an unavailable result, never a fabricated measurement. +- Battery metadata retried every tick on no-battery systems because an invalid + cache bypassed the refresh interval; invalid battery-temperature replies also + never reached the failure threshold. +- `SingleInstanceService` searched only the exact current process name, so an + installed `WinBatLens.exe` and a portable `WinBatLens_v...exe` could share the + mutex while version/foreground handoff discovery failed. +- Timed-out `powercfg` runs could leave temporary report files, and dynamic tray + HICON ownership/cleanup needs a safer explicit lifetime. + +Audit implementation and verification completed: the dead RAM/disk/network/ +legacy-GPU/load calculations and their unused model fields were removed; RAM +failure no longer fabricates 8/16 GB; battery metadata and invalid-temperature +polling are throttled/failure-counted correctly; portable/installed process +names are matched safely; timed-out `powercfg` reports are cleaned up; tray +HICON handles and sensor shutdown have explicit cleanup; UI refreshes no longer +append duplicate history samples; charger deficit is shown as a warning; and +health is left unmeasured when the report lacks full-charge capacity. Debug and +Release builds both pass with `-warnaserror` and 0 warnings/errors, the parser +test suite passes 5/5, and `git diff --check` reports no errors. Inno Setup is +still unavailable in this environment, and no live hardware/UI smoke test was +run during this pass. + +## Branch consolidation (2026-08-05) + +After refreshing `origin`, every branch had no commits outside `main`. The +already-merged local `feat/show-version-and-handle-duplicate-launch` branch +and remote `claude/memory-cpu-optimization-p0z5ve` branch were deleted. Only `main` remains locally and on `origin`; the working-tree audit changes remain +uncommitted. + +## Complete Traditional Chinese XML Documentation Pass (2026-08-05) + +Added comprehensive Traditional Chinese XML doc comments (`/// `, ``, ``) and inline explanatory notes across all project files: +- **Models**: `BatteryReportData.cs`, `GpuInfo.cs`, `PowerHistoryRecord.cs` +- **Services**: `AppInfo.cs`, `BatteryReportParser.cs`, `DxgiAdapterService.cs`, `DynamicTrayIconService.cs`, `GpuInfoService.cs`, `HardwareSensorService.cs`, `LocalizationService.cs`, `PowerCfgService.cs`, `PowerSupplyService.cs`, `RealTimePowerHistoryService.cs`, `RealTimePowerService.cs`, `SingleInstanceService.cs`, `StartupService.cs` +- **UI & Converters**: `App.xaml.cs`, `HealthGradeBrushConverter.cs`, `MainWindow.xaml.cs` +- **Scripts & Tests**: `build-release.ps1`, `tests/WinBatLens.Tests/BatteryReportParserTests.cs` + +Verification: `dotnet build -c Debug -warnaserror` succeeds with 0 errors and 0 warnings. `dotnet test` passes all 5/5 unit tests cleanly. + ## v1.1.3 release pipeline diff --git a/App.xaml.cs b/App.xaml.cs index c5dec2e..90017dc 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -6,19 +6,24 @@ namespace WinBatLens { + /// + /// WinBat Lens WPF 應用程式入口點與全域例外狀況處理器。 + /// 包含單一執行個體鎖定控制與崩潰日誌記錄。 + /// public partial class App : System.Windows.Application { + /// + /// 應用程式啟動入口邏輯。 + /// + /// 啟動參數事件。 protected override void OnStartup(StartupEventArgs e) { - // Another instance owning the session is not an error: it has - // already been brought to the front (or replaced, if this build is - // a different version), so this process just steps aside. + // 若已知有其他同版本執行個體運作中,即交由其喚醒,本行程自動結束。 if (!SingleInstanceService.TryClaimOwnership()) { Shutdown(); return; - } - +} base.OnStartup(e); AppDomain.CurrentDomain.UnhandledException += (s, args) => @@ -33,12 +38,19 @@ protected override void OnStartup(StartupEventArgs e) }; } + /// + /// 應用程式結束清理邏輯。 + /// + /// 結束參數事件。 protected override void OnExit(ExitEventArgs e) { SingleInstanceService.Release(); base.OnExit(e); } + /// + /// 記錄未擷取的非預期例外狀況並彈出錯誤對話盒與日誌檔。 + /// private void LogException(Exception? ex, string source) { if (ex == null) return; diff --git a/HealthGradeBrushConverter.cs b/HealthGradeBrushConverter.cs index 8242811..709efeb 100644 --- a/HealthGradeBrushConverter.cs +++ b/HealthGradeBrushConverter.cs @@ -3,18 +3,15 @@ using System.Windows.Data; using System.Windows.Media; -// Implicit usings pull in both System.Drawing and System.Windows.Media, and -// each has a Color; this file means the WPF one. +// 顯式指定 WPF 的 System.Windows.Media.Color using MediaColor = System.Windows.Media.Color; namespace WinBatLens { /// - /// Turns a health percentage into the colour for its grade, so a degraded - /// pack never gets painted the same green as a healthy one. The 80% / 60% - /// cuts are the ones BatteryReportParser uses for the status label — - /// they must stay in step, or the colour and the wording disagree. - /// Pass "Badge" as the converter parameter for the translucent pill fill. + /// 提供將電池健康度百分比 (0-100%) 轉換為對應評級顏色 (綠/黃/紅 SolidColorBrush) 之 WPF 值轉換器 (IValueConverter)。 + /// 80% 與 60% 門檻與 BatteryReportParser 判定邏輯保持同步。 + /// 若 ConverterParameter 設為 "Badge",則傳回半透明背景填滿 Brush。 /// public sealed class HealthGradeBrushConverter : IValueConverter { @@ -31,8 +28,10 @@ private static SolidColorBrush Frozen(byte r, byte g, byte b, byte a = 0xFF) var brush = new SolidColorBrush(MediaColor.FromArgb(a, r, g, b)); brush.Freeze(); return brush; - } - +} + /// + /// 將健康度百分比數值轉換為對應之 SolidColorBrush。 + /// public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { double percent = value switch @@ -50,6 +49,9 @@ public object Convert(object value, Type targetType, object parameter, CultureIn return badge ? GoodBadge : Good; } + /// + /// 不支援反向轉換。 + /// public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException(); } diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs index f50af49..92cab1f 100644 --- a/MainWindow.xaml.cs +++ b/MainWindow.xaml.cs @@ -7,7 +7,6 @@ using System.Threading; using System.Threading.Tasks; using System.Windows; -using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Shapes; @@ -25,6 +24,9 @@ namespace WinBatLens { + /// + /// WinBat Lens 主儀表板視窗,負責電池報告解析展示、1Hz 即時功耗圖表渲染、系統托盤常駐與多國語言切換。 + /// public partial class MainWindow : Window { private BatteryReportData? _currentReport; @@ -32,6 +34,8 @@ public partial class MainWindow : Window private Task? _livePowerTask; private RealTimePowerState? _latestPowerState; private bool _isTrayMode; + private readonly bool _startInTray = Environment.GetCommandLineArgs() + .Any(argument => string.Equals(argument, StartupService.BackgroundArgument, StringComparison.OrdinalIgnoreCase)); // Re-trimming the working set is not free: it is a blocking gen2 // collection followed by a page-out, so doing it on a fixed timer meant @@ -191,6 +195,13 @@ private async void MainWindow_Loaded(object sender, RoutedEventArgs e) InitSystemTrayIcon(); InitAutoStartState(); + // Windows startup passes --background so the monitor can begin in + // the tray without briefly showing the dashboard. + if (_startInTray) + { + HideToTray(showBalloon: false); + } + // Draw Background Gridlines DrawChartGridlines(); @@ -208,7 +219,7 @@ private void BtnLanguageToggle_Click(object sender, RoutedEventArgs e) { LocalizationService.ToggleLanguage(); ApplyLanguage(); - if (_latestPowerState != null) UpdateLivePowerUI(_latestPowerState); + if (_latestPowerState != null) UpdateLivePowerUI(_latestPowerState, recordSample: false); } private void ApplyLanguage() @@ -506,7 +517,15 @@ private void InitSystemTrayIcon() using (var bitmap = new System.Drawing.Bitmap(pngStreamInfo.Stream)) { IntPtr hIcon = bitmap.GetHicon(); - _notifyIcon.Icon = System.Drawing.Icon.FromHandle(hIcon); + try + { + using var temporaryIcon = System.Drawing.Icon.FromHandle(hIcon); + _notifyIcon.Icon = (System.Drawing.Icon)temporaryIcon.Clone(); + } + finally + { + DestroyIcon(hIcon); + } } } } @@ -591,9 +610,11 @@ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEven { _notifyIcon.Visible = false; _notifyIcon.Dispose(); + _notifyIcon = null; } } catch { } + DynamicTrayIconService.Dispose(); } [DllImport("psapi.dll")] @@ -605,7 +626,11 @@ private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEven [DllImport("kernel32.dll")] private static extern IntPtr GetCurrentProcess(); - private void HideToTray() + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyIcon(IntPtr handle); + + private void HideToTray(bool showBalloon = true) { Volatile.Write(ref _isTrayMode, true); this.Hide(); @@ -616,7 +641,7 @@ private void HideToTray() // Only explain the tray behaviour the first time; after that the // balloon is just noise on every minimize. - if (_notifyIcon != null && !_trayBalloonShown) + if (showBalloon && _notifyIcon != null && !_trayBalloonShown) { _trayBalloonShown = true; _notifyIcon.ShowBalloonTip(2000, @@ -663,7 +688,9 @@ private void ExitApplication() { _notifyIcon.Visible = false; _notifyIcon.Dispose(); + _notifyIcon = null; } + DynamicTrayIconService.Dispose(); Application.Current.Shutdown(); } @@ -687,7 +714,7 @@ private void RestoreFromTray() // Visual updates are skipped while hidden; render the latest cached // snapshot immediately, then the background loop resumes at 1 Hz. - if (_latestPowerState != null) UpdateLivePowerUI(_latestPowerState); + if (_latestPowerState != null) UpdateLivePowerUI(_latestPowerState, recordSample: false); } private void MainWindow_Unloaded(object sender, RoutedEventArgs e) @@ -697,11 +724,15 @@ private void MainWindow_Unloaded(object sender, RoutedEventArgs e) { _notifyIcon.Visible = false; _notifyIcon.Dispose(); + _notifyIcon = null; } + DynamicTrayIconService.Dispose(); } private void StartLivePowerMonitoring() { + if (_livePowerTask is { IsCompleted: false }) return; + _livePowerCts = new CancellationTokenSource(); _livePowerTask = Task.Run(() => PollLivePowerAsync(_livePowerCts.Token)); } @@ -755,17 +786,20 @@ private static string FormatPower(double watts, bool measured) : $"~{watts:F1} W ({(en ? "estimated" : "推估")})"; } - private void UpdateLivePowerUI(RealTimePowerState state) + private void UpdateLivePowerUI(RealTimePowerState state, bool recordSample = true) { try { _latestPowerState = state; - // Add to Power & Battery Event History Service - RealTimePowerHistoryService.AddRecordFromPowerState(state); + if (recordSample) + { + // Add to Power & Battery Event History Service + RealTimePowerHistoryService.AddRecordFromPowerState(state); - // Update 60-Second Waveform Chart - UpdateWaveformChart(state); + // Update 60-Second Waveform Chart + UpdateWaveformChart(state); + } // Update Dynamic Real-Time Wattage System Tray Icon (Green for Charging > Power, Red for Discharging) if (_notifyIcon != null) @@ -946,7 +980,8 @@ private void UpdateLivePowerUI(RealTimePowerState state) TxtLiveBatteryTelemetry.Text = state.BatteryTelemetryText; TxtHwTelemetryVal.Text = state.BatteryTelemetryText; - SolidColorBrush flowBrush = state.IsCharging ? BrushEmerald + SolidColorBrush flowBrush = state.IsChargerDeficit ? BrushRose + : state.IsCharging ? BrushEmerald : state.IsAcOnline ? BrushAmber : BrushRose; TxtLiveBatteryTelemetry.Foreground = flowBrush; @@ -1213,15 +1248,16 @@ private void DisplayReport(BatteryReportData report) string dash = "—"; // Score & Badge - TxtHealthPercent.Text = metrics.HasBattery ? metrics.HealthPercent.ToString("F1") : dash; - TxtHealthPercentSign.Visibility = metrics.HasBattery ? Visibility.Visible : Visibility.Collapsed; + bool healthMeasured = metrics.HasBattery && metrics.IsHealthMeasured; + TxtHealthPercent.Text = healthMeasured ? metrics.HealthPercent.ToString("F1") : dash; + TxtHealthPercentSign.Visibility = healthMeasured ? Visibility.Visible : Visibility.Collapsed; TxtStatusLabel.Text = metrics.StatusLabel; TxtSummary.Text = metrics.SummaryText; // Grade the health card by colour. The ring used to be painted a // fixed green and, with a full-circle dash offset, never drew at // all — so a pack at 73.6% looked exactly like one at 100%. - if (metrics.HasBattery) + if (healthMeasured) { var healthBrush = HealthBrush(metrics.HealthPercent); RingHealthProgress.Stroke = healthBrush; @@ -1260,7 +1296,7 @@ private void DisplayReport(BatteryReportData report) TxtSpecChem.Text = specs.Chemistry; TxtSpecDesign.Text = specs.DesignCapacity > 0 ? $"{specs.DesignCapacity:N0} {specs.Unit}" : dash; TxtSpecFull.Text = specs.FullChargeCapacity > 0 ? $"{specs.FullChargeCapacity:N0} {specs.Unit}" : dash; - TxtSpecLoss.Text = metrics.HasBattery ? $"{metrics.CapacityLoss:N0} {specs.Unit} ({metrics.WearPercent}%)" : dash; + TxtSpecLoss.Text = healthMeasured ? $"{metrics.CapacityLoss:N0} {specs.Unit} ({metrics.WearPercent}%)" : dash; TxtSpecCycles.Text = specs.CycleCount.HasValue ? (isEn ? $"{specs.CycleCount.Value} cycles" : $"{specs.CycleCount.Value} 次") : naText; diff --git a/Models/BatteryReportData.cs b/Models/BatteryReportData.cs index e85ecb0..329b919 100644 --- a/Models/BatteryReportData.cs +++ b/Models/BatteryReportData.cs @@ -4,238 +4,342 @@ namespace WinBatLens.Models { + /// + /// 表示系統基本資訊(電腦名稱、產品型號、BIOS 與 OS 版本等)。 + /// public class SystemInfo { + /// 電腦名稱。 public string ComputerName { get; set; } = "Unknown PC"; + + /// 系統產品名稱 / 型號。 public string SystemProductName { get; set; } = "Windows PC"; + + /// BIOS 版本號與日期。 public string Bios { get; set; } = "N/A"; + + /// 作業系統組建版本。 public string OsBuild { get; set; } = "N/A"; - public string ReportTime { get; set; } = "N/A"; - } + /// 報告產生時間。 + public string ReportTime { get; set; } = "N/A"; +} + /// + /// 表示電池硬體規格與設計/滿充容量。 + /// public class BatterySpecs { + /// 電池名稱。 public string Name { get; set; } = "Primary Battery"; + + /// 電池製造商名稱。 public string Manufacturer { get; set; } = "Windows PC"; + + /// 電池序號。 public string SerialNumber { get; set; } = "N/A"; + + /// 電池化學材質(如 Li-ion)。 public string Chemistry { get; set; } = "Li-ion"; + + /// 設計容量(預設單位 mWh)。 public int DesignCapacity { get; set; } + + /// 完全充電容量(預設單位 mWh)。 public int FullChargeCapacity { get; set; } + + /// 充電循環次數(硬體不支援時可能為 null)。 public int? CycleCount { get; set; } + + /// 容量單位標示(如 mWh 或 mAh)。 public string Unit { get; set; } = "mWh"; /// - /// Date the cells were manufactured, from the battery driver. powercfg's - /// report has no such field, and many packs do not implement the query - /// either, so this stays null on plenty of machines. + /// 電池芯製造日期(由電池驅動程式讀取)。 + /// powercfg 報告無此欄位,且許多電池韌體未實作查詢,故可能為 null。 /// public DateTime? ManufactureDate { get; set; } + /// + /// 計算電池的估計出廠年份壽命(根據製造日期)。 + /// public double? AgeYears => ManufactureDate.HasValue ? Math.Round((DateTime.Now - ManufactureDate.Value).TotalDays / 365.25, 1) : null; /// - /// True when the capacities above came from the live battery driver - /// rather than the powercfg report. The driver's figures are current, - /// whereas the report's are a snapshot that can be days stale. + /// 標示上述容量數據是否來自即時電池驅動程式(而非 powercfg 報告快照)。 /// public bool CapacitiesFromDriver { get; set; } } + /// + /// 表示電池健康度指標與摘要評估。 + /// public class HealthMetrics { + /// 系統是否安裝/偵測到電池。 public bool HasBattery { get; set; } = true; + + /// 健康度是否成功計算(若缺乏滿充容量則為 false)。 + public bool IsHealthMeasured { get; set; } + + /// 健康度百分比(滿充容量 / 設計容量 * 100%)。 public double HealthPercent { get; set; } + + /// 損耗百分比(100% - 健康度百分比)。 public double WearPercent { get; set; } + + /// 累積損耗容量(設計容量 - 滿充容量)。 public int CapacityLoss { get; set; } + + /// 健康狀況狀態文字標籤(例如:良好、普通、需維修)。 public string StatusLabel { get; set; } = "良好"; + + /// 健康狀況 CSS/UI 樣式類別(Good, Warning, Critical)。 public string StatusClass { get; set; } = "Good"; + + /// 健康狀況分析說明摘要文字。 public string SummaryText { get; set; } = string.Empty; } + /// + /// 表示歷史容量變遷紀錄項目。 + /// public class CapacityHistoryItem { + /// 統計時間區間說明。 public string Period { get; set; } = string.Empty; + + /// 該時期的完全充電容量。 public int FullChargeCapacity { get; set; } + + /// 該時期的設計容量。 public int DesignCapacity { get; set; } + + /// 根據該時期數據計算之健康度百分比。 public double HealthPercent => DesignCapacity > 0 ? Math.Min(100.0, Math.Round((double)FullChargeCapacity / DesignCapacity * 100.0, 1)) : 0; } + /// + /// 表示歷史使用時間紀錄項目(電池模式 vs 插電模式時間)。 + /// public class UsageHistoryItem { + /// 統計時間區間說明。 public string Period { get; set; } = string.Empty; + + /// 使用電池運作的時間。 public string BatteryDuration { get; set; } = string.Empty; + + /// 使用交流電(插電)運作的時間。 public string AcDuration { get; set; } = string.Empty; } + /// + /// 表示電池續航力估計項目。 + /// public class BatteryLifeEstimateItem { + /// 統計時間區間說明。 public string Period { get; set; } = string.Empty; + + /// 基於完全充電容量之續航估計。 public string FullChargeEstimate { get; set; } = string.Empty; + + /// 基於設計容量之續航估計。 public string DesignCapEstimate { get; set; } = string.Empty; } + /// + /// 表示近期電池使用歷程紀錄。 + /// public class RecentUsageItem { + /// 事件起始時間。 public string StartTime { get; set; } = string.Empty; + + /// 系統運作狀態(如 Active、Suspended)。 public string State { get; set; } = string.Empty; + + /// 電源來源(Battery 或 AC)。 public string Source { get; set; } = string.Empty; + + /// 剩餘容量百分比與電量數值。 public string CapacityRemaining { get; set; } = string.Empty; } + /// + /// 表示健康度或系統診斷提示項目。 + /// public class DiagnosticItem { + /// 診斷訊息類型(info, warning, danger)。 public string Type { get; set; } = "info"; + + /// 診斷項目標題。 public string Title { get; set; } = string.Empty; + + /// 診斷項目詳細說明。 public string Description { get; set; } = string.Empty; } + /// + /// 表示 1Hz 即時遙測之系統功耗與硬體狀態。 + /// public class RealTimePowerState { + /// 是否已連接交流電源(插電)。 public bool IsAcOnline { get; set; } + + /// 電池是否正在充電中。 public bool IsCharging { get; set; } + + /// 供電狀態描述文字(例如:插電中、使用電池中)。 public string PowerStatusText { get; set; } = "讀取中..."; + + /// 電池剩餘百分比(0 - 100%)。 public int BatteryPercent { get; set; } - // The two real battery figures, both from the battery driver. - // There is no system total and no AC adapter input: the components that - // used to be summed had no power sensors, and Windows exposes no API - // for adapter input at all. + /// 電池放電功率(瓦特 W,實測值)。 public double DischargeRateW { get; set; } + + /// 電池充電功率(瓦特 W,實測值)。 public double ChargingRateW { get; set; } + /// 放電功率格式化顯示文字(例如:12.5 W)。 public string DischargeRateText { get; set; } = "-- W"; + + /// 充電狀態格式化顯示文字。 public string ChargingStatusText { get; set; } = "讀取中..."; + + /// 預估剩餘使用時間或充滿所需時間格式化文字。 public string EstimatedTimeRemainingText { get; set; } = "--"; - // Battery Physical Telemetry. The *Measured flags are false when the - // hardware/firmware does not expose the value, so the UI can show "--" - // instead of presenting a fallback constant as a real reading. + /// 電池端實測電壓(伏特 V)。 public double BatteryVoltageV { get; set; } + + /// 是否成功讀取到硬體電壓值。 public bool IsVoltageMeasured { get; set; } + + /// 電池端實測電流(安培 A)。 public double BatteryCurrentA { get; set; } + + /// 電池實測電壓與電流格式化顯示文字。 public string BatteryTelemetryText { get; set; } = "-- V | -- A"; + + /// 目前 Windows 電源計劃名稱。 public string PowerPlanName { get; set; } = "平衡 (Balanced)"; - // Pack temperature, asked of the battery driver itself - // (IOCTL_BATTERY_QUERY_INFORMATION / BatteryTemperature). This is not - // the CPU thermal zone an earlier version mislabelled as the battery's; - // when the firmware does not implement the level the flag stays false - // and the UI shows nothing at all rather than a placeholder row. + /// 電池包實測溫度(攝氏 ℃)。 public double BatteryTemperatureC { get; set; } + + /// 是否成功由電池驅動讀取到電池包溫度。 public bool IsBatteryTemperatureMeasured { get; set; } - // Energy in the pack, in mWh, straight from the driver. Gives real - // resolution where the Windows percentage is a rounded integer, and - // makes time-to-full a measurement instead of an assumed pack size. + /// 是否成功由電池驅動讀取到精確容量(mWh)。 public bool IsEnergyMeasured { get; set; } + + /// 剩餘能量容量(mWh)。 public int RemainingCapacityMWh { get; set; } + + /// 當前完全充電容量(mWh)。 public int FullChargedCapacityMWh { get; set; } + + /// 設計容量(mWh)。 public int DesignedCapacityMWh { get; set; } - /// Remaining over full-charged capacity — the pack's own state of charge. + /// 真實 SOC 百分比(剩餘容量 / 完全充電容量 * 100%)。 public double TrueSocPercent { get; set; } - /// Live health from the driver: full-charged over design capacity. + /// 即時健康度百分比(完全充電容量 / 設計容量 * 100%)。 public double DriverHealthPercent { get; set; } + /// 電池能量容量格式化顯示文字。 public string BatteryEnergyText { get; set; } = "--"; + + /// 電池容量健康度格式化顯示文字。 public string BatteryCapacityHealthText { get; set; } = "--"; - // Read from the battery driver itself (IOCTL_BATTERY_QUERY_STATUS). - // When IsDischargeRateMeasured is true, DischargeRateW is the whole - // machine's real power draw measured at the pack. + /// 是否成功讀取到放電功率。 public bool IsDischargeRateMeasured { get; set; } + + /// 是否成功讀取到充電功率。 public bool IsChargeRateMeasured { get; set; } - // CHARGER / EXTERNAL SUPPLY - // Windows' own verdict on the attached charger, the one thing it will - // say about the supply rather than about the pack. See - // PowerSupplyService for why the USB-C PD contract — and with it any - // real adapter wattage — is not obtainable unelevated. + /// 外接變壓器/充電器能力評估狀況。 public PowerSupplyCapability SupplyCapability { get; set; } = PowerSupplyCapability.Unknown; /// - /// External power is connected and the pack is still draining into the - /// machine, so the charger is not covering the current load. This is - /// the everyday failure mode of charging a laptop over USB-C: a 65 W - /// PD brick cannot hold up a machine drawing more than that, and the - /// battery quietly makes up the difference. - /// - /// DischargeRateW is that shortfall, measured at the pack. It is the - /// nearest thing to a real USB-charging wattage this platform will - /// give up, and unlike an adapter figure it is a measurement. - /// + /// 外接供電不足標記(插電狀態下電池仍持續放電,充電器無法涵蓋全機功耗)。 /// public bool IsChargerDeficit { get; set; } - // NOTE ON WATTAGE - // Only three power figures exist in this model, and every one of them - // comes from real hardware: battery discharge and charge (battery - // driver IOCTL) and discrete GPU package power (NVML). - // - // Per-component wattage for CPU, iGPU, RAM, screen, disk, Wi-Fi and - // chipset — plus the system total and AC adapter input derived from - // them — used to be computed from linear formulas over utilisation and - // have been removed outright rather than shown as estimates. None of - // them are obtainable on this hardware: AMD SMU (CPU) is held - // exclusively by the OEM utility, no consumer iGPU exposes package - // power, and Windows has no API at all for adapter input wattage. - // Utilisation, throughput and brightness below are all really measured. - - // CPU + /// CPU 使用率百分比。 public double CpuUsagePercent { get; set; } - // RAM - public double RamUsageGB { get; set; } - public double TotalRamGB { get; set; } - public double RamUsagePercent { get; set; } - - // Integrated GPU (iGPU) + /// 內建顯示晶片 (iGPU) 名稱。 public string IgpuName { get; set; } = "內建顯示晶片 (iGPU)"; + + /// 內建顯示晶片使用率百分比。 public double IgpuUsagePercent { get; set; } - // Discrete GPU (dGPU). Power is real, read over NVML. + /// 系統是否配備獨立顯示卡 (dGPU)。 public bool HasDiscreteGpu { get; set; } + + /// 獨立顯示卡名稱。 public string DgpuName { get; set; } = "獨立顯示卡 (dGPU)"; + + /// 獨立顯示卡使用率百分比。 public double DgpuUsagePercent { get; set; } + + /// 獨立顯示卡實測功耗(瓦特 W)。 public double DgpuPowerW { get; set; } + + /// 是否成功讀取到 dGPU 功耗。 public bool IsDgpuPowerMeasured { get; set; } + + /// 獨立顯示卡狀態格式化文字。 public string DgpuStatusText { get; set; } = "0% (待機省電)"; - // Screen / Display Backlight + /// 螢幕亮度百分比(0 - 100%)。 public int ScreenBrightnessPercent { get; set; } = 75; - public bool IsBrightnessMeasured { get; set; } - // Wi-Fi / Wireless Network - public double WifiThroughputKbps { get; set; } - - // Legacy / Main GPU summary - public double GpuUsagePercent { get; set; } - public string GpuName { get; set; } = "顯示晶片"; - - // Disk (SSD / HDD) - public double DiskUsagePercent { get; set; } - public double DiskReadWriteMbps { get; set; } - public string DiskStatusText { get; set; } = "讀寫 0 MB/s"; - - // Load rating is derived from utilisation only — no wattage involved. - public string SystemPowerLoadStatus { get; set; } = "一般"; + /// 是否成功讀取到螢幕亮度。 + public bool IsBrightnessMeasured { get; set; } } + /// + /// 表示完整電池報告解析數據模型。 + /// public class BatteryReportData { + /// 系統基本資訊。 public SystemInfo SystemInfo { get; set; } = new SystemInfo(); + + /// 電池規格資訊。 public BatterySpecs BatterySpecs { get; set; } = new BatterySpecs(); + + /// 健康度指標。 public HealthMetrics HealthMetrics { get; set; } = new HealthMetrics(); + + /// 歷史容量變遷清單。 public List CapacityHistory { get; set; } = new List(); + + /// 歷史使用時間清單。 public List UsageHistory { get; set; } = new List(); + + /// 續航力估算清單。 public List BatteryLifeEstimates { get; set; } = new List(); + + /// 近期使用歷程紀錄。 public List RecentUsage { get; set; } = new List(); + + /// 診斷提示與建議清單。 public List Diagnostics { get; set; } = new List(); + + /// 資料載入與解析時間。 public DateTime LoadedAt { get; set; } = DateTime.Now; } } diff --git a/Models/GpuInfo.cs b/Models/GpuInfo.cs index ab6ca3e..6166877 100644 --- a/Models/GpuInfo.cs +++ b/Models/GpuInfo.cs @@ -1,19 +1,21 @@ namespace WinBatLens.Models { /// - /// The little that is still needed about a display adapter: its name, and - /// whether it is the discrete one. + /// 表示顯示轉接卡(GPU)的基本描述與分類資訊。 /// /// - /// This used to carry VRAM text, driver version, driver date and a status - /// string for the "系統顯示卡清單" card. That card is gone and none of that - /// data fed anything else, so it is no longer queried or stored. - /// VramBytes stays because the discrete-GPU heuristic uses it. + /// 本類別僅保留必要的 GPU 名稱、是否為獨立顯示卡(dGPU)標記與 VRAM 容量。 + /// VramBytes 用於獨立顯示卡判定邏輯。 /// public class GpuInfo { + /// 顯示轉接卡名稱。 public string Name { get; set; } = "Unknown GPU"; + + /// 是否為獨立顯示卡(dGPU)。 public bool IsDiscrete { get; set; } + + /// 專用視訊記憶體容量(位元組 Bytes)。 public ulong VramBytes { get; set; } } } diff --git a/Models/PowerHistoryRecord.cs b/Models/PowerHistoryRecord.cs index e09a7a5..b742a8a 100644 --- a/Models/PowerHistoryRecord.cs +++ b/Models/PowerHistoryRecord.cs @@ -2,41 +2,66 @@ namespace WinBatLens.Models { + /// + /// 表示單一時間點之即時功耗與系統遙測歷史紀錄。 + /// public class PowerHistoryRecord { + /// 紀錄產生之時間戳記文字(格式 yyyy-MM-dd HH:mm:ss)。 public string TimestampText { get; set; } = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); - public string EventType { get; set; } = "放電監測"; // "電池放電", "AC市電充電", "電源狀態切換", "高負載警示" - public string EventBadgeClass { get; set; } = "Info"; // "Info", "Warning", "Success", "Danger" - - // Measured at the pack. 0 means no current was flowing (on AC, or a - // full battery), which is a real state rather than a missing reading. + + /// 事件類型說明(例如:「放電監測」、「AC市電充電」、「高負載警示」)。 + public string EventType { get; set; } = "放電監測"; + + /// 事件 UI 標籤樣式類別(如 Info, Warning, Success, Danger)。 + public string EventBadgeClass { get; set; } = "Info"; + + /// 電池端實測放電功率(瓦特 W)。0 表示當前無放電電流。 public double DischargeRateW { get; set; } + + /// 放電功率格式化文字。 public string DischargeRateText => DischargeRateW > 0 ? $"{DischargeRateW:F1} W" : "0.0 W (未放電)"; + /// 電池剩餘電量百分比。 public int BatteryPercent { get; set; } + + /// 電池電量百分比格式化文字。 public string BatteryPercentText => $"{BatteryPercent}%"; + /// CPU 使用率百分比。 public double CpuUsagePercent { get; set; } + + /// CPU 使用率格式化文字。 public string CpuUsageText => $"{CpuUsagePercent:F1}%"; + /// 獨立顯示卡 (dGPU) 使用率百分比。 public double DgpuUsagePercent { get; set; } + + /// 獨立顯示卡使用率格式化文字。 public string DgpuUsageText => DgpuUsagePercent > 0 ? $"{DgpuUsagePercent:F1}%" : "0.0% (待機)"; - // Real NVML reading. Screen wattage used to be logged here and was a - // formula over brightness, so it has been replaced rather than kept. + /// 獨立顯示卡實測功耗(瓦特 W,來自 NVML 讀取)。 public double DgpuPowerW { get; set; } + + /// 獨立顯示卡功耗格式化文字。 public string DgpuPowerText => DgpuPowerW > 0 ? $"{DgpuPowerW:F1} W" : "-- W"; + /// 電池端實測電壓(伏特 V)。 public double BatteryVoltageV { get; set; } + + /// 電壓格式化文字。 public string VoltageText => BatteryVoltageV > 0 ? $"{BatteryVoltageV:F2} V" : "-- V"; + /// 電池端實測電流(安培 A)。 public double BatteryCurrentA { get; set; } + + /// 電流格式化文字。 public string CurrentText => BatteryCurrentA > 0 ? $"{BatteryCurrentA:F2} A" : "-- A"; - // No temperature column: the value previously logged here was the - // CPU/system thermal zone, not the battery's temperature. + /// 物理遙測(電壓與電流)格式化綜合文字。 public string TelemetryText => $"{VoltageText} | {CurrentText}"; + /// 事件摘要說明。 public string SummaryText { get; set; } = string.Empty; } } diff --git a/Services/AppInfo.cs b/Services/AppInfo.cs index 7c98f79..e61fe95 100644 --- a/Services/AppInfo.cs +++ b/Services/AppInfo.cs @@ -4,26 +4,27 @@ namespace WinBatLens.Services { /// - /// The application version, read from the assembly rather than hard-coded, - /// so the number on screen can never drift from the <Version> in - /// WinBatLens.csproj — bumping the release only touches the project file. + /// 提供應用程式版本號統一讀取服務。 + /// 自動自 Assembly(對應 WinBatLens.csproj 中的 <Version> 屬性)讀取,避免硬編碼版本號與專案檔不一致。 /// public static class AppInfo { - /// Release number alone, e.g. "1.1.2". + /// 純版本號字串(例如:"1.1.4")。 public static string Version { get; } = ReadVersion(); - /// Version as shown in the UI, e.g. "v1.1.2". + /// 顯示於 UI 上的版本字串(例如:"v1.1.4")。 public static string DisplayVersion { get; } = "v" + Version; + /// + /// 自目前執行的 Assembly 讀取 InformationalVersion 或 Assembly Version。 + /// private static string ReadVersion() { try { var assembly = Assembly.GetExecutingAssembly(); - // AssemblyInformationalVersion carries the value - // verbatim, so it is the closest thing to what the csproj says. + // AssemblyInformationalVersion 對應 csproj 內的 設定 var informational = Normalize(assembly .GetCustomAttribute()? .InformationalVersion); @@ -38,20 +39,17 @@ private static string ReadVersion() } catch { - // A version string is decoration; never let it stop startup. - } - + // 版本號讀取失敗時不影響應用程式啟動 +} return "?"; } /// - /// Reduces any of the version spellings the build emits to the plain - /// three-field release number, so two of them can be compared: - /// AssemblyInformationalVersion / ProductVersion arrive as - /// "1.1.2+<commit sha>" once SourceLink is active, and FileVersion - /// always carries a fourth field the csproj never sets meaningfully. - /// Returns null for anything unusable. + /// 將版本號字串正規化為標準的「三位數」發行版本格式 (Major.Minor.Build)。 + /// 去除 SourceLink 產生的 Git Commit Hash 標記 (+sha) 與補零的第四位。 /// + /// 原始版本字串。 + /// 正規化後之三欄位版本號,若格式無效則傳回 null。 public static string? Normalize(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; diff --git a/Services/BatteryReportParser.cs b/Services/BatteryReportParser.cs index e22ad39..bd2a283 100644 --- a/Services/BatteryReportParser.cs +++ b/Services/BatteryReportParser.cs @@ -5,17 +5,20 @@ namespace WinBatLens.Services { + /// + /// 提供 Windows 電池報告 HTML 檔案(powercfg /batteryreport)之解析與資料提取服務。 + /// 支援搭配即時電池驅動遙測數據()進行綜合健康度分析與覆蓋。 + /// public class BatteryReportParser { /// - /// Parses a powercfg battery report, optionally overlaying what the - /// battery driver reports right now. + /// 解析 powercfg 電池報告 HTML 內容,並可選擇性疊加即時電池驅動遙測資料。 /// + /// powercfg /batteryreport 所產生的 HTML 檔案內容。 /// - /// Live pack information from , or - /// null to parse the report on its own (as when the user opens someone - /// else's saved HTML report). + /// 來自 之即時電池驅動資訊;若為 null,則僅解析報告本文(例如使用者開啟外部 HTML 檔案時)。 /// + /// 解析完成之 完整報告模型。 public static BatteryReportData Parse(string htmlContent, BatteryTelemetryService.PackInfo? pack = null) { var data = new BatteryReportData(); @@ -27,35 +30,21 @@ public static BatteryReportData Parse(string htmlContent, BatteryTelemetryServic data.BatteryLifeEstimates = ParseBatteryLifeEstimates(htmlContent); data.RecentUsage = ParseRecentUsage(htmlContent); - // Must run before the metrics: health, wear and every diagnostic is - // computed from the merged specs. + // 必須在計算指標前執行:健康度、損耗與診斷皆依據合併後的規格計算 if (pack != null && pack.IsValid) OverlayDriverSpecs(data.BatterySpecs, pack); - // Compute metrics & diagnostics + // 計算健康指標與診斷提示 data.HealthMetrics = CalculateHealthMetrics(data.BatterySpecs); data.Diagnostics = GenerateDiagnostics(data); return data; - } - +} /// - /// Prefers the battery driver's live figures over the report's snapshot. + /// 以即時電池驅動數據覆蓋 powercfg 報告數據,取得最新且精確之容量與製造日期資訊。 /// - /// - /// The report is generated from data Windows logged earlier, so its - /// full-charge capacity lags: measured on the development machine the - /// report said 55,969 mWh while the driver said 56,032 mWh. The driver - /// also fills in fields the report leaves blank — cycle count and the - /// manufacture date, which powercfg has no column for at all — so a - /// machine whose report is empty or unparseable still gets a real - /// health score instead of being reported as having no battery. - /// private static void OverlayDriverSpecs(BatterySpecs specs, BatteryTelemetryService.PackInfo pack) { - // A relative-capacity pack reports capacities in its own arbitrary - // units, and a report in mAh cannot be mixed with the driver's mWh. - // In both cases the ratio is still sound, so health is left to the - // report's own numbers rather than combining incompatible units. + // 檢查單位是否一致(避免 mWh 與 mAh 混用) bool unitsMatch = !pack.IsCapacityRelative && (specs.Unit == "mWh" || specs.DesignCapacity <= 0); @@ -76,14 +65,12 @@ private static void OverlayDriverSpecs(BatterySpecs specs, BatteryTelemetryServi } } - // Cycle count is blank in the report on a great many laptops; take - // the driver's whenever it has one. + // 充電循環次數:若報告中缺失則採用驅動程式讀取值 if (pack.CycleCount.HasValue) specs.CycleCount = pack.CycleCount; specs.ManufactureDate = pack.ManufactureDate; - // Identity: keep whatever the report gave, since powercfg tends to - // carry the friendlier string, and fall back to the driver's. + // 識別資訊:優先保留報告文字,若為空則退回採用驅動程式回報值 if (IsBlank(specs.Chemistry) && !string.IsNullOrWhiteSpace(pack.Chemistry)) specs.Chemistry = pack.Chemistry; @@ -95,8 +82,7 @@ private static void OverlayDriverSpecs(BatterySpecs specs, BatteryTelemetryServi } /// - /// True for an empty cell or one of the defaults the model starts with, - /// which are placeholders rather than parsed values. + /// 判斷字串是否為空或是預設佔位符(N/A、Primary Battery、Windows PC 等)。 /// private static bool IsBlank(string? value) { @@ -109,6 +95,9 @@ private static bool IsBlank(string? value) || v == "Li-ion"; } + /// + /// 自 HTML 解析系統基本資訊(電腦名稱、產品名稱、BIOS、OS 版本與報告時間)。 + /// private static SystemInfo ParseSystemInfo(string html) { var info = new SystemInfo(); @@ -131,11 +120,14 @@ private static SystemInfo ParseSystemInfo(string html) return info; } + /// + /// 自 HTML 解析已安裝電池規格(名稱、製造商、序號、化學材質、設計容量、滿充容量、循環次數)。 + /// private static BatterySpecs ParseBatterySpecs(string html) { var specs = new BatterySpecs(); - // Locate INSTALLED BATTERIES section specifically + // 定位 INSTALLED BATTERIES 區塊 var sectionMatch = Regex.Match(html, @"INSTALLED BATTERIES[\s\S]*?]*>([\s\S]*?)<\/table>", RegexOptions.IgnoreCase); string searchBlock = sectionMatch.Success ? sectionMatch.Groups[1].Value : html; @@ -179,6 +171,9 @@ private static BatterySpecs ParseBatterySpecs(string html) return specs; } + /// + /// 解析歷史電池容量變遷表格。 + /// private static List ParseCapacityHistory(string html) { var list = new List(); @@ -214,6 +209,9 @@ private static List ParseCapacityHistory(string html) return list; } + /// + /// 解析歷史使用時間統計表格(電池使用時間 vs 插電時間)。 + /// private static List ParseUsageHistory(string html) { var list = new List(); @@ -249,6 +247,9 @@ private static List ParseUsageHistory(string html) return list; } + /// + /// 解析電池續航估算表格。 + /// private static List ParseBatteryLifeEstimates(string html) { var list = new List(); @@ -284,6 +285,9 @@ private static List ParseBatteryLifeEstimates(string ht return list; } + /// + /// 解析近期使用歷程紀錄表格。 + /// private static List ParseRecentUsage(string html) { var list = new List(); @@ -321,17 +325,18 @@ private static List ParseRecentUsage(string html) return list; } + /// + /// 計算健康度指標(健康百分比、損耗率、容量差額與狀態等級說明)。 + /// private static HealthMetrics CalculateHealthMetrics(BatterySpecs specs) { - // No design capacity means the battery-report contains no battery at - // all (e.g. a desktop PC, or the battery was removed). Report a neutral - // "no battery" state instead of a misleading 0% "critically degraded" - // score, which is what the old (current/1.0)*100 formula produced. + // 若無設計容量,代表此裝置無電池(如桌上型電腦或電池已拔除) if (specs.DesignCapacity <= 0) { return new HealthMetrics { HasBattery = false, + IsHealthMeasured = false, HealthPercent = 0, WearPercent = 0, CapacityLoss = 0, @@ -341,6 +346,18 @@ private static HealthMetrics CalculateHealthMetrics(BatterySpecs specs) }; } + if (specs.FullChargeCapacity <= 0) + { + return new HealthMetrics + { + HasBattery = true, + IsHealthMeasured = false, + StatusLabel = "無法判定", + StatusClass = "None", + SummaryText = "電池缺少滿電容量資料,無法計算健康度。" + }; + } + double design = specs.DesignCapacity > 0 ? specs.DesignCapacity : 1.0; double current = specs.FullChargeCapacity; @@ -366,6 +383,7 @@ private static HealthMetrics CalculateHealthMetrics(BatterySpecs specs) return new HealthMetrics { + IsHealthMeasured = true, HealthPercent = healthPercent, WearPercent = wearPercent, CapacityLoss = Math.Max(0, specs.DesignCapacity - specs.FullChargeCapacity), @@ -375,14 +393,15 @@ private static HealthMetrics CalculateHealthMetrics(BatterySpecs specs) }; } + /// + /// 根據電池報告數據與指標,自動產生健康與維護診斷提示清單。 + /// private static List GenerateDiagnostics(BatteryReportData report) { var tips = new List(); var metrics = report.HealthMetrics; var specs = report.BatterySpecs; - // Desktop / no-battery machine: skip all degradation warnings that - // would otherwise be driven by a bogus 0% health score. if (!metrics.HasBattery) { tips.Add(new DiagnosticItem @@ -394,6 +413,17 @@ private static List GenerateDiagnostics(BatteryReportData report return tips; } + if (!metrics.IsHealthMeasured) + { + tips.Add(new DiagnosticItem + { + Type = "info", + Title = "健康度無法判定", + Description = "報告缺少設計容量或滿電容量,未顯示虛假的健康百分比。" + }); + return tips; + } + if (metrics.HealthPercent < 70.0) { tips.Add(new DiagnosticItem @@ -445,8 +475,6 @@ private static List GenerateDiagnostics(BatteryReportData report } else { - // Worth saying out loud: a blank cycle count is the pack's - // firmware declining to keep the tally, not a read failure. tips.Add(new DiagnosticItem { Type = "info", @@ -476,18 +504,20 @@ private static List GenerateDiagnostics(BatteryReportData report return tips; } + /// + /// 清除 HTML 標籤、HtmlDecode 並整合連續空白,傳回乾淨文字。 + /// private static string StripTags(string input) { if (string.IsNullOrWhiteSpace(input)) return string.Empty; string clean = Regex.Replace(input, @"<[^>]+>", " "); clean = System.Net.WebUtility.HtmlDecode(clean); - // powercfg splits date ranges across source lines, e.g. - // "2026-07-12\n - 2026-07-18". Those newlines survive into the cell - // text and make a TextBlock render two lines, so collapse every run - // of whitespace into a single space. return Regex.Replace(clean, @"\s+", " ").Trim(); } + /// + /// 從字串中提取所有數字並轉為整數。 + /// private static int ExtractNumber(string str) { if (string.IsNullOrWhiteSpace(str)) return 0; diff --git a/Services/DxgiAdapterService.cs b/Services/DxgiAdapterService.cs index a4733fe..23e609b 100644 --- a/Services/DxgiAdapterService.cs +++ b/Services/DxgiAdapterService.cs @@ -6,26 +6,34 @@ namespace WinBatLens.Services { /// - /// Enumerates physical display adapters via DXGI so that each GPU Engine - /// performance-counter instance (identified by its LUID) can be mapped back - /// to the correct physical GPU (discrete vs. integrated). This is required - /// because on many laptops every counter instance reports "phys_0" and only - /// the LUID distinguishes the adapters. + /// 提供 DXGI (DirectX Graphics Infrastructure) 顯示轉接卡枚舉服務。 + /// 用於將 GPU Engine 效能計數器實例(依 LUID 識別)精確對映至實體顯示卡(獨顯 dGPU 或內顯 iGPU)。 /// public static class DxgiAdapterService { + /// + /// 表示單一 DXGI 顯示轉接卡的規格與 LUID 識別資訊。 + /// public class DxgiAdapter { - /// Lower-cased LUID token as it appears inside a GPU Engine - /// counter instance name, e.g. "luid_0x00000000_0x00010666". + /// 小寫格式之 LUID 鍵值(例如:"luid_0x00000000_0x00010666"),用於對映效能計數器。 public string LuidKey { get; set; } = string.Empty; + + /// 顯示卡名稱描述。 public string Description { get; set; } = string.Empty; + + /// 製造商 Vendor ID(例如 NVIDIA: 0x10DE, AMD: 0x1002, Intel: 0x8086)。 public uint VendorId { get; set; } + + /// 專用視訊記憶體容量(位元組 Bytes)。 public ulong DedicatedVideoMemoryBytes { get; set; } + + /// 是否為軟體模擬顯示轉接卡(如 WARP)。 public bool IsSoftware { get; set; } - public bool IsDiscrete { get; set; } - } + /// 是否判斷為獨立顯示卡 (dGPU)。 + public bool IsDiscrete { get; set; } +} private const uint VendorNvidia = 0x10DE; private const uint VendorAmd = 0x1002; private const uint VendorIntel = 0x8086; @@ -88,9 +96,10 @@ private interface IDXGIAdapter1 private static extern int CreateDXGIFactory1(ref Guid riid, out IntPtr factory); /// - /// Returns all physical adapters known to DXGI. Never throws; returns an - /// empty list if DXGI is unavailable so callers can fall back gracefully. + /// 枚舉系統中所有由 DXGI 識別之實體顯示轉接卡。 + /// 即使失敗亦傳回空清單,不拋出例外。 /// + /// 清單。 public static List GetAdapters() { var result = new List(); @@ -121,9 +130,7 @@ public static List GetAdapters() ulong dedicatedVram = (ulong)desc.DedicatedVideoMemory.ToUInt64(); bool isSoftware = (desc.Flags & DxgiAdapterFlagSoftware) != 0; - // NVIDIA is always discrete on these laptops; otherwise treat - // anything with >= 1 GB dedicated VRAM as discrete. Integrated - // GPUs (AMD/Intel iGPU) carve out far less dedicated memory. + // NVIDIA 視為獨顯;其餘廠商若專用 VRAM >= 1GB 亦判定為獨顯 bool isDiscrete = !isSoftware && (desc.VendorId == VendorNvidia || dedicatedVram >= (ulong)OneGigabyte); diff --git a/Services/DynamicTrayIconService.cs b/Services/DynamicTrayIconService.cs index ac2a9b1..483dd66 100644 --- a/Services/DynamicTrayIconService.cs +++ b/Services/DynamicTrayIconService.cs @@ -8,15 +8,25 @@ namespace WinBatLens.Services { + /// + /// 提供 Windows 系統工作列托盤(Tray Icon)動態圖示繪製服務。 + /// 根據即時功率(瓦特 W)動態渲染清晰數字與色彩(充電綠色、放電紅色),並妥善管理 GDI HICON 資源避免記憶體洩漏。 + /// public static class DynamicTrayIconService { - [DllImport("user32.dll", CharSet = CharSet.Auto)] + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] private static extern bool DestroyIcon(IntPtr handle); private static Icon? _currentCreatedIcon = null; private static string? _lastDrawnText; private static Color _lastDrawnColor; + /// + /// 根據即時電源與功率狀態,動態繪製繪圖字型並更新系統托盤圖示。 + /// + /// WPF/WinForms NotifyIcon 控制項。 + /// 1Hz 即時遙測電源狀態。 public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState state) { if (notifyIcon == null) return; @@ -26,37 +36,28 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat string textToDraw; Color textColor; - // Only ever draw a measured rate. With the pack idle on AC there - // is no real wattage, so the icon shows a dash rather than a - // number that would look like a reading. if (state.IsCharging && state.IsChargeRateMeasured) { - // Charging into the battery, e.g. 56.1W -> 56 in GREEN + // 充電中:綠色顯示充電功率(如 56W) int wattVal = (int)Math.Round(state.ChargingRateW); textToDraw = wattVal > 99 ? "99+" : wattVal.ToString(); - textColor = Color.FromArgb(255, 16, 185, 129); // #10B981 Emerald Green + textColor = Color.FromArgb(255, 16, 185, 129); // #10B981 翡翠綠 } else if (state.IsDischargeRateMeasured) { - // Discharging, e.g. 48.9W -> 49 in RED. Discharge is red - // everywhere else in the app and the colours have to agree. - // This also covers being plugged into a charger that cannot - // keep up: the pack is draining, so the icon says so. + // 放電中:紅色顯示放電功率(如 49W) int wattVal = (int)Math.Round(state.DischargeRateW); textToDraw = wattVal > 99 ? "99+" : wattVal.ToString(); - textColor = Color.FromArgb(255, 244, 63, 94); // #F43F5E Red + textColor = Color.FromArgb(255, 244, 63, 94); // #F43F5E 玫瑰紅 } else { + // 未放電/滿電待機 textToDraw = "–"; - textColor = Color.FromArgb(255, 148, 163, 184); // slate + textColor = Color.FromArgb(255, 148, 163, 184); // 板岩灰 } - // The rounded wattage usually repeats between ticks; skip the - // whole bitmap/font/icon regeneration when nothing changed. - // Key on exactly what is drawn — text and colour — so a state - // change that keeps the digits but changes the colour still - // repaints. + // 數值與顏色未改變時跳過重複繪製,節省 GPU/CPU 資源 if (_currentCreatedIcon != null && textToDraw == _lastDrawnText && textColor == _lastDrawnColor) @@ -64,7 +65,7 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat return; } - // Generate 32x32 transparent bitmap with EXTRA LARGE rounded integer digits + // 產生 32x32 透明 Bitmap 圖元 using (var bitmap = new Bitmap(32, 32)) using (var g = Graphics.FromImage(bitmap)) { @@ -72,7 +73,7 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; g.Clear(Color.Transparent); - // Start from extra large font size for 1-2 digits + // 自訂字型大小調整 float fontSize = 20.0f; Font font = new Font("Segoe UI", fontSize, FontStyle.Bold, GraphicsUnit.Point); @@ -100,17 +101,24 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat g.DrawString(textToDraw, font, textBrush, new RectangleF(0, 0, 32, 32), sf); } - // Create HICON + // 取得 Native HICON 並複製至 Managed Icon,隨後立即銷毀原生 HICON 以防止 GDI 洩漏 IntPtr hIcon = bitmap.GetHicon(); - Icon newIcon = Icon.FromHandle(hIcon); + Icon newIcon; + try + { + using var temporaryIcon = Icon.FromHandle(hIcon); + newIcon = (Icon)temporaryIcon.Clone(); + } + finally + { + DestroyIcon(hIcon); + } - // Set to NotifyIcon notifyIcon.Icon = newIcon; - // Destroy old icon handle to prevent GDI leak + // 釋放舊 Icon if (_currentCreatedIcon != null) { - DestroyIcon(_currentCreatedIcon.Handle); _currentCreatedIcon.Dispose(); } @@ -124,5 +132,16 @@ public static void UpdateTrayIcon(NotifyIcon notifyIcon, RealTimePowerState stat System.Diagnostics.Debug.WriteLine($"UpdateTrayIcon error: {ex.Message}"); } } + + /// + /// 應用程式關閉時釋放最後建立之動態 Icon 資源。 + /// + public static void Dispose() + { + try { _currentCreatedIcon?.Dispose(); } catch { } + _currentCreatedIcon = null; + _lastDrawnText = null; + _lastDrawnColor = default; + } } } diff --git a/Services/GpuInfoService.cs b/Services/GpuInfoService.cs index fcaf895..bc69205 100644 --- a/Services/GpuInfoService.cs +++ b/Services/GpuInfoService.cs @@ -5,13 +5,16 @@ namespace WinBatLens.Services { + /// + /// 提供透過 WMI (Win32_VideoController) 查詢系統已安裝顯示卡之服務。 + /// 包含顯示卡名稱讀取與獨顯 (dGPU) 關鍵字與 VRAM 容量啟發式判讀。 + /// public class GpuInfoService { /// - /// Enumerates display adapters. Only the name and whether each is the - /// discrete GPU are used now, so the query asks for nothing else beyond - /// AdapterRAM, which the discrete-GPU heuristic needs. + /// 枚舉系統中已安裝之顯示轉接卡清單。 /// + /// 清單。 public static List GetInstalledGpus() { var list = new List(); @@ -33,6 +36,7 @@ public static List GetInstalledGpus() gpu.VramBytes = ramBytes; } + // 依廠牌名稱關鍵字與專用 VRAM 判斷是否為獨立顯示卡 string upperName = name.ToUpper(); gpu.IsDiscrete = upperName.Contains("NVIDIA") || upperName.Contains("GEFORCE") || @@ -41,7 +45,7 @@ public static List GetInstalledGpus() upperName.Contains("QUADRO") || (upperName.Contains("RADEON") && !upperName.Contains("GRAPHICS")) || (upperName.Contains("AMD") && !upperName.Contains("TM") && !upperName.Contains("GRAPHICS")) || - gpu.VramBytes >= 1073741824; // >= 1 GB dedicated + gpu.VramBytes >= 1073741824; // >= 1 GB 專用記憶體 list.Add(gpu); } diff --git a/Services/HardwareSensorService.cs b/Services/HardwareSensorService.cs index ab3254b..c175cd7 100644 --- a/Services/HardwareSensorService.cs +++ b/Services/HardwareSensorService.cs @@ -1,29 +1,14 @@ using System; using System.Linq; +using System.Threading; using LibreHardwareMonitor.Hardware; namespace WinBatLens.Services { /// - /// Reads real power/temperature sensors via LibreHardwareMonitor. - /// Every reading is exposed as a nullable: null means this machine - /// genuinely does not report that value, and the caller must fall back to - /// an estimate and say so. Nothing here ever invents a number. + /// 提供基於 LibreHardwareMonitor 之實體硬體感測器(如 dGPU NVML 功耗與電池電壓)讀取服務。 + /// 採非同步背景定時輪詢,並支援託管/待機模式切換(前台 1s / 托盤背景 5s)以最小化 CPU 資源佔用。 /// - /// - /// Measured on the development machine (Ryzen + RTX 3060 Laptop): - /// NVIDIA GPU package power and temperatures work at normal privilege - /// because NVML is a userspace API, but CPU package power comes from RAPL - /// via a kernel driver and reads 0 W unless the process is elevated. The UI - /// does not report CPU power for that reason, so the CPU group is not opened - /// at all — which also keeps the ring-0 driver out of the process. - /// - /// Only sensors something actually displays are swept on the timer. An - /// IHardware.Update() is not free — measured per sweep on this - /// machine: dGPU 15.6 ms of CPU, iGPU 6.2 ms, CPU 3.1 ms, battery 0.0 ms — - /// and at one sweep a second the ones feeding nothing were pure cost. - /// - /// public static class HardwareSensorService { private static Computer? _computer; @@ -33,35 +18,27 @@ public static class HardwareSensorService private static readonly object _sync = new object(); private static System.Threading.Timer? _pollTimer; private static int _polling; + private static int _shuttingDown; - // A sweep is far too slow to sit on the UI thread's 1 s tick (it was - // 85-256 ms when every hardware group was polled, ~16 ms now that only - // the dGPU and the battery are). Polling therefore runs on a background - // timer and the UI only ever reads cached fields. + /// 前台高畫質圖表更新間隔(毫秒)。 private const int RefreshMs = 1000; - // While the window is hidden in the tray the monitoring loop itself - // only samples every 5 s, so sweeping the sensors five times per - // consumed reading was pure waste — and this sweep is the single - // largest piece of background CPU the app spends (~16 ms of it a - // second, forever, on a machine nobody is looking at). + /// 托盤背景節能更新間隔(毫秒)。 private const int IdleRefreshMs = 5000; private static int _currentIntervalMs = RefreshMs; + /// 感測器服務是否已成功初始化。 public static bool IsInitialized { get; private set; } - /// True when discrete GPU package power is actually being reported. - public static bool IsGpuPowerAvailable { get; private set; } - + /// 獨立顯示卡 (dGPU) 實測 Package 功耗(瓦特 W),無數據時為 null。 public static double? DgpuPackageW { get; private set; } - public static double? DgpuTempC { get; private set; } - public static double? BatteryRateW { get; private set; } + + /// 電池端實測電壓(伏特 V),無數據時為 null。 public static double? BatteryVoltageV { get; private set; } /// - /// Opens the sensor stack. Slow (loads a kernel driver on some systems), - /// so call it from the background warmup, never on the UI thread. + /// 初始化感測器堆疊並建立背景輪詢定時器。建議於背景工作執行緒呼叫。 /// public static void Initialize() { @@ -71,12 +48,7 @@ public static void Initialize() try { - // Only the hardware we actually report on. Enabling storage - // and motherboard costs real time on Open() and adds - // sensors we never read. The CPU group is off for the same - // reason plus one more: it is the part that loads the ring-0 - // driver, and RAPL reads 0 W unelevated on this machine - // anyway. CPU load already comes from a PerformanceCounter. + Volatile.Write(ref _shuttingDown, 0); _computer = new Computer { IsCpuEnabled = false, @@ -90,75 +62,59 @@ public static void Initialize() _dGpu = _computer.Hardware.FirstOrDefault(h => h.HardwareType == HardwareType.GpuNvidia) ?? _computer.Hardware.FirstOrDefault(h => h.HardwareType == HardwareType.GpuIntel); - // An AMD-only machine has no NVIDIA/Intel part, so the AMD - // GPU is the discrete one rather than the integrated one. - // Where a discrete part exists the AMD one is integrated, - // and nothing displays its wattage, so it is never polled. _dGpu ??= _computer.Hardware.FirstOrDefault(h => h.HardwareType == HardwareType.GpuAmd); IsInitialized = true; - // Prime the sensors: several report null until the second - // update, which would otherwise look like "unavailable". + // 預熱感測器(避免前兩次採樣為 null) PollOnce(); PollOnce(); - IsGpuPowerAvailable = DgpuPackageW.HasValue; - DisableValueHistory(); int interval = System.Threading.Volatile.Read(ref _currentIntervalMs); _pollTimer = new System.Threading.Timer(_ => PollOnce(), null, interval, interval); - // A SetIdleMode call that landed while the stack was being - // opened found no timer to retarget, so honour it now. interval = System.Threading.Volatile.Read(ref _currentIntervalMs); try { _pollTimer.Change(interval, interval); } catch (ObjectDisposedException) { } } catch (Exception ex) { - // A locked-down machine, a blocked driver or a hostile AV can - // all fail here. The app must still run on formula estimates. System.Diagnostics.Debug.WriteLine($"HardwareSensorService.Initialize failed: {ex.Message}"); + try { _computer?.Close(); } catch { } + _computer = null; + _dGpu = null; + _battery = null; + DgpuPackageW = null; + BatteryVoltageV = null; IsInitialized = false; } } } /// - /// Matches the sweep cadence to how often anything actually reads the - /// values: 1 s while the dashboard is on screen, 5 s once it is hidden - /// in the tray. Safe to call before — the - /// chosen period is remembered and applied when the timer starts. + /// 設定背景待機模式(視窗隱藏至托盤時傳入 true 以降低採樣率至 5 秒)。 /// + /// 是否啟用待機節能模式。 public static void SetIdleMode(bool idle) { int interval = idle ? IdleRefreshMs : RefreshMs; if (System.Threading.Interlocked.Exchange(ref _currentIntervalMs, interval) == interval) return; - // Deliberately outside _sync: Initialize can hold that lock for - // seconds while it opens the sensor stack, and this is called from - // the UI thread on minimize/restore, which must never block on it. - // Timer.Change is itself thread-safe, and a timer that has not been - // created yet picks the interval up when Initialize starts it. var timer = _pollTimer; if (timer == null) return; - // Restoring the window asks for fresh values, so the first fast - // sweep is due immediately rather than a second later. try { timer.Change(idle ? interval : 0, interval); } catch (ObjectDisposedException) { } } /// - /// One sensor sweep. Only ever runs on the polling thread (or on the - /// warmup thread during ), never on the UI - /// thread. Re-entrancy is skipped rather than queued: if a sweep is - /// still running when the timer fires again, that tick is simply lost. + /// 執行單次硬體感測器採樣(於背景 ThreadPool 執行緒運作)。 /// private static void PollOnce() { + if (Volatile.Read(ref _shuttingDown) != 0) return; if (System.Threading.Interlocked.Exchange(ref _polling, 1) == 1) return; try @@ -166,12 +122,10 @@ private static void PollOnce() UpdateAndRead(_dGpu, hw => { DgpuPackageW = ReadPower(hw, "GPU Package", "GPU Power", "GPU PPT"); - DgpuTempC = ReadTemp(hw, "GPU Core", "GPU Hot Spot"); }); UpdateAndRead(_battery, hw => { - BatteryRateW = ReadPower(hw, "Charge/Discharge Rate", "Charge Rate", "Discharge Rate"); var v = hw.Sensors.FirstOrDefault(s => s.SensorType == SensorType.Voltage && s.Value.HasValue && s.Value.Value > 0); BatteryVoltageV = v?.Value; }); @@ -187,11 +141,7 @@ private static void PollOnce() } /// - /// Every ISensor retains a rolling history of past readings — one day's - /// worth by default. Polling once a second across every sensor of the - /// CPU/GPU/battery, that retention grew private bytes by ~20 MB in three - /// minutes. Only the instantaneous value is ever read here, so the - /// window is collapsed to zero and any primed values are dropped. + /// 關閉 LibreHardwareMonitor 感測器的滾動歷史紀錄,降低記憶體消耗。 /// private static void DisableValueHistory() { @@ -217,6 +167,9 @@ static void Apply(IHardware hw) } } + /// + /// 更新硬體感測器並呼叫讀取委派。 + /// private static void UpdateAndRead(IHardware? hw, Action read) { if (hw == null) return; @@ -233,10 +186,7 @@ private static void UpdateAndRead(IHardware? hw, Action read) } /// - /// Returns the first matching power sensor with a usable reading. - /// A sensor present but reading exactly 0 W is treated as unavailable: - /// that is what RAPL reports when the driver could not be loaded, and a - /// powered-on component never genuinely draws 0 W. + /// 自指定的硬體物件中尋找匹配名稱之功耗感測器數值。 /// private static double? ReadPower(IHardware hw, params string[] preferredNames) { @@ -250,28 +200,34 @@ private static void UpdateAndRead(IHardware? hw, Action read) return null; } - private static double? ReadTemp(IHardware hw, params string[] preferredNames) - { - foreach (var name in preferredNames) - { - var s = hw.Sensors.FirstOrDefault(x => - x.SensorType == SensorType.Temperature && - string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase)); - if (s?.Value is float v && v > 0.01f) return Math.Round(v, 1); - } - return null; - } - + /// + /// 停止背景輪詢定時器並關閉 LibreHardwareMonitor 資源。 + /// public static void Shutdown() { + Volatile.Write(ref _shuttingDown, 1); + System.Threading.Timer? timer; + lock (_sync) { - try { _pollTimer?.Dispose(); } catch { } + timer = _pollTimer; _pollTimer = null; + } + + try { timer?.Dispose(); } catch { } + + for (int i = 0; i < 1000 && Volatile.Read(ref _polling) != 0; i++) + Thread.Sleep(1); + lock (_sync) + { try { _computer?.Close(); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Sensor shutdown: {ex.Message}"); } _computer = null; + _dGpu = null; + _battery = null; + DgpuPackageW = null; + BatteryVoltageV = null; IsInitialized = false; } } diff --git a/Services/IconHelper.cs b/Services/IconHelper.cs deleted file mode 100644 index 3810bb0..0000000 --- a/Services/IconHelper.cs +++ /dev/null @@ -1,52 +0,0 @@ -using System; -using System.Drawing; -using System.IO; - -namespace WinBatLens.Services -{ - public class IconHelper - { - public static Icon GetAppIcon(string pngPath) - { - try - { - if (File.Exists(pngPath)) - { - using (var bitmap = new Bitmap(pngPath)) - { - IntPtr hIcon = bitmap.GetHicon(); - return Icon.FromHandle(hIcon); - } - } - } - catch (Exception ex) - { - System.Diagnostics.Debug.WriteLine($"IconHelper error: {ex.Message}"); - } - - return SystemIcons.Application; - } - - public static void EnsureIcoFile(string pngPath, string icoPath) - { - try - { - if (File.Exists(pngPath) && !File.Exists(icoPath)) - { - using (var bitmap = new Bitmap(pngPath)) - { - IntPtr hIcon = bitmap.GetHicon(); - using (var icon = Icon.FromHandle(hIcon)) - { - using (var stream = new FileStream(icoPath, FileMode.Create)) - { - icon.Save(stream); - } - } - } - } - } - catch { } - } - } -} diff --git a/Services/LocalizationService.cs b/Services/LocalizationService.cs index 99ab917..3b56f76 100644 --- a/Services/LocalizationService.cs +++ b/Services/LocalizationService.cs @@ -3,14 +3,24 @@ namespace WinBatLens.Services { + /// + /// 表示應用程式支援之語系(繁體中文與英文)。 + /// public enum AppLanguage { + /// 繁體中文 (Traditional Chinese) TraditionalChinese, + + /// 英文 (English) English } + /// + /// 提供雙語系(繁體中文 / 英文)UI 字串切換與查詢服務。 + /// public class LocalizationService { + /// 目前應用程式顯示語系設定。 public static AppLanguage CurrentLanguage { get; set; } = AppLanguage.TraditionalChinese; private static readonly Dictionary ZhTwStrings = new Dictionary @@ -75,8 +85,6 @@ public class LocalizationService ["TrayTooltip"] = "WinBat Lens - 電池健康度與即時耗電監測", ["TrayBalloonTitle"] = "WinBat Lens 已縮小至托盤", ["TrayBalloonText"] = "程式將在背景持續為您進行即時耗電與電池狀態監測。", - // Duplicate-launch dialogs. {0} is the running version, {1} the one - // being launched. ["InstanceVersionTitle"] = "WinBat Lens - 偵測到不同版本", ["InstanceVersionText"] = "背景已有 WinBat Lens v{0} 正在執行,而您啟動的是 {1}。\n\n要結束執行中的 v{0},改用 {1} 嗎?\n\n選擇「否」則會叫出執行中的 v{0} 視窗。", ["InstanceReplaceFailedTitle"] = "WinBat Lens - 無法切換版本", @@ -155,12 +163,20 @@ public class LocalizationService ["InstanceRunningText"] = "WinBat Lens is already running in the background, so a second copy was not started.\n\nClick its system tray icon to open the main window." }; + /// + /// 根據鍵值與目前選擇的語系取得多國語言文字。 + /// + /// 語系鍵值。 + /// 翻譯後的文字內容,若不存在則傳回原鍵值。 public static string Get(string key) { var dict = CurrentLanguage == AppLanguage.English ? EnUsStrings : ZhTwStrings; return dict.TryGetValue(key, out var val) ? val : key; } + /// + /// 切換目前應用程式的語系設定(繁體中文 <-> 英文)。 + /// public static void ToggleLanguage() { CurrentLanguage = CurrentLanguage == AppLanguage.TraditionalChinese diff --git a/Services/PowerCfgService.cs b/Services/PowerCfgService.cs index e956a12..73edf38 100644 --- a/Services/PowerCfgService.cs +++ b/Services/PowerCfgService.cs @@ -5,26 +5,33 @@ namespace WinBatLens.Services { - public class PowerCfgService + /// + /// 提供呼叫 Windows 系統指令 `powercfg /batteryreport` 產生 HTML 電池報告之服務。 + /// 包含非同步行程控制、10 秒逾時保護與暫存檔自動清理。 + /// + public static class PowerCfgService { + /// + /// 非同步執行 powercfg 指令產生電池報告 HTML 內容。 + /// + /// 包含執行成功與否、HTML 內文與錯誤訊息之元組。 public static async Task<(bool Success, string HtmlContent, string ErrorMessage)> GenerateReportAsync() { return await Task.Run(() => { string tempFile = Path.Combine(Path.GetTempPath(), $"winbat_report_{Guid.NewGuid():N}.html"); - string command = $"/batteryreport /output \"{tempFile}\""; - try { var startInfo = new ProcessStartInfo { FileName = "powercfg", - Arguments = command, UseShellExecute = false, CreateNoWindow = true, - RedirectStandardOutput = true, RedirectStandardError = true }; + startInfo.ArgumentList.Add("/batteryreport"); + startInfo.ArgumentList.Add("/output"); + startInfo.ArgumentList.Add(tempFile); using (var process = Process.Start(startInfo)) { @@ -33,9 +40,10 @@ public class PowerCfgService return (false, string.Empty, "無法啟動 powercfg 行程。"); } - if (!process.WaitForExit(10000)) // 10s timeout + if (!process.WaitForExit(10000)) // 10s 逾時控制 { - try { process.Kill(); } catch { } + try { process.Kill(entireProcessTree: true); } catch { } + try { process.WaitForExit(1000); } catch { } return (false, string.Empty, "powercfg 執行逾時(超過 10 秒),已強制結束。"); } @@ -56,6 +64,11 @@ public class PowerCfgService { return (false, string.Empty, $"執行 powercfg 失敗: {ex.Message}"); } + finally + { + // 自動清理未處理完畢或失敗之暫存 HTML 檔案 + try { if (File.Exists(tempFile)) File.Delete(tempFile); } catch { } + } }); } } diff --git a/Services/PowerSupplyService.cs b/Services/PowerSupplyService.cs index e2acff4..cbf3592 100644 --- a/Services/PowerSupplyService.cs +++ b/Services/PowerSupplyService.cs @@ -4,87 +4,33 @@ namespace WinBatLens.Services { /// - /// Windows' own verdict on whether the attached charger can actually run - /// this machine, from Windows.System.Power.PowerManager. + /// 表示外接充電器/變壓器供電能力評估狀況(透過 Windows.System.Power.PowerManager)。 /// public enum PowerSupplyCapability { - /// The API could not be reached; nothing may be inferred. + /// 無法取得 API 數據或無法推論。 Unknown, - /// Running on battery — no external supply attached. + /// 目前使用電池運作,未連接外接電源。 NotPresent, - /// - /// A supply is attached but cannot meet the system's demand. On a - /// USB-C laptop this is the classic under-powered PD charger. - /// + /// 外接充電器供電不足(例如用 65W PD 充電器推高負載筆電,電池仍持續放電)。 Inadequate, - /// The attached supply covers the system's demand. + /// 外接充電器供電充足,能完全涵蓋系統運作需求。 Adequate, } /// - /// Reads PowerManager.PowerSupplyStatus, the only documented, - /// unelevated signal Windows gives about the charger itself rather than - /// about the battery. + /// 提供讀取 Windows 原生 WinRT PowerManager.PowerSupplyStatus 之服務。 + /// 無需最高管理權限即可精確偵測外接充電器是否供電不足(Inadequate PD Supply)。 /// - /// - /// WHY THIS IS THE BEST AVAILABLE CHARGER SIGNAL - /// - /// The obvious thing to want is the USB-C Power Delivery contract — the - /// negotiated volts and amps that give a real adapter wattage. It is not - /// obtainable from a normal user-mode process on Windows, which was - /// verified against this machine (ASUS ROG Zephyrus G14, charging over - /// USB-C) rather than assumed: - /// - /// - /// The UCM-UCSI ACPI device (ACPI\USBC000) is present, but the - /// only device interfaces it registers are absent from the public SDK — - /// they are driver-to-driver, with no documented user-mode IOCTL. - /// BATTERY_USB_CHARGER_STATUS in poclass.h does carry the PD - /// contract flag, the port's mA and its mV — but it travels in the - /// IOCTL_BATTERY_SET_INFORMATION direction, pushed in by a Charging - /// Arbitration Driver that desktop laptops do not have. There is no - /// matching query level. - /// POWER_ADAPTER_STATUS.MaxOutputPower is the adapter's rated - /// wattage, but batclass.h only exposes it through a kernel-mode adapter - /// miniclass callback. The generic Microsoft AC Adapter driver on - /// ACPI\ACPI0003 does not surface it. - /// The battery's Customized I/O levels, the escape hatch for OEM - /// values, answer SupportedInputs = 0 here — nothing exposed. - /// ASUS' own ATK WMI knows the charge source, but every query to it - /// is access-denied unelevated, and this app deliberately runs - /// unelevated. - /// - /// - /// So no adapter wattage is reported anywhere in this app: consistent with - /// the rest of the dashboard, an unobtainable number is left out rather - /// than estimated. What Windows will answer is whether the supply is - /// keeping up, and that pairs with the pack's own measured rate to tell the - /// whole story — see . - /// - /// - /// Reached through raw WinRT activation instead of the C# projection on - /// purpose. Using the projection would mean moving the project from - /// net8.0-windows to a net8.0-windows10.0.x target, which - /// drags the whole Windows SDK projection assembly into a single-file - /// bundle whose size and cold-start time this project measures and tunes. - /// One IID and one vtable slot cost nothing by comparison. Measured on this - /// machine: 0.022 us per read, so it is taken fresh every tick with no - /// caching. - /// - /// public static class PowerSupplyService { private const string PowerManagerClassName = "Windows.System.Power.PowerManager"; private static Guid IID_IPowerManagerStatics = new("1394825D-62CE-4364-98D5-AA28C7FBD15B"); - // IInspectable takes vtable slots 0-5. IPowerManagerStatics then - // declares EnergySaverStatus (get/add/remove) at 6-8, BatteryStatus at - // 9-11, and PowerSupplyStatus' getter at 12. private const int VtblSlotGetPowerSupplyStatus = 12; private const int RO_INIT_MULTITHREADED = 1; @@ -97,32 +43,22 @@ public static class PowerSupplyService private static GetEnumProperty? _getPowerSupplyStatus; private static bool _attempted; - /// True once the WinRT factory has been obtained. - public static bool IsAvailable => _getPowerSupplyStatus != null; - /// - /// Obtains the activation factory. Slow enough (COM/WinRT activation) - /// to belong on the background warmup thread rather than the first UI - /// tick, like the rest of the sensor stack. + /// 初始化 WinRT PowerManager 啟動處理器(建議於背景 ThreadPool 執行緒執行)。 /// public static void Initialize() { lock (_sync) { - // Nothing is held, so either this is the first call or a - // previous Shutdown() parked the service; both want a fresh - // activation. When a factory is already held this is a no-op, - // so calling Initialize twice cannot leak a second reference. if (_factory == IntPtr.Zero) _attempted = false; EnsureFactory(); } } /// - /// Windows' current verdict on the attached supply. Returns - /// — never a guess — if the - /// API is unavailable or the call fails. + /// 取得外接充電器供電能力評估狀況(Adequate、Inadequate 或 NotPresent)。 /// + /// 枚舉。 public static PowerSupplyCapability GetStatus() { GetEnumProperty? getter; @@ -141,7 +77,6 @@ public static PowerSupplyCapability GetStatus() { if (getter(factory, out int value) != 0) return PowerSupplyCapability.Unknown; - // Windows.System.Power.PowerSupplyStatus return value switch { 0 => PowerSupplyCapability.NotPresent, @@ -157,6 +92,9 @@ public static PowerSupplyCapability GetStatus() } } + /// + /// 關閉並釋放 COM / WinRT 工廠物件。 + /// public static void Shutdown() { lock (_sync) @@ -167,15 +105,13 @@ public static void Shutdown() _factory = IntPtr.Zero; } _getPowerSupplyStatus = null; - - // Stays parked: a read arriving after shutdown (a late timer - // tick during teardown) must answer Unknown rather than quietly - // activating COM again on the way out. _attempted = true; } } - /// Callers hold . + /// + /// 初始化 WinRT Activation Factory COM 介面指標。 + /// private static void EnsureFactory() { if (_attempted) return; @@ -184,11 +120,6 @@ private static void EnsureFactory() IntPtr classId = IntPtr.Zero; try { - // S_FALSE (already initialised) and RPC_E_CHANGED_MODE (the WPF - // UI thread is an STA) are both fine: all we need is that the - // calling thread belongs to some apartment. PowerManager is - // declared agile, so the factory is safe to call from any - // thread once obtained. RoInitialize(RO_INIT_MULTITHREADED); if (WindowsCreateString(PowerManagerClassName, PowerManagerClassName.Length, out classId) != 0) @@ -211,9 +142,6 @@ private static void EnsureFactory() } catch (Exception ex) { - // A stripped-down or future Windows that no longer activates - // this class must leave the app running on battery telemetry - // alone, so failure here is silent and permanent. System.Diagnostics.Debug.WriteLine($"PowerSupplyService.EnsureFactory: {ex.Message}"); } finally @@ -225,6 +153,7 @@ private static void EnsureFactory() } } + #region WinRT P/Invoke [DllImport("combase.dll")] private static extern int RoInitialize(int initType); @@ -236,5 +165,6 @@ private static void EnsureFactory() [DllImport("combase.dll")] private static extern int RoGetActivationFactory(IntPtr activatableClassId, ref Guid iid, out IntPtr factory); + #endregion } } diff --git a/Services/RealTimePowerHistoryService.cs b/Services/RealTimePowerHistoryService.cs index 018bae6..966281b 100644 --- a/Services/RealTimePowerHistoryService.cs +++ b/Services/RealTimePowerHistoryService.cs @@ -8,21 +8,31 @@ namespace WinBatLens.Services { + /// + /// 提供即時功耗與插拔電事件歷史紀錄之管理、UI 綁定集合 (ObservableCollection) 與 CSV 匯出服務。 + /// public class RealTimePowerHistoryService { private static readonly ObservableCollection _records = new ObservableCollection(); private static bool? _lastAcStatus = null; private static DateTime _lastSampleTime = DateTime.MinValue; + + /// 最多保留之歷史事件紀錄筆數上限。 private const int MAX_RECORDS = 500; + /// 可供 UI DataGrid 綁定之歷史紀錄集合。 public static ObservableCollection Records => _records; + /// + /// 根據 1Hz 即時電源狀態,判定插拔電事件或定時(5 秒)記錄歷史採樣。 + /// + /// 目前即時電源狀態。 public static void AddRecordFromPowerState(RealTimePowerState state) { DateTime now = DateTime.Now; bool isAc = state.IsAcOnline; - // 1. Detect AC Plug in / Plug out transition event + // 1. 偵測 AC 市電插拔狀態切換事件 if (_lastAcStatus.HasValue && _lastAcStatus.Value != isAc) { string eventTitle = isAc ? "🔌 連接 AC 市電 (開始充電)" : "🔋 拔除電源 (開始電池放電)"; @@ -48,7 +58,7 @@ public static void AddRecordFromPowerState(RealTimePowerState state) _lastAcStatus = isAc; - // 2. Periodic sample (every 5 seconds) + // 2. 定時週期採樣(每 5 秒一次) if ((now - _lastSampleTime).TotalSeconds >= 5.0) { _lastSampleTime = now; @@ -63,10 +73,6 @@ public static void AddRecordFromPowerState(RealTimePowerState state) } else if (state.IsChargerDeficit) { - // Being plugged in is not the same as being powered. When - // the charger is out-run the pack drains anyway, and the - // log has to say so — "AC 供電 / 市電正常" alongside a real - // non-zero discharge figure would contradict itself. eventType = "⚠️ 外接電源不足"; badgeClass = "Danger"; } @@ -99,12 +105,11 @@ public static void AddRecordFromPowerState(RealTimePowerState state) } } + /// + /// 跨執行緒安全插入紀錄至 ObservableCollection。 + /// private static void AddRecord(PowerHistoryRecord record) { - // Callers are already on the UI thread (the monitoring loop hands - // its snapshot over before touching anything). Dispatcher.Invoke - // still builds a DispatcherOperation and walks the queue in that - // case, so the same-thread path is taken directly. var dispatcher = App.Current?.Dispatcher; if (dispatcher == null) return; @@ -121,6 +126,9 @@ private static void Insert(PowerHistoryRecord record) } } + /// + /// 清空所有歷史紀錄。 + /// public static void ClearHistory() { var dispatcher = App.Current?.Dispatcher; @@ -130,12 +138,19 @@ public static void ClearHistory() else dispatcher.Invoke(() => _records.Clear()); } - // Quotes a CSV field, doubling any embedded quote per RFC 4180. + /// + /// 符合 RFC 4180 標準之 CSV 欄位跳脫轉義。 + /// private static string Csv(string? value) { return "\"" + (value ?? string.Empty).Replace("\"", "\"\"") + "\""; } + /// + /// 將目前歷史紀錄匯出為 UTF-8 CSV 檔案。 + /// + /// 匯出檔案路徑。 + /// 匯出成功傳回 true,失敗傳回 false。 public static bool ExportToCsv(string filePath) { try diff --git a/Services/RealTimePowerService.cs b/Services/RealTimePowerService.cs index f5117fd..33eb1f8 100644 --- a/Services/RealTimePowerService.cs +++ b/Services/RealTimePowerService.cs @@ -8,6 +8,10 @@ namespace WinBatLens.Services { + /// + /// 提供 1Hz 全系統即時電源與硬體功耗遙測整合主服務。 + /// 整合 CPU 使用率、iGPU/dGPU 負載與功耗 (DXGI/NVML)、電池端原生充放電功率 (IOCTL) 與螢幕亮度等數據。 + /// public class RealTimePowerService { [StructLayout(LayoutKind.Sequential)] @@ -179,12 +183,7 @@ static RealTimePowerService() } /// - /// - /// Warms up everything the first monitoring tick would otherwise pay - /// for on the UI thread: the static constructor (PerformanceCounter and - /// GPU WMI enumeration), the GPU Engine counter category, and the slow - /// WMI caches. Intended to be called once from a background thread - /// before the 1-second timer starts. + /// 預熱監測服務所需的效能計數器、GPU 分類與底層硬體感測器(應於背景 ThreadPool 執行緒呼叫)。 /// public static void Initialize() { @@ -206,6 +205,10 @@ public static void Initialize() catch { } } + /// + /// 採樣並傳回當前 1Hz 全系統即時功耗與硬體狀態模型。 + /// + /// 即時狀態。 public static RealTimePowerState GetCurrentPowerState() { var state = new RealTimePowerState(); @@ -262,56 +265,10 @@ public static RealTimePowerState GetCurrentPowerState() state.DgpuStatusText = "無獨立顯示卡"; } - // Legacy total GPU - state.GpuUsagePercent = Math.Max(iGpuVal, dGpuVal); - state.GpuName = state.HasDiscreteGpu ? state.DgpuName : state.IgpuName; - - // 3. Disk (SSD / HDD) usage and throughput - try - { - if (_diskTimeCounter != null) - { - double dTime = _diskTimeCounter.NextValue(); - state.DiskUsagePercent = Math.Min(100.0, Math.Round(dTime, 1)); - } - - if (_diskBytesCounter != null) - { - double bytesPerSec = _diskBytesCounter.NextValue(); - double mbps = Math.Round(bytesPerSec / (1024.0 * 1024.0), 1); - state.DiskReadWriteMbps = mbps; - state.DiskStatusText = $"即時吞吐量: {mbps:F1} MB/s"; - } - } - catch - { - state.DiskUsagePercent = 0.0; - state.DiskReadWriteMbps = 0.0; - state.DiskStatusText = "即時吞吐量: --"; - } - // 4. Screen brightness state.ScreenBrightnessPercent = GetScreenBrightnessPercent(); state.IsBrightnessMeasured = _brightnessMeasured; - // 5. Wi-Fi throughput - state.WifiThroughputKbps = GetWifiThroughputKbps(); - - // 6. Memory (RAM) usage - try - { - var ramInfo = GetSystemRamInfo(); - state.RamUsageGB = ramInfo.UsedGb; - state.TotalRamGB = ramInfo.TotalGb; - state.RamUsagePercent = Math.Round((ramInfo.UsedGb / ramInfo.TotalGb) * 100.0, 1); - } - catch - { - state.TotalRamGB = 0.0; - state.RamUsageGB = 0.0; - state.RamUsagePercent = 0.0; - } - // There is no system-total wattage. Screen, disk, Wi-Fi, RAM and // chipset power were all linear guesses over utilisation and are // gone; summing them produced an equally invented total. On battery @@ -559,19 +516,7 @@ public static RealTimePowerState GetCurrentPowerState() } } - // 10. Load rating, from utilisation only — no wattage involved. - if (state.CpuUsagePercent > 70.0 || state.DgpuUsagePercent > 40.0) - { - state.SystemPowerLoadStatus = "高負載 (高耗電)"; - } - else if (state.CpuUsagePercent > 30.0 || state.IgpuUsagePercent > 30.0) - { - state.SystemPowerLoadStatus = "中度運算"; - } - else - { - state.SystemPowerLoadStatus = "輕度省電"; - } + // 11. Battery Physical Telemetry (Voltage, Current, Temperature). // The pack voltage arrives in the same IOCTL the rate came from, so diff --git a/Services/SingleInstanceService.cs b/Services/SingleInstanceService.cs index da6aec8..fbeda25 100644 --- a/Services/SingleInstanceService.cs +++ b/Services/SingleInstanceService.cs @@ -6,13 +6,8 @@ namespace WinBatLens.Services { /// - /// Keeps exactly one WinBat Lens alive per user session. - /// - /// A duplicate launch used to call Shutdown() and vanish, which looks - /// identical to the app failing to start. Instead it now hands the running - /// instance the foreground so its window appears — which is what - /// double-clicking the shortcut was asking for — and, when the two builds - /// are different versions, offers to replace the running one. + /// 提供單一執行個體 (Single-Instance) 檢測、重複開啟聚焦喚醒與跨版本更換啟動機制服務。 + /// 使用具名 Mutex 與 EventWaitHandle 進行跨行程協調。 /// public static class SingleInstanceService { diff --git a/Services/StartupService.cs b/Services/StartupService.cs index d17fdc4..a3ee087 100644 --- a/Services/StartupService.cs +++ b/Services/StartupService.cs @@ -4,11 +4,21 @@ namespace WinBatLens.Services { + /// + /// 提供 Windows 登錄檔 (HKCU Run 鍵值) 開機自動啟動與背景模式參數選單管理服務。 + /// public class StartupService { private const string REG_RUN_KEY = @"Software\Microsoft\Windows\CurrentVersion\Run"; private const string APP_NAME = "WinBatLens"; + /// 開機自啟動時傳遞之背景常駐參數旗標(最小化至系統工作列托盤)。 + public const string BackgroundArgument = "--background"; + + /// + /// 檢查當前使用者是否已啟用開機自動啟動。 + /// + /// 若登錄檔中已設定啟動項目則傳回 true,否則傳回 false。 public static bool IsAutoStartEnabled() { try @@ -29,6 +39,11 @@ public static bool IsAutoStartEnabled() return false; } + /// + /// 設定或取消開機自動啟動(包含 --background 參數)。 + /// + /// true 為啟用開機自啟動,false 為取消。 + /// 設定成功傳回 true,失敗傳回 false。 public static bool SetAutoStart(bool enable) { try @@ -42,7 +57,7 @@ public static bool SetAutoStart(bool enable) string? exePath = Process.GetCurrentProcess().MainModule?.FileName; if (!string.IsNullOrEmpty(exePath)) { - key.SetValue(APP_NAME, $"\"{exePath}\""); + key.SetValue(APP_NAME, $"\"{exePath}\" {BackgroundArgument}"); return true; } } diff --git a/WinBatLens.csproj b/WinBatLens.csproj index 2c44037..1f0d558 100644 --- a/WinBatLens.csproj +++ b/WinBatLens.csproj @@ -13,9 +13,9 @@ WinBat Lens WinBat Lens Windows 電池健康度視覺化儀表板與 powercfg /batteryreport 解析工具 - 1.1.4 - 1.1.4.0 - 1.1.4.0 + 1.1.5 + 1.1.5.0 + 1.1.5.0 diff --git a/build-release.ps1 b/build-release.ps1 index bac959e..e7c2a84 100644 --- a/build-release.ps1 +++ b/build-release.ps1 @@ -4,7 +4,8 @@ param( [string]$TimestampUrl = "http://timestamp.digicert.com" ) -# WinBat Lens Release Packaging Script +# WinBat Lens 自動化發布與打包腳本 (Powershell) +# 包含 .NET 獨立單一執行檔發布、便攜版 ZIP 打包、Inno Setup 安裝檔編譯與數位簽署驗證 Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" diff --git a/installer/WinBatLens.iss b/installer/WinBatLens.iss index d0f7b31..25719c5 100644 --- a/installer/WinBatLens.iss +++ b/installer/WinBatLens.iss @@ -86,7 +86,7 @@ english.StartupOptions=Startup options: ; this installer task stay in sync. ISCC emits a UsedUserAreasWarning for this ; in admin install mode; that is expected and harmless when the user elevates ; with their own account, which is the normal UAC-consent path. -Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "WinBatLens"; ValueData: """{app}\{#MyAppExeName}"""; Tasks: autostart; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Run"; ValueType: string; ValueName: "WinBatLens"; ValueData: """{app}\{#MyAppExeName}"" --background"; Tasks: autostart; Flags: uninsdeletevalue [Run] Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent diff --git a/tests/WinBatLens.Tests/BatteryReportParserTests.cs b/tests/WinBatLens.Tests/BatteryReportParserTests.cs index a34e16c..dcac520 100644 --- a/tests/WinBatLens.Tests/BatteryReportParserTests.cs +++ b/tests/WinBatLens.Tests/BatteryReportParserTests.cs @@ -1,8 +1,12 @@ +using System; using WinBatLens.Services; using Xunit; namespace WinBatLens.Tests; +/// +/// 電池報告 HTML 解析器 (BatteryReportParser) 之 xUnit 單元測試套件。 +/// public sealed class BatteryReportParserTests { private const string Report = """ @@ -29,6 +33,9 @@ public sealed class BatteryReportParserTests """; + /// + /// 測試正確解析規格、歷史紀錄並轉置換行空白。 + /// [Fact] public void ParsesSpecsHistoryAndNormalizesWhitespace() { @@ -45,6 +52,9 @@ public void ParsesSpecsHistoryAndNormalizesWhitespace() Assert.Equal(73.6, history.HealthPercent, 1); } + /// + /// 測試結合驅動程式實測 PackInfo 進行數值覆蓋。 + /// [Fact] public void OverlaysCompatibleLiveDriverPackInfo() { @@ -68,6 +78,9 @@ public void OverlaysCompatibleLiveDriverPackInfo() Assert.Equal(73.7, report.HealthMetrics.HealthPercent, 1); } + /// + /// 測試當報告單位為 mAh 時,不強行混合 mWh 的驅動實測數據。 + /// [Fact] public void DoesNotMixDriverMwhWithReportMah() { @@ -92,6 +105,32 @@ public void DoesNotMixDriverMwhWithReportMah() Assert.False(report.BatterySpecs.CapacitiesFromDriver); } + /// + /// 測試當滿電容量缺失時,不會誤判為 0% 健康度。 + /// + [Fact] + public void DoesNotTreatMissingFullChargeCapacityAsZeroPercentHealth() + { + const string incompleteReport = """ +

INSTALLED BATTERIES

+ + +
DESIGN CAPACITY50,000 mWh
FULL CHARGE CAPACITY--
+ """; + + var report = BatteryReportParser.Parse(incompleteReport); + + Assert.True(report.HealthMetrics.HasBattery); + Assert.False(report.HealthMetrics.IsHealthMeasured); + Assert.Equal("無法判定", report.HealthMetrics.StatusLabel); + var diagnostic = Assert.Single(report.Diagnostics); + Assert.Equal("info", diagnostic.Type); + Assert.Equal("健康度無法判定", diagnostic.Title); + } + + /// + /// 測試無電池裝置(例如桌上型電腦)時顯示中性診斷訊息。 + /// [Fact] public void ReportsNeutralDiagnosticsWhenNoBatteryExists() {