From 2b9593106c643c52174b9ed38320ea01feedd5fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:03:05 +0000 Subject: [PATCH 01/43] feat(cli): lower windows floor to build 14393 with support tiers The CLI refused to start below build 26200 (Windows 11 25H2), which locked out exactly the older hardware pcHealth was written for. Nothing in the CLI needs 25H2: its real floor is PowerShell 7's own, Windows 10 1607. Start.ps1 now reports the tier instead of exiting -- recommended (>= 26200), supported (>= 19045), legacy (>= 14393, warns and continues), blocked below that. app.ps1 keeps only the hard floor as a safety net so a normal launch does not print the same advisory twice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- src/CLI/Start.ps1 | 27 ++++++++++++++++++++++++--- src/CLI/app.ps1 | 8 +++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/CLI/Start.ps1 b/src/CLI/Start.ps1 index a8eb40b..da62499 100644 --- a/src/CLI/Start.ps1 +++ b/src/CLI/Start.ps1 @@ -39,13 +39,34 @@ if ($onLinux) { # -- Windows: build check, elevate, relaunch in PS7 --------------------------- if (-not $onLinux) { + # Windows support tiers -- see README.md and SECURITY.md. + # >= 26200 recommended : the build every release is tested on + # >= 19045 supported : Windows 10 22H2 and up; also the GUI's floor + # >= 14393 legacy : runs, but untested -- winget may be missing + # < 14393 blocked : PowerShell 7 does not run on these builds + # The floor is PowerShell 7's own, not a preference: below 1607 there is no + # pwsh to bootstrap, so the CLI cannot start no matter what pcHealth allows. + $recommendedBuild = 26200 # Windows 11 25H2 + $supportedBuild = 19045 # Windows 10 22H2 + $hardMinimumBuild = 14393 # Windows 10 1607 $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 26200) { + + if ($build -lt $hardMinimumBuild) { Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build 26200 (Windows 11 version 25H2)." -ForegroundColor Red - Write-Host " https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information" -ForegroundColor DarkGray + Write-Host " Minimum required: build $hardMinimumBuild (Windows 10 version 1607)," -ForegroundColor Red + Write-Host " the oldest build PowerShell 7 supports." -ForegroundColor Red + Write-Host " https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows" -ForegroundColor DarkGray Read-Host 'Press Enter to exit' exit 1 + } elseif ($build -lt $supportedBuild) { + Write-Host '' + Write-Host "[!] Legacy Windows build $build -- below the supported floor of $supportedBuild (10 22H2)." -ForegroundColor Yellow + Write-Host " pcHealth continues, but this build is not tested. Tools that need winget" -ForegroundColor Yellow + Write-Host " stay unavailable until winget is installed (Tools > Repair Winget)." -ForegroundColor DarkGray + } elseif ($build -lt $recommendedBuild) { + Write-Host '' + Write-Host "[!] Windows build $build is supported; $recommendedBuild (11 25H2) is recommended." -ForegroundColor Yellow + Write-Host " https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information" -ForegroundColor DarkGray } $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( diff --git a/src/CLI/app.ps1 b/src/CLI/app.ps1 index 94ef4ab..e8a6bcc 100644 --- a/src/CLI/app.ps1 +++ b/src/CLI/app.ps1 @@ -28,11 +28,13 @@ if ($IsLinux) { $Global:PcPlatformLabel = 'Linux' } elseif ($IsWindows) { # Also checked in Start.ps1 before elevation; repeated here as safety net. + # Only the hard floor is enforced here -- the tier warnings live in Start.ps1 + # so a normal launch does not print them twice. $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 26200) { + if ($build -lt 14393) { Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build 26200 (Windows 11 version 25H2)." -ForegroundColor Red - Write-Host " Please upgrade your system." -ForegroundColor Yellow + Write-Host " Minimum required: build 14393 (Windows 10 version 1607)." -ForegroundColor Red + Write-Host " PowerShell 7 does not support older builds." -ForegroundColor Yellow exit 1 } $Global:PcPlatform = 'Windows' From 67aa01b91a07f107f9a4bc5926b620e64270a68c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:03:05 +0000 Subject: [PATCH 02/43] fix(cli): handle a missing winget instead of throwing winget ships with Windows 10 1809 and later, so on the legacy tier -- and on LTSC images at any build -- it is absent. A missing native command throws CommandNotFoundException under $ErrorActionPreference = 'Stop', taking the whole menu down rather than just the tool the user picked. Test-PcWinget reports it and points at Repair Winget; the Programs menu checks inline so the absence renders through the existing winget-result path. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- src/CLI/menus/Helpers.ps1 | 15 +++++++++++++++ src/CLI/menus/Programs.ps1 | 27 ++++++++++++++++++--------- src/CLI/tools/Invoke-HPUpdate.ps1 | 2 ++ src/CLI/tools/Invoke-SystemUpdate.ps1 | 2 ++ 4 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/CLI/menus/Helpers.ps1 b/src/CLI/menus/Helpers.ps1 index fb647cb..e9c3ad8 100644 --- a/src/CLI/menus/Helpers.ps1 +++ b/src/CLI/menus/Helpers.ps1 @@ -102,6 +102,21 @@ function Get-PcPackageManager { return [PSCustomObject]($definitions[$name] + @{ Cmd = $name }) } +# Windows counterpart of Get-PcPackageManager: reports whether winget is usable. +# winget ships with Windows 10 1809 and later, so on the legacy tier -- and on +# LTSC images and freshly deployed systems at any build -- it is simply absent. +# A missing native command throws under $ErrorActionPreference = 'Stop', which +# would take the whole menu down instead of just the tool the user picked. +function Test-PcWinget { + if (Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) { return $true } + + Write-Host "`n[!!] winget is not available on this system." -ForegroundColor Red + Write-Host " It ships with Windows 10 1809 and later; older or stripped-down" -ForegroundColor Yellow + Write-Host " installations need it added separately." -ForegroundColor Yellow + Write-Host " Try 'Repair Winget' in the Tools menu.`n" -ForegroundColor DarkGray + return $false +} + # Opens a URL in the user's browser. # Start-Process cannot launch a URL on Linux -- it tries to exec it as a file -- # so hand the address to xdg-open, and drop privileges so the browser lands in diff --git a/src/CLI/menus/Programs.ps1 b/src/CLI/menus/Programs.ps1 index c136c68..af7f68b 100644 --- a/src/CLI/menus/Programs.ps1 +++ b/src/CLI/menus/Programs.ps1 @@ -156,10 +156,16 @@ function Show-WindowsProgramsMenu { '1' { Clear-PcHost Write-Host "[>>] Checking for updates for $($pkg.Name)...`n" -ForegroundColor Yellow - $proc = Start-Process winget ` - -ArgumentList "upgrade --id $($pkg.Id) --accept-source-agreements --accept-package-agreements" ` - -Wait -PassThru -NoNewWindow - $result = Get-WingetResult $proc.ExitCode + # Checked here rather than via Test-PcWinget so a missing winget + # renders through the same result path as a winget failure. + $result = if (-not (Get-Command winget -CommandType Application -ErrorAction SilentlyContinue)) { + @{ Ok = $false; Message = 'winget is not available on this system.'; SuggestRepair = $true } + } else { + $proc = Start-Process winget ` + -ArgumentList "upgrade --id $($pkg.Id) --accept-source-agreements --accept-package-agreements" ` + -Wait -PassThru -NoNewWindow + Get-WingetResult $proc.ExitCode + } if ($result.Ok) { Write-Host "`n[OK] $($pkg.Name) updated." -ForegroundColor Green } else { @@ -191,11 +197,14 @@ function Show-WindowsProgramsMenu { Clear-PcHost Write-Host "[>>] Installing $($pkg.Name)...`n" -ForegroundColor Yellow - $proc = Start-Process winget ` - -ArgumentList "install --id $($pkg.Id) --accept-source-agreements --accept-package-agreements" ` - -Wait -PassThru -NoNewWindow - - $result = Get-WingetResult $proc.ExitCode + $result = if (-not (Get-Command winget -CommandType Application -ErrorAction SilentlyContinue)) { + @{ Ok = $false; Message = 'winget is not available on this system.'; SuggestRepair = $true } + } else { + $proc = Start-Process winget ` + -ArgumentList "install --id $($pkg.Id) --accept-source-agreements --accept-package-agreements" ` + -Wait -PassThru -NoNewWindow + Get-WingetResult $proc.ExitCode + } if ($result.Ok) { Write-Host "`n[OK] $($pkg.Name) installed." -ForegroundColor Green $allApps = @(Get-InstalledApp) diff --git a/src/CLI/tools/Invoke-HPUpdate.ps1 b/src/CLI/tools/Invoke-HPUpdate.ps1 index d19aa87..e3dbc44 100644 --- a/src/CLI/tools/Invoke-HPUpdate.ps1 +++ b/src/CLI/tools/Invoke-HPUpdate.ps1 @@ -4,6 +4,8 @@ # Installs HP Image Assistant which detects and updates HP-specific drivers. # ============================================================================ +if (-not (Test-PcWinget)) { return } + # Warn if this does not appear to be an HP device so users don't install # unnecessary software on non-HP machines. $manufacturer = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).Manufacturer diff --git a/src/CLI/tools/Invoke-SystemUpdate.ps1 b/src/CLI/tools/Invoke-SystemUpdate.ps1 index fb1f40f..e3378a7 100644 --- a/src/CLI/tools/Invoke-SystemUpdate.ps1 +++ b/src/CLI/tools/Invoke-SystemUpdate.ps1 @@ -4,6 +4,8 @@ # Upgrades all installed winget packages. # ============================================================================ +if (-not (Test-PcWinget)) { return } + Write-Host "`nDetecting updatable packages...`n" -ForegroundColor Cyan winget upgrade From 5852a99b06806ab1bfb97e56ebd6eca33d3974f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:03:05 +0000 Subject: [PATCH 03/43] feat(gui): support windows 10 22h2 as the gui floor TargetPlatformMinVersion was pinned at 10.0.26100.0 while Start.ps1 already allowed build 19045, so the launcher promised something the build did not. Lower it to 10.0.19041.0 -- the nearest real SDK version below WinUI 3's own 22H2 floor -- and keep TargetFramework on the newest SDK. Title bar customization is Windows 11 only, so extend into it only where AppWindowTitleBar.IsCustomizationSupported() is true; Windows 10 keeps the system caption. Below 19045 the launcher now points at the CLI, which runs there, instead of only refusing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- src/GUI/Start.ps1 | 11 +++++++---- src/GUI/pcHealth/MainWindow.xaml.cs | 8 +++++++- src/GUI/pcHealth/pcHealth.csproj | 7 +++++-- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/GUI/Start.ps1 b/src/GUI/Start.ps1 index c20365f..8457b83 100644 --- a/src/GUI/Start.ps1 +++ b/src/GUI/Start.ps1 @@ -15,15 +15,18 @@ if ($IsLinux -or $IsMacOS) { exit 1 } -# Recommended and hard minimum Windows build versions (see README.md) +# Recommended and hard minimum Windows build versions (see README.md). +# The GUI floor sits above the CLI's: WinUI 3 does not render below 22H2, so +# older machines are pointed at the CLI rather than blocked outright. $recommendedBuild = 26200 # 25H2+ -$hardMinimumBuild = 19045 # 22H2 (hard minimum) +$hardMinimumBuild = 19045 # 22H2 (hard minimum -- WinUI 3's own floor) $build = [System.Environment]::OSVersion.Version.Build if ($build -lt $hardMinimumBuild) { - Write-Host "[!!] pcHealth requires at least Windows build $hardMinimumBuild (22H2)." -ForegroundColor Red + Write-Host "[!!] The pcHealth GUI requires at least Windows build $hardMinimumBuild (22H2)." -ForegroundColor Red Write-Host " Your build: $build" -ForegroundColor Red - Write-Host " Update Windows and try again." -ForegroundColor Yellow + Write-Host " WinUI 3 does not run on older builds." -ForegroundColor Yellow + Write-Host " Use the CLI instead: .\src\CLI\Start.ps1" -ForegroundColor Yellow Read-Host 'Press Enter to exit' exit 1 } elseif ($build -lt $recommendedBuild) { diff --git a/src/GUI/pcHealth/MainWindow.xaml.cs b/src/GUI/pcHealth/MainWindow.xaml.cs index 3e7236b..cbf97c9 100644 --- a/src/GUI/pcHealth/MainWindow.xaml.cs +++ b/src/GUI/pcHealth/MainWindow.xaml.cs @@ -1,3 +1,4 @@ +using Microsoft.UI.Windowing; using NLog; using pcHealth.Pages; using pcHealth.Services; @@ -30,7 +31,12 @@ public MainWindow() } AppWindow.Resize(new SizeInt32(1100, 720)); - ExtendsContentIntoTitleBar = true; + + // Title bar customization is Windows 11 only -- IsCustomizationSupported() + // returns false on Windows 10, where the fallback behaviour differs per + // Windows App SDK version. Keep the system caption there instead. + if (AppWindowTitleBar.IsCustomizationSupported()) + ExtendsContentIntoTitleBar = true; var iconPath = Path.Combine(AppContext.BaseDirectory, "Assets", "pcHealth.ico"); if (File.Exists(iconPath)) diff --git a/src/GUI/pcHealth/pcHealth.csproj b/src/GUI/pcHealth/pcHealth.csproj index 62c4259..f6fbe3c 100644 --- a/src/GUI/pcHealth/pcHealth.csproj +++ b/src/GUI/pcHealth/pcHealth.csproj @@ -8,8 +8,11 @@ WinExe net10.0-windows10.0.26100.0 - - 10.0.26100.0 + + 10.0.19041.0 pcHealth pcHealth Assets\pcHealth.ico From 187c3c0889ec61a1e70025b133715e7514845065 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:03:05 +0000 Subject: [PATCH 04/43] docs: document the windows support tiers Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- Documentation/changelog.md | 11 +++++++++++ README.md | 28 ++++++++++++++++++++-------- SECURITY.md | 13 ++++++++----- src/CLI/tools/Invoke-BootRepair.ps1 | 11 ++++++----- 4 files changed, 45 insertions(+), 18 deletions(-) diff --git a/Documentation/changelog.md b/Documentation/changelog.md index 763ccbc..63b52ba 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -1,5 +1,16 @@ # Changelog.md - pcHealth +## 17-09-2026 - @Stensel8 + +Windows — tiered support replaces the single hard cut-off, so older hardware is usable again. + +- **CLI floor lowered from build 26200 to 14393** (Windows 10 1607). That is PowerShell 7's own minimum, so it is the lowest build on which the CLI can physically start. `Start.ps1` now reports the tier instead of exiting: recommended (>= 26200), supported (>= 19045), legacy (>= 14393, warns and continues), blocked (below that). +- **GUI floor lowered from build 26200 to 19045** (Windows 10 22H2) — WinUI 3 does not render below 22H2, so this is its technical floor. `TargetPlatformMinVersion` was still pinned at 10.0.26100.0, which contradicted the launcher's existing 19045 check; it is now 10.0.19041.0 (the nearest real SDK version). Below 19045 the launcher points users at the CLI rather than just refusing. +- Title bar customization is now applied only where `AppWindowTitleBar.IsCustomizationSupported()` is true (Windows 11), so Windows 10 keeps the system caption instead of relying on version-dependent fallback behaviour. +- Added `Test-PcWinget` helper: winget ships with Windows 10 1809 and later, so tools that need it (`Invoke-SystemUpdate`, `Invoke-HPUpdate`, the Programs menu) now report it as missing and point at "Repair Winget" instead of throwing and taking the menu down. +- Boot Repair is unchanged and still UEFI-only: a BIOS/MBR install is detected and refused, which is what keeps the lower floor safe on pre-UEFI hardware. +- Updated `README.md` and `SECURITY.md` with the tier table. + ## 02-05-2026 - @Stensel8 Linux — Topgrade integration replaces distro-specific package update script. diff --git a/README.md b/README.md index 962b959..dd056c2 100644 --- a/README.md +++ b/README.md @@ -17,16 +17,28 @@ pcHealth is a cross-platform toolkit for IT technicians and power users. It runs ## Supported Platforms -| Platform | CLI | GUI | Minimum | -|----------|-----|-----|-------------------------------| -| Windows | ✅ | ✅ | Build 26200 (Windows 11 25H2) | -| Linux | ✅ | ❌ | Kernel 7.0 | +| Platform | CLI | GUI | Minimum | +|----------|-----|-----|--------------------------------| +| Windows | ✅ | ✅ | CLI: build 14393 (Windows 10 1607) · GUI: build 19045 (Windows 10 22H2) | +| Linux | ✅ | ❌ | Kernel 7.0 | -pcHealth targets current systems only and exits immediately below the minimum. Everything in that range boots UEFI with GPT, which is why the repair tools are UEFI-only and no MBR/CSM paths remain. +### Windows support tiers + +The CLI runs on anything PowerShell 7 itself runs on; the GUI cannot go below what WinUI 3 supports. Rather than one hard cut-off, pcHealth tells you which tier you are on and keeps going where it can: + +| Tier | Build | Windows | CLI | GUI | +|-------------|---------|----------------|---------------------------------------|-----| +| Recommended | ≥ 26200 | 11 25H2 | ✅ tested | ✅ tested | +| Supported | ≥ 19045 | 10 22H2, 11 | ✅ note on start | ✅ note on start | +| Legacy | ≥ 14393 | 10 1607 – 21H2 | ⚠️ runs, warns, untested | ❌ WinUI 3 will not render | +| Blocked | < 14393 | older | ❌ PowerShell 7 does not run here | ❌ | + +On the legacy tier tools keep working where the OS allows it: winget ships with Windows 10 1809 and later, so on older builds the tools that need it say so instead of failing, and `Repair Winget` can add it. Boot Repair stays UEFI-only at every tier — a BIOS/MBR install is detected and refused rather than half-repaired. On image-based systems (Fedora Silverblue, Bazzite, Kinoite, openSUSE MicroOS) the tools that manage packages or boot files are hidden rather than reimplemented: `/usr` is read-only and the bootloader belongs to the deployment, so `bootc` and `rpm-ostree` own that work. The other 14 Linux tools -- all the diagnostics -- run normally. -- Windows release info: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information +- Windows 11 release info: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information +- Windows 10 release info: https://learn.microsoft.com/en-us/windows/release-health/release-information - Linux kernel releases: https://www.kernel.org/ See [SECURITY.md](SECURITY.md) for version and end-of-life details. @@ -35,7 +47,7 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. ## Getting Started -**Requirements:** PowerShell 7+, run as Administrator (Windows) or root/sudo (Linux). Minimum: Windows build 26200 (11 25H2) or Linux kernel 7.0. +**Requirements:** PowerShell 7+, run as Administrator (Windows) or root/sudo (Linux). Minimum: Windows build 14393 (10 1607) or Linux kernel 7.0. Build 26200 (11 25H2) is what releases are tested on. ### Windows @@ -59,7 +71,7 @@ sudo pwsh src/CLI/Start.ps1 ### GUI -On Windows, pcHealth includes a native desktop application built with **WinUI 3** (.NET 10). It provides the same functionality as the CLI in a graphical interface. Minimum: build 26200 (Windows 11 25H2). +On Windows, pcHealth includes a native desktop application built with **WinUI 3** (.NET 10). It provides the same functionality as the CLI in a graphical interface. Minimum: build 19045 (Windows 10 22H2) — below that WinUI 3 does not render, and the launcher points you at the CLI instead. Recommended: build 26200 (Windows 11 25H2). ![Health tab](Health-tab.avif) ![Tools tab](Tools-tab.avif) ![Programs tab](Programs-tab.avif) diff --git a/SECURITY.md b/SECURITY.md index 17dcc5d..a28f7b5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,12 +4,15 @@ The table below lists each supported platform, its recommended and hard minimum OS version, and its current support status within this project. -| Platform | Minimum | Status | -|----------|-------------------------------|------------------------| -| Windows | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | -| Linux | Kernel 7.0 | ✅ Actively maintained | +| Platform | Minimum | Recommended | Status | +|-------------------|--------------------------------|-------------------------------|------------------------| +| Windows (CLI) | Build 14393 (Windows 10 1607) | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | +| Windows (GUI) | Build 19045 (Windows 10 22H2) | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | +| Linux | Kernel 7.0 | — | ✅ Actively maintained | -Running below the minimum exits immediately; there is no warn-and-continue tier. Older releases are out of scope rather than best-effort: Windows 10 22H2 reached end of life in October 2025, and supporting pre-UEFI systems would mean carrying MBR/CSM repair paths that cannot be tested on any supported target. +Each minimum is a technical floor, not a preference: PowerShell 7 does not run below Windows 10 1607, and WinUI 3 does not render below 22H2. Below its floor pcHealth exits immediately; above it, builds older than the recommended one are a legacy tier that warns on start and then continues. + +Security fixes are shipped for the recommended tier first. The legacy tier is best-effort and untested — Windows 10 22H2 reached end of life in October 2025, so anything below it receives no OS security updates from Microsoft either, and running pcHealth there does not change that. Pre-UEFI systems remain out of scope: Boot Repair detects BIOS/MBR firmware and refuses rather than carrying MBR/CSM repair paths that cannot be tested on any supported target. - Windows release info: https://learn.microsoft.com/en-us/windows/release-health/release-information - Windows 11 release info: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information diff --git a/src/CLI/tools/Invoke-BootRepair.ps1 b/src/CLI/tools/Invoke-BootRepair.ps1 index 8956baf..ea61d0e 100644 --- a/src/CLI/tools/Invoke-BootRepair.ps1 +++ b/src/CLI/tools/Invoke-BootRepair.ps1 @@ -4,11 +4,12 @@ # Repairs the EFI boot files via CHKDSK, SFC and BCDBOOT. # Best run from a recovery environment (WinRE/CMD) with Administrator rights. # -# UEFI only. Windows 11 requires UEFI + GPT and pcHealth's minimum is build -# 26200, so every supported system boots UEFI. The old bootrec /fixmbr and -# /fixboot steps wrote MBR-era boot code that nothing on a GPT disk reads -- -# /fixboot in fact returns "Access is denied" on EFI systems, which is why the -# real repair was always the bcdboot fallback underneath it. +# UEFI only, and it stays that way now that the legacy tier lets older Windows +# 10 builds in: a BIOS/MBR install is detected and refused below rather than +# half-repaired. The old bootrec /fixmbr and /fixboot steps wrote MBR-era boot +# code that nothing on a GPT disk reads -- /fixboot in fact returns "Access is +# denied" on EFI systems, which is why the real repair was always the bcdboot +# fallback underneath it. # ============================================================================ if (Get-Command Set-PcTheme -ErrorAction SilentlyContinue) { From ab920732754889878d3423ea7e13ee5a86868094 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:10:25 +0000 Subject: [PATCH 05/43] feat: set the floors at windows 19045 and kernel 6.0 19045 is where WinUI 3 stops rendering, so the CLI and the GUI share one floor instead of the CLI reaching down to builds the GUI can never support. Below it pcHealth exits; between 19045 and 26200 it runs and names the recommended build. On Linux the kernel floor drops from 7.0 to 6.0, which covers the LTS kernels current distros still ship. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- Documentation/changelog.md | 13 +++++++------ README.md | 29 +++++++++++++---------------- SECURITY.md | 13 ++++++------- src/CLI/Start.ps1 | 28 ++++++++++------------------ src/CLI/app.ps1 | 14 +++++++------- src/CLI/menus/Helpers.ps1 | 11 +++++------ src/CLI/tools/Invoke-BootRepair.ps1 | 4 ++-- src/GUI/Start.ps1 | 8 ++++---- 8 files changed, 54 insertions(+), 66 deletions(-) diff --git a/Documentation/changelog.md b/Documentation/changelog.md index 63b52ba..d90a7c8 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -2,14 +2,15 @@ ## 17-09-2026 - @Stensel8 -Windows — tiered support replaces the single hard cut-off, so older hardware is usable again. +Support floors lowered so older devices are usable again — Windows 10 22H2 and Linux kernel 6.0. -- **CLI floor lowered from build 26200 to 14393** (Windows 10 1607). That is PowerShell 7's own minimum, so it is the lowest build on which the CLI can physically start. `Start.ps1` now reports the tier instead of exiting: recommended (>= 26200), supported (>= 19045), legacy (>= 14393, warns and continues), blocked (below that). -- **GUI floor lowered from build 26200 to 19045** (Windows 10 22H2) — WinUI 3 does not render below 22H2, so this is its technical floor. `TargetPlatformMinVersion` was still pinned at 10.0.26100.0, which contradicted the launcher's existing 19045 check; it is now 10.0.19041.0 (the nearest real SDK version). Below 19045 the launcher points users at the CLI rather than just refusing. +- **Windows floor lowered from build 26200 to 19045** (Windows 10 22H2) for both the CLI and the GUI. 19045 is where WinUI 3 stops rendering, so the two share one floor instead of drifting apart. Builds between 19045 and 26200 run normally and get a note naming the recommended build. +- `TargetPlatformMinVersion` was still pinned at 10.0.26100.0 while the GUI launcher already allowed 19045 — the launcher promised what the build did not deliver. It is now 10.0.19041.0, the nearest real SDK version; `TargetFramework` stays on the newest SDK. +- **Linux kernel floor lowered from 7.0 to 6.0**, covering the LTS kernels still shipping on current distros. - Title bar customization is now applied only where `AppWindowTitleBar.IsCustomizationSupported()` is true (Windows 11), so Windows 10 keeps the system caption instead of relying on version-dependent fallback behaviour. -- Added `Test-PcWinget` helper: winget ships with Windows 10 1809 and later, so tools that need it (`Invoke-SystemUpdate`, `Invoke-HPUpdate`, the Programs menu) now report it as missing and point at "Repair Winget" instead of throwing and taking the menu down. -- Boot Repair is unchanged and still UEFI-only: a BIOS/MBR install is detected and refused, which is what keeps the lower floor safe on pre-UEFI hardware. -- Updated `README.md` and `SECURITY.md` with the tier table. +- Added `Test-PcWinget` helper: LTSC and stripped-down images ship without App Installer, and a missing native command throws under `$ErrorActionPreference = 'Stop'`, taking the whole menu down. The tools that need winget (`Invoke-SystemUpdate`, `Invoke-HPUpdate`, the Programs menu) now report it and point at "Repair Winget". +- Boot Repair is unchanged and still UEFI-only: Windows 10 22H2 runs on plenty of BIOS/MBR machines, and those are detected and refused rather than half-repaired. +- Updated `README.md` and `SECURITY.md` with the support levels. ## 02-05-2026 - @Stensel8 diff --git a/README.md b/README.md index dd056c2..9db9844 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,20 @@ pcHealth is a cross-platform toolkit for IT technicians and power users. It runs ## Supported Platforms -| Platform | CLI | GUI | Minimum | -|----------|-----|-----|--------------------------------| -| Windows | ✅ | ✅ | CLI: build 14393 (Windows 10 1607) · GUI: build 19045 (Windows 10 22H2) | -| Linux | ✅ | ❌ | Kernel 7.0 | +| Platform | CLI | GUI | Minimum | +|----------|-----|-----|-------------------------------| +| Windows | ✅ | ✅ | Build 19045 (Windows 10 22H2) | +| Linux | ✅ | ❌ | Kernel 6.0 | -### Windows support tiers +### Windows support levels -The CLI runs on anything PowerShell 7 itself runs on; the GUI cannot go below what WinUI 3 supports. Rather than one hard cut-off, pcHealth tells you which tier you are on and keeps going where it can: +| Level | Build | Windows | Behaviour | +|-------------|---------|-------------|--------------------------------------------| +| Recommended | ≥ 26200 | 11 25H2 | What every release is tested on | +| Supported | ≥ 19045 | 10 22H2, 11 | Runs; a note on start names the recommended build | +| Blocked | < 19045 | older | Exits immediately | -| Tier | Build | Windows | CLI | GUI | -|-------------|---------|----------------|---------------------------------------|-----| -| Recommended | ≥ 26200 | 11 25H2 | ✅ tested | ✅ tested | -| Supported | ≥ 19045 | 10 22H2, 11 | ✅ note on start | ✅ note on start | -| Legacy | ≥ 14393 | 10 1607 – 21H2 | ⚠️ runs, warns, untested | ❌ WinUI 3 will not render | -| Blocked | < 14393 | older | ❌ PowerShell 7 does not run here | ❌ | - -On the legacy tier tools keep working where the OS allows it: winget ships with Windows 10 1809 and later, so on older builds the tools that need it say so instead of failing, and `Repair Winget` can add it. Boot Repair stays UEFI-only at every tier — a BIOS/MBR install is detected and refused rather than half-repaired. +Build 19045 is where WinUI 3 stops rendering, so the CLI and the GUI share one floor rather than drifting apart. Windows 10 22H2 still runs on plenty of BIOS/MBR machines: Boot Repair detects the firmware type and refuses a legacy install rather than half-repairing it. Tools that need winget say so when App Installer is missing (LTSC and stripped images) instead of failing, and `Repair Winget` can add it. On image-based systems (Fedora Silverblue, Bazzite, Kinoite, openSUSE MicroOS) the tools that manage packages or boot files are hidden rather than reimplemented: `/usr` is read-only and the bootloader belongs to the deployment, so `bootc` and `rpm-ostree` own that work. The other 14 Linux tools -- all the diagnostics -- run normally. @@ -47,7 +44,7 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. ## Getting Started -**Requirements:** PowerShell 7+, run as Administrator (Windows) or root/sudo (Linux). Minimum: Windows build 14393 (10 1607) or Linux kernel 7.0. Build 26200 (11 25H2) is what releases are tested on. +**Requirements:** PowerShell 7+, run as Administrator (Windows) or root/sudo (Linux). Minimum: Windows build 19045 (10 22H2) or Linux kernel 6.0. Build 26200 (11 25H2) is what releases are tested on. ### Windows @@ -71,7 +68,7 @@ sudo pwsh src/CLI/Start.ps1 ### GUI -On Windows, pcHealth includes a native desktop application built with **WinUI 3** (.NET 10). It provides the same functionality as the CLI in a graphical interface. Minimum: build 19045 (Windows 10 22H2) — below that WinUI 3 does not render, and the launcher points you at the CLI instead. Recommended: build 26200 (Windows 11 25H2). +On Windows, pcHealth includes a native desktop application built with **WinUI 3** (.NET 10). It provides the same functionality as the CLI in a graphical interface. Minimum: build 19045 (Windows 10 22H2) — the build where WinUI 3 stops rendering. Recommended: build 26200 (Windows 11 25H2). ![Health tab](Health-tab.avif) ![Tools tab](Tools-tab.avif) ![Programs tab](Programs-tab.avif) diff --git a/SECURITY.md b/SECURITY.md index a28f7b5..be8958d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,15 +4,14 @@ The table below lists each supported platform, its recommended and hard minimum OS version, and its current support status within this project. -| Platform | Minimum | Recommended | Status | -|-------------------|--------------------------------|-------------------------------|------------------------| -| Windows (CLI) | Build 14393 (Windows 10 1607) | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | -| Windows (GUI) | Build 19045 (Windows 10 22H2) | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | -| Linux | Kernel 7.0 | — | ✅ Actively maintained | +| Platform | Minimum | Recommended | Status | +|----------|-------------------------------|-------------------------------|------------------------| +| Windows | Build 19045 (Windows 10 22H2) | Build 26200 (Windows 11 25H2) | ✅ Actively maintained | +| Linux | Kernel 6.0 | Current stable | ✅ Actively maintained | -Each minimum is a technical floor, not a preference: PowerShell 7 does not run below Windows 10 1607, and WinUI 3 does not render below 22H2. Below its floor pcHealth exits immediately; above it, builds older than the recommended one are a legacy tier that warns on start and then continues. +Below the minimum pcHealth exits immediately. Build 19045 is where WinUI 3 stops rendering, so the CLI and the GUI share one floor; supported builds older than the recommended one run normally and get a note on start. -Security fixes are shipped for the recommended tier first. The legacy tier is best-effort and untested — Windows 10 22H2 reached end of life in October 2025, so anything below it receives no OS security updates from Microsoft either, and running pcHealth there does not change that. Pre-UEFI systems remain out of scope: Boot Repair detects BIOS/MBR firmware and refuses rather than carrying MBR/CSM repair paths that cannot be tested on any supported target. +Security fixes are shipped for the recommended build first. Windows 10 22H2 reached end of life in October 2025 and receives no OS security updates from Microsoft — pcHealth running there does not change that. Pre-UEFI systems are out of scope: Boot Repair detects BIOS/MBR firmware and refuses rather than carrying MBR/CSM repair paths that cannot be tested on any supported target. - Windows release info: https://learn.microsoft.com/en-us/windows/release-health/release-information - Windows 11 release info: https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information diff --git a/src/CLI/Start.ps1 b/src/CLI/Start.ps1 index da62499..a4a7d01 100644 --- a/src/CLI/Start.ps1 +++ b/src/CLI/Start.ps1 @@ -21,9 +21,9 @@ if ($onLinux) { exit 1 } $kernelMajor = [int]($kernelStr -split '[.-]')[0] - if ($kernelMajor -lt 7) { + if ($kernelMajor -lt 6) { Write-Host "[!!] pcHealth cannot run on kernel $kernelStr." -ForegroundColor Red - Write-Host " Minimum required: kernel 7.0." -ForegroundColor Red + Write-Host " Minimum required: kernel 6.0." -ForegroundColor Red Write-Host " https://www.kernel.org/" -ForegroundColor DarkGray Read-Host 'Press Enter to exit' exit 1 @@ -39,30 +39,22 @@ if ($onLinux) { # -- Windows: build check, elevate, relaunch in PS7 --------------------------- if (-not $onLinux) { - # Windows support tiers -- see README.md and SECURITY.md. + # Windows support floors -- see README.md and SECURITY.md. # >= 26200 recommended : the build every release is tested on - # >= 19045 supported : Windows 10 22H2 and up; also the GUI's floor - # >= 14393 legacy : runs, but untested -- winget may be missing - # < 14393 blocked : PowerShell 7 does not run on these builds - # The floor is PowerShell 7's own, not a preference: below 1607 there is no - # pwsh to bootstrap, so the CLI cannot start no matter what pcHealth allows. + # >= 19045 supported : Windows 10 22H2 and Windows 11 + # < 19045 blocked : WinUI 3 does not render below 22H2, so the GUI + # cannot follow the CLI down and the two floors + # are kept identical rather than drifting apart $recommendedBuild = 26200 # Windows 11 25H2 - $supportedBuild = 19045 # Windows 10 22H2 - $hardMinimumBuild = 14393 # Windows 10 1607 + $hardMinimumBuild = 19045 # Windows 10 22H2 $build = [System.Environment]::OSVersion.Version.Build if ($build -lt $hardMinimumBuild) { Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build $hardMinimumBuild (Windows 10 version 1607)," -ForegroundColor Red - Write-Host " the oldest build PowerShell 7 supports." -ForegroundColor Red - Write-Host " https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows" -ForegroundColor DarkGray + Write-Host " Minimum required: build $hardMinimumBuild (Windows 10 version 22H2)." -ForegroundColor Red + Write-Host " https://learn.microsoft.com/en-us/windows/release-health/release-information" -ForegroundColor DarkGray Read-Host 'Press Enter to exit' exit 1 - } elseif ($build -lt $supportedBuild) { - Write-Host '' - Write-Host "[!] Legacy Windows build $build -- below the supported floor of $supportedBuild (10 22H2)." -ForegroundColor Yellow - Write-Host " pcHealth continues, but this build is not tested. Tools that need winget" -ForegroundColor Yellow - Write-Host " stay unavailable until winget is installed (Tools > Repair Winget)." -ForegroundColor DarkGray } elseif ($build -lt $recommendedBuild) { Write-Host '' Write-Host "[!] Windows build $build is supported; $recommendedBuild (11 25H2) is recommended." -ForegroundColor Yellow diff --git a/src/CLI/app.ps1 b/src/CLI/app.ps1 index e8a6bcc..a47ac89 100644 --- a/src/CLI/app.ps1 +++ b/src/CLI/app.ps1 @@ -11,9 +11,9 @@ if ($IsLinux) { # Also checked in Start.ps1; repeated here as safety net for direct invocation. $kernelVersion = (uname -r) $kernelMajor = [int]($kernelVersion -split '[\.\-]')[0] - if ($kernelMajor -lt 7) { + if ($kernelMajor -lt 6) { Write-Host "[!!] pcHealth cannot run on kernel $kernelVersion." -ForegroundColor Red - Write-Host " Minimum required: kernel 7.0." -ForegroundColor Red + Write-Host " Minimum required: kernel 6.0." -ForegroundColor Red exit 1 } # Also checked in Start.ps1; repeated here so tools can rely on being root @@ -28,13 +28,13 @@ if ($IsLinux) { $Global:PcPlatformLabel = 'Linux' } elseif ($IsWindows) { # Also checked in Start.ps1 before elevation; repeated here as safety net. - # Only the hard floor is enforced here -- the tier warnings live in Start.ps1 - # so a normal launch does not print them twice. + # Only the hard floor is enforced here -- the "recommended build" note lives + # in Start.ps1 so a normal launch does not print it twice. $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 14393) { + if ($build -lt 19045) { Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build 14393 (Windows 10 version 1607)." -ForegroundColor Red - Write-Host " PowerShell 7 does not support older builds." -ForegroundColor Yellow + Write-Host " Minimum required: build 19045 (Windows 10 version 22H2)." -ForegroundColor Red + Write-Host " Please upgrade your system." -ForegroundColor Yellow exit 1 } $Global:PcPlatform = 'Windows' diff --git a/src/CLI/menus/Helpers.ps1 b/src/CLI/menus/Helpers.ps1 index e9c3ad8..a21da8f 100644 --- a/src/CLI/menus/Helpers.ps1 +++ b/src/CLI/menus/Helpers.ps1 @@ -103,16 +103,15 @@ function Get-PcPackageManager { } # Windows counterpart of Get-PcPackageManager: reports whether winget is usable. -# winget ships with Windows 10 1809 and later, so on the legacy tier -- and on -# LTSC images and freshly deployed systems at any build -- it is simply absent. -# A missing native command throws under $ErrorActionPreference = 'Stop', which -# would take the whole menu down instead of just the tool the user picked. +# Every supported build ships winget, but LTSC images, stripped deployment +# images and machines where App Installer was removed do not have it. A missing +# native command throws under $ErrorActionPreference = 'Stop', which would take +# the whole menu down instead of just the tool the user picked. function Test-PcWinget { if (Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) { return $true } Write-Host "`n[!!] winget is not available on this system." -ForegroundColor Red - Write-Host " It ships with Windows 10 1809 and later; older or stripped-down" -ForegroundColor Yellow - Write-Host " installations need it added separately." -ForegroundColor Yellow + Write-Host " LTSC and stripped-down images ship without App Installer." -ForegroundColor Yellow Write-Host " Try 'Repair Winget' in the Tools menu.`n" -ForegroundColor DarkGray return $false } diff --git a/src/CLI/tools/Invoke-BootRepair.ps1 b/src/CLI/tools/Invoke-BootRepair.ps1 index ea61d0e..b8d8ab9 100644 --- a/src/CLI/tools/Invoke-BootRepair.ps1 +++ b/src/CLI/tools/Invoke-BootRepair.ps1 @@ -4,8 +4,8 @@ # Repairs the EFI boot files via CHKDSK, SFC and BCDBOOT. # Best run from a recovery environment (WinRE/CMD) with Administrator rights. # -# UEFI only, and it stays that way now that the legacy tier lets older Windows -# 10 builds in: a BIOS/MBR install is detected and refused below rather than +# UEFI only. Windows 10 22H2 still runs on plenty of BIOS/MBR machines, so the +# firmware type is checked below and a legacy install is refused rather than # half-repaired. The old bootrec /fixmbr and /fixboot steps wrote MBR-era boot # code that nothing on a GPT disk reads -- /fixboot in fact returns "Access is # denied" on EFI systems, which is why the real repair was always the bcdboot diff --git a/src/GUI/Start.ps1 b/src/GUI/Start.ps1 index 8457b83..f766a25 100644 --- a/src/GUI/Start.ps1 +++ b/src/GUI/Start.ps1 @@ -16,17 +16,17 @@ if ($IsLinux -or $IsMacOS) { } # Recommended and hard minimum Windows build versions (see README.md). -# The GUI floor sits above the CLI's: WinUI 3 does not render below 22H2, so -# older machines are pointed at the CLI rather than blocked outright. +# 19045 is WinUI 3's own floor; the CLI uses the same one so the two never +# disagree about which machines pcHealth supports. $recommendedBuild = 26200 # 25H2+ $hardMinimumBuild = 19045 # 22H2 (hard minimum -- WinUI 3's own floor) $build = [System.Environment]::OSVersion.Version.Build if ($build -lt $hardMinimumBuild) { - Write-Host "[!!] The pcHealth GUI requires at least Windows build $hardMinimumBuild (22H2)." -ForegroundColor Red + Write-Host "[!!] pcHealth requires at least Windows build $hardMinimumBuild (22H2)." -ForegroundColor Red Write-Host " Your build: $build" -ForegroundColor Red Write-Host " WinUI 3 does not run on older builds." -ForegroundColor Yellow - Write-Host " Use the CLI instead: .\src\CLI\Start.ps1" -ForegroundColor Yellow + Write-Host " Update Windows and try again." -ForegroundColor Yellow Read-Host 'Press Enter to exit' exit 1 } elseif ($build -lt $recommendedBuild) { From e166865ccfd88f6827714fdd738479a7420eaf0f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:13:57 +0000 Subject: [PATCH 06/43] refactor: move the windows stack under src/Windows Pure rename plus the path references that follow it: src/CLI becomes src/Windows/CLI and src/GUI becomes src/Windows/GUI. Nothing else changes, so the CLI still runs on Linux exactly as before. This is the first half of separating the two stacks. It is deliberately reversible: if the codebase stays shared instead, the move is one git mv back. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .github/labeler.yml | 6 +++--- .github/workflows/ci-cd.yml | 8 ++++---- .github/workflows/codeql.yml | 2 +- .github/workflows/security.yml | 4 ++-- AGENTS.md | 8 ++++---- README.md | 12 ++++++------ development/tools/Build-Release.ps1 | 6 +++--- development/tools/Invoke-BomFix.ps1 | 2 +- development/tools/Invoke-DotnetCheck.ps1 | 2 +- development/tools/Invoke-ScriptAnalyzer.ps1 | 2 +- pcHealth.sln | 2 +- src/{ => Windows}/CLI/Start.ps1 | 2 +- src/{ => Windows}/CLI/app.ps1 | 2 +- src/{ => Windows}/CLI/menus/Helpers.ps1 | 0 src/{ => Windows}/CLI/menus/Main.ps1 | 0 src/{ => Windows}/CLI/menus/Programs.ps1 | 0 src/{ => Windows}/CLI/menus/Tools.ps1 | 0 src/{ => Windows}/CLI/tools/Get-BatteryReport.ps1 | 0 src/{ => Windows}/CLI/tools/Get-HardwareInfo.ps1 | 0 src/{ => Windows}/CLI/tools/Get-LicenseKey.ps1 | 0 src/{ => Windows}/CLI/tools/Get-Ninite.ps1 | 0 src/{ => Windows}/CLI/tools/Get-SystemInfo.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-AudioRestart.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-BootRepair.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-DiskCleanup.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-DiskOptimize.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-HPUpdate.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-NetworkReset.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-PowerOptions.ps1 | 0 .../CLI/tools/Invoke-ScanAndRepair.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-SystemUpdate.ps1 | 0 .../CLI/tools/Invoke-WindowsUpdate.ps1 | 0 src/{ => Windows}/CLI/tools/Invoke-WingetRepair.ps1 | 0 .../CLI/tools/Open-BIOSPasswordTool.ps1 | 0 src/{ => Windows}/CLI/tools/Open-BatteryReport.ps1 | 0 src/{ => Windows}/CLI/tools/Open-CBSLog.ps1 | 0 .../CLI/tools/Test-NetworkContinuous.ps1 | 0 src/{ => Windows}/CLI/tools/Test-NetworkShort.ps1 | 0 src/{ => Windows}/CLI/tools/Test-Traceroute.ps1 | 0 .../CLI/tools/linux/Get-BatteryReport.ps1 | 0 .../CLI/tools/linux/Get-SystemLogs.ps1 | 0 .../CLI/tools/linux/Invoke-AudioRestart.ps1 | 0 .../CLI/tools/linux/Invoke-BootRepair.ps1 | 0 .../CLI/tools/linux/Invoke-DiskCleanup.ps1 | 0 .../CLI/tools/linux/Invoke-DiskOptimize.ps1 | 0 .../CLI/tools/linux/Invoke-FirmwareUpdate.ps1 | 0 .../CLI/tools/linux/Invoke-NetworkReset.ps1 | 0 .../CLI/tools/linux/Invoke-ScanAndRepair.ps1 | 0 .../CLI/tools/linux/Invoke-SystemUpdate.ps1 | 0 .../CLI/tools/linux/Invoke-Topgrade.ps1 | 0 src/{ => Windows}/GUI/.gitkeep | 0 src/{ => Windows}/GUI/Start.ps1 | 2 +- src/{ => Windows}/GUI/pcHealth/App.xaml | 0 src/{ => Windows}/GUI/pcHealth/App.xaml.cs | 0 src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.ico | Bin src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.png | Bin src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.svg | 0 src/{ => Windows}/GUI/pcHealth/GlobalUsings.cs | 0 .../GUI/pcHealth/Helpers/DialogHelper.cs | 0 src/{ => Windows}/GUI/pcHealth/Helpers/UiHelper.cs | 0 src/{ => Windows}/GUI/pcHealth/KeyExtractor.cs | 0 src/{ => Windows}/GUI/pcHealth/MainWindow.xaml | 0 src/{ => Windows}/GUI/pcHealth/MainWindow.xaml.cs | 0 src/{ => Windows}/GUI/pcHealth/Models/InfoRow.cs | 0 src/{ => Windows}/GUI/pcHealth/Models/ItemGroup.cs | 0 .../GUI/pcHealth/Models/ProgramItem.cs | 0 src/{ => Windows}/GUI/pcHealth/Models/ToolItem.cs | 0 src/{ => Windows}/GUI/pcHealth/NLog.config | 0 .../GUI/pcHealth/Pages/AudioRestartPage.xaml | 0 .../GUI/pcHealth/Pages/AudioRestartPage.xaml.cs | 0 .../GUI/pcHealth/Pages/BIOSPasswordPage.xaml | 0 .../GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs | 0 .../GUI/pcHealth/Pages/BatteryReportPage.xaml | 0 .../GUI/pcHealth/Pages/BatteryReportPage.xaml.cs | 0 .../GUI/pcHealth/Pages/BootRepairPage.xaml | 0 .../GUI/pcHealth/Pages/BootRepairPage.xaml.cs | 0 .../GUI/pcHealth/Pages/CBSLogPage.xaml | 0 .../GUI/pcHealth/Pages/CBSLogPage.xaml.cs | 0 .../GUI/pcHealth/Pages/DiskCleanupPage.xaml | 0 .../GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs | 0 .../GUI/pcHealth/Pages/DiskOptimizationPage.xaml | 0 .../GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs | 0 .../GUI/pcHealth/Pages/HPUpdatePage.xaml | 0 .../GUI/pcHealth/Pages/HPUpdatePage.xaml.cs | 0 .../GUI/pcHealth/Pages/HardwareInfoPage.xaml | 0 .../GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs | 0 .../GUI/pcHealth/Pages/HealthPage.xaml | 0 .../GUI/pcHealth/Pages/HealthPage.xaml.cs | 0 src/{ => Windows}/GUI/pcHealth/Pages/InfoPage.xaml | 0 .../GUI/pcHealth/Pages/InfoPage.xaml.cs | 0 .../GUI/pcHealth/Pages/LicenseKeyPage.xaml | 0 .../GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs | 0 .../GUI/pcHealth/Pages/NetworkContinuousPage.xaml | 0 .../pcHealth/Pages/NetworkContinuousPage.xaml.cs | 0 .../GUI/pcHealth/Pages/NetworkPingPage.xaml | 0 .../GUI/pcHealth/Pages/NetworkPingPage.xaml.cs | 0 .../GUI/pcHealth/Pages/NetworkResetPage.xaml | 0 .../GUI/pcHealth/Pages/NetworkResetPage.xaml.cs | 0 .../GUI/pcHealth/Pages/NinitePage.xaml | 0 .../GUI/pcHealth/Pages/NinitePage.xaml.cs | 0 .../GUI/pcHealth/Pages/OpenBatteryReportPage.xaml | 0 .../pcHealth/Pages/OpenBatteryReportPage.xaml.cs | 0 .../GUI/pcHealth/Pages/PowerOptionsPage.xaml | 0 .../GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs | 0 .../GUI/pcHealth/Pages/ProgramsPage.xaml | 0 .../GUI/pcHealth/Pages/ProgramsPage.xaml.cs | 0 .../GUI/pcHealth/Pages/ScanRepairPage.xaml | 0 .../GUI/pcHealth/Pages/ScanRepairPage.xaml.cs | 0 .../GUI/pcHealth/Pages/SettingsPage.xaml | 0 .../GUI/pcHealth/Pages/SettingsPage.xaml.cs | 0 .../GUI/pcHealth/Pages/SystemInfoPage.xaml | 0 .../GUI/pcHealth/Pages/SystemInfoPage.xaml.cs | 0 .../GUI/pcHealth/Pages/SystemUpdatePage.xaml | 0 .../GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs | 0 src/{ => Windows}/GUI/pcHealth/Pages/ToolsPage.xaml | 0 .../GUI/pcHealth/Pages/ToolsPage.xaml.cs | 0 .../GUI/pcHealth/Pages/TraceroutePage.xaml | 0 .../GUI/pcHealth/Pages/TraceroutePage.xaml.cs | 0 .../GUI/pcHealth/Pages/WindowsUpdatePage.xaml | 0 .../GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs | 0 .../GUI/pcHealth/Pages/WingetRepairPage.xaml | 0 .../GUI/pcHealth/Pages/WingetRepairPage.xaml.cs | 0 .../GUI/pcHealth/Services/AppSettings.cs | 0 .../GUI/pcHealth/Services/CliRunner.cs | 4 ++-- .../GUI/pcHealth/Services/IAppSettings.cs | 0 .../GUI/pcHealth/Services/ICliRunner.cs | 0 .../GUI/pcHealth/Services/IProcessRunner.cs | 0 .../GUI/pcHealth/Services/IUpdateChecker.cs | 0 .../GUI/pcHealth/Services/ProcessRunner.cs | 0 .../GUI/pcHealth/Services/UpdateChecker.cs | 0 .../pcHealth/ViewModels/AudioRestartViewModel.cs | 0 .../pcHealth/ViewModels/BIOSPasswordViewModel.cs | 0 .../pcHealth/ViewModels/BatteryReportViewModel.cs | 0 .../GUI/pcHealth/ViewModels/BootRepairViewModel.cs | 0 .../GUI/pcHealth/ViewModels/CBSLogViewModel.cs | 0 .../GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs | 0 .../ViewModels/DiskOptimizationViewModel.cs | 0 .../GUI/pcHealth/ViewModels/HPUpdateViewModel.cs | 0 .../pcHealth/ViewModels/HardwareInfoViewModel.cs | 0 .../GUI/pcHealth/ViewModels/HealthModels.cs | 0 .../GUI/pcHealth/ViewModels/HealthViewModel.cs | 0 .../GUI/pcHealth/ViewModels/InfoViewModel.cs | 0 .../GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs | 0 .../ViewModels/NetworkContinuousViewModel.cs | 0 .../GUI/pcHealth/ViewModels/NetworkPingViewModel.cs | 0 .../pcHealth/ViewModels/NetworkResetViewModel.cs | 0 .../GUI/pcHealth/ViewModels/NiniteViewModel.cs | 0 .../ViewModels/OpenBatteryReportViewModel.cs | 0 .../pcHealth/ViewModels/PowerOptionsViewModel.cs | 0 .../GUI/pcHealth/ViewModels/ProgramsViewModel.cs | 0 .../GUI/pcHealth/ViewModels/ScanRepairViewModel.cs | 0 .../GUI/pcHealth/ViewModels/SettingsViewModel.cs | 0 .../GUI/pcHealth/ViewModels/SystemInfoViewModel.cs | 0 .../pcHealth/ViewModels/SystemUpdateViewModel.cs | 0 .../GUI/pcHealth/ViewModels/ToolsViewModel.cs | 0 .../GUI/pcHealth/ViewModels/TracerouteViewModel.cs | 0 .../pcHealth/ViewModels/WindowsUpdateViewModel.cs | 0 .../pcHealth/ViewModels/WingetRepairViewModel.cs | 0 src/{ => Windows}/GUI/pcHealth/app.manifest | 0 src/{ => Windows}/GUI/pcHealth/pcHealth.csproj | 6 +++--- 160 files changed, 35 insertions(+), 35 deletions(-) rename src/{ => Windows}/CLI/Start.ps1 (98%) rename src/{ => Windows}/CLI/app.ps1 (97%) rename src/{ => Windows}/CLI/menus/Helpers.ps1 (100%) rename src/{ => Windows}/CLI/menus/Main.ps1 (100%) rename src/{ => Windows}/CLI/menus/Programs.ps1 (100%) rename src/{ => Windows}/CLI/menus/Tools.ps1 (100%) rename src/{ => Windows}/CLI/tools/Get-BatteryReport.ps1 (100%) rename src/{ => Windows}/CLI/tools/Get-HardwareInfo.ps1 (100%) rename src/{ => Windows}/CLI/tools/Get-LicenseKey.ps1 (100%) rename src/{ => Windows}/CLI/tools/Get-Ninite.ps1 (100%) rename src/{ => Windows}/CLI/tools/Get-SystemInfo.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-AudioRestart.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-BootRepair.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-DiskCleanup.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-DiskOptimize.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-HPUpdate.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-NetworkReset.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-PowerOptions.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-ScanAndRepair.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-SystemUpdate.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-WindowsUpdate.ps1 (100%) rename src/{ => Windows}/CLI/tools/Invoke-WingetRepair.ps1 (100%) rename src/{ => Windows}/CLI/tools/Open-BIOSPasswordTool.ps1 (100%) rename src/{ => Windows}/CLI/tools/Open-BatteryReport.ps1 (100%) rename src/{ => Windows}/CLI/tools/Open-CBSLog.ps1 (100%) rename src/{ => Windows}/CLI/tools/Test-NetworkContinuous.ps1 (100%) rename src/{ => Windows}/CLI/tools/Test-NetworkShort.ps1 (100%) rename src/{ => Windows}/CLI/tools/Test-Traceroute.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Get-BatteryReport.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Get-SystemLogs.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-AudioRestart.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-BootRepair.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-DiskCleanup.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-DiskOptimize.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-NetworkReset.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-ScanAndRepair.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-SystemUpdate.ps1 (100%) rename src/{ => Windows}/CLI/tools/linux/Invoke-Topgrade.ps1 (100%) rename src/{ => Windows}/GUI/.gitkeep (100%) rename src/{ => Windows}/GUI/Start.ps1 (98%) rename src/{ => Windows}/GUI/pcHealth/App.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/App.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.ico (100%) rename src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.png (100%) rename src/{ => Windows}/GUI/pcHealth/Assets/pcHealth.svg (100%) rename src/{ => Windows}/GUI/pcHealth/GlobalUsings.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Helpers/DialogHelper.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Helpers/UiHelper.cs (100%) rename src/{ => Windows}/GUI/pcHealth/KeyExtractor.cs (100%) rename src/{ => Windows}/GUI/pcHealth/MainWindow.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/MainWindow.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Models/InfoRow.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Models/ItemGroup.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Models/ProgramItem.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Models/ToolItem.cs (100%) rename src/{ => Windows}/GUI/pcHealth/NLog.config (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/AudioRestartPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/AudioRestartPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BIOSPasswordPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BatteryReportPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BatteryReportPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BootRepairPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/BootRepairPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/CBSLogPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/CBSLogPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/DiskCleanupPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/DiskOptimizationPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HPUpdatePage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HPUpdatePage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HardwareInfoPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HealthPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/HealthPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/InfoPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/InfoPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/LicenseKeyPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkContinuousPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkContinuousPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkPingPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkPingPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkResetPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NetworkResetPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NinitePage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/NinitePage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/PowerOptionsPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ProgramsPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ProgramsPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ScanRepairPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ScanRepairPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SettingsPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SettingsPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SystemInfoPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SystemInfoPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SystemUpdatePage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ToolsPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/ToolsPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/TraceroutePage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/TraceroutePage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/WindowsUpdatePage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/WingetRepairPage.xaml (100%) rename src/{ => Windows}/GUI/pcHealth/Pages/WingetRepairPage.xaml.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/AppSettings.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/CliRunner.cs (97%) rename src/{ => Windows}/GUI/pcHealth/Services/IAppSettings.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/ICliRunner.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/IProcessRunner.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/IUpdateChecker.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/ProcessRunner.cs (100%) rename src/{ => Windows}/GUI/pcHealth/Services/UpdateChecker.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/BIOSPasswordViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/BatteryReportViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/BootRepairViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/CBSLogViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/DiskOptimizationViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/HardwareInfoViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/HealthModels.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/HealthViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/InfoViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/NetworkContinuousViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/NetworkPingViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/NetworkResetViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/NiniteViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/OpenBatteryReportViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/PowerOptionsViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/ProgramsViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/ScanRepairViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/SettingsViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/SystemInfoViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/ToolsViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/TracerouteViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/WindowsUpdateViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/ViewModels/WingetRepairViewModel.cs (100%) rename src/{ => Windows}/GUI/pcHealth/app.manifest (100%) rename src/{ => Windows}/GUI/pcHealth/pcHealth.csproj (93%) diff --git a/.github/labeler.yml b/.github/labeler.yml index 9a2660f..0936190 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -75,13 +75,13 @@ "windows-11": - changed-files: - any-glob-to-any-file: - - "src/GUI/**" + - "src/Windows/GUI/**" "linux": - changed-files: - any-glob-to-any-file: - - "src/CLI/tools/linux/**" - - "src/CLI/start.sh" + - "src/Windows/CLI/tools/linux/**" + - "src/Linux/**" # ── Language labels ─────────────────────────────────────────────────────────── diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8872038..a422755 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -53,10 +53,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore "src/GUI/pcHealth/pcHealth.csproj" + run: dotnet restore "src/Windows/GUI/pcHealth/pcHealth.csproj" - name: Build - run: dotnet build "src/GUI/pcHealth/pcHealth.csproj" -c Release --no-restore + run: dotnet build "src/Windows/GUI/pcHealth/pcHealth.csproj" -c Release --no-restore # ---------------------------------------------------------- # DOTNET FORMAT @@ -78,10 +78,10 @@ jobs: dotnet-version: '10.0.x' - name: Restore - run: dotnet restore "src/GUI/pcHealth/pcHealth.csproj" + run: dotnet restore "src/Windows/GUI/pcHealth/pcHealth.csproj" - name: Check formatting - run: dotnet format "src/GUI/pcHealth/pcHealth.csproj" --verify-no-changes --verbosity diagnostic + run: dotnet format "src/Windows/GUI/pcHealth/pcHealth.csproj" --verify-no-changes --verbosity diagnostic # ---------------------------------------------------------- # PR TITLE CHECK diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fc3f106..238b17c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: build-mode: manual - name: Build for CodeQL - run: dotnet build "src/GUI/pcHealth/pcHealth.csproj" -c Release + run: dotnet build "src/Windows/GUI/pcHealth/pcHealth.csproj" -c Release - name: Perform CodeQL analysis uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index f616ab7..712ecb4 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -18,7 +18,7 @@ concurrency: # ============================================================ # Overview of checks: # -# powershell-lint → PSScriptAnalyzer (src/CLI/ scripts) +# powershell-lint → PSScriptAnalyzer (src/Windows/CLI/ scripts) # trivy-scan → Trivy (filesystem vulnerability scan) # devskim → DevSkim (insecure code patterns, C# / PS) # semgrep → Semgrep (OWASP Top 10, C# rules, secrets) @@ -49,7 +49,7 @@ jobs: - name: Run PSScriptAnalyzer (SARIF output) uses: microsoft/psscriptanalyzer-action@6b2948b1944407914a58661c49941824d149734f # v1.1 with: - path: src/CLI + path: src/Windows/CLI recurse: true settings: .github/PSScriptAnalyzerSettings.psd1 output: psscriptanalyzer-results.sarif diff --git a/AGENTS.md b/AGENTS.md index 725fdf3..a158d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,8 +12,8 @@ This project has **two separate codebases**. Know which one you're in: | Part | Location | Language | Purpose | |---|---|---|---| -| CLI | `src/CLI/` | PowerShell 7 + Bash | Cross-platform terminal health tool | -| GUI | `src/GUI/pcHealth/` | C# / WinUI 3 (.NET) | Windows-only graphical frontend | +| CLI | `src/Windows/CLI/` | PowerShell 7 | Cross-platform terminal health tool | +| GUI | `src/Windows/GUI/pcHealth/` | C# / WinUI 3 (.NET) | Windows-only graphical frontend | Do not mix patterns between them. C# APIs do not belong in PowerShell scripts and vice versa. @@ -42,7 +42,7 @@ https://github.com/JuliusBrussee/caveman ## Deprecated APIs — Avoid These -### C# / .NET (GUI — `src/GUI/`) +### C# / .NET (GUI — `src/Windows/GUI/`) The GUI uses WinUI 3 on .NET. Replace legacy APIs with their modern equivalents: @@ -74,7 +74,7 @@ foreach (var instance in session.QueryInstances( Console.WriteLine(instance.CimInstanceProperties["Caption"].Value); ``` -### PowerShell 7 (CLI — `src/CLI/`) +### PowerShell 7 (CLI — `src/Windows/CLI/`) | Deprecated / Avoid | Preferred | Why | |---|---|---| diff --git a/README.md b/README.md index 9db9844..5deba62 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. 2. Run `Start.ps1` from an elevated PowerShell 7 terminal: ```powershell -.\src\CLI\Start.ps1 +.\src\Windows\CLI\Start.ps1 ``` ### Linux @@ -63,7 +63,7 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. 2. Run `Start.ps1` elevated: ```bash -sudo pwsh src/CLI/Start.ps1 +sudo pwsh src/Windows/CLI/Start.ps1 ``` ### GUI @@ -84,10 +84,10 @@ A Linux GUI is not yet available - WinUI 3 is Windows-only. A cross-platform alt | Windows App SDK | Included via NuGet on build | ```powershell -dotnet build "src/GUI/pcHealth/pcHealth.csproj" -c Release +dotnet build "src/Windows/GUI/pcHealth/pcHealth.csproj" -c Release ``` -Or open `src/GUI/pcHealth/pcHealth.csproj` in Visual Studio 2026. +Or open `src/Windows/GUI/pcHealth/pcHealth.csproj` in Visual Studio 2026. --- @@ -187,8 +187,8 @@ Installed packages are marked `[installed]` in the menu. Contributions are welcome. Follow the existing naming conventions: `Verb-Noun.ps1` for tools, consistent `Write-PcOption` / `Set-PcTheme` calls for UI. -- New tool scripts go in `src/CLI/tools/` and must be registered in `src/CLI/menus/Tools.ps1` with appropriate `Platforms` tags. -- Linux-only tools go in `src/CLI/tools/linux/`. +- New tool scripts go in `src/Windows/CLI/tools/` and must be registered in `src/Windows/CLI/menus/Tools.ps1` with appropriate `Platforms` tags. +- Linux-only tools go in `src/Windows/CLI/tools/linux/`. - Open an issue before starting larger changes to avoid duplicate work. See [SECURITY.md](SECURITY.md) for responsible disclosure of vulnerabilities. diff --git a/development/tools/Build-Release.ps1 b/development/tools/Build-Release.ps1 index ceff0a7..c9abc20 100644 --- a/development/tools/Build-Release.ps1 +++ b/development/tools/Build-Release.ps1 @@ -57,7 +57,7 @@ $null = New-Item $cliStage -ItemType Directory -Force Write-Host '[2/4] Building GUI...' -ForegroundColor Yellow -$csproj = Join-Path $repoRoot 'src\GUI\pcHealth\pcHealth.csproj' +$csproj = Join-Path $repoRoot 'src\Windows\GUI\pcHealth\pcHealth.csproj' dotnet build $csproj --configuration Release --runtime $rid --no-self-contained --nologo @@ -68,7 +68,7 @@ if ($LASTEXITCODE -ne 0) { # Read TargetFramework from csproj so the bin path never drifts. $tfm = ([xml](Get-Content $csproj)).Project.PropertyGroup.TargetFramework | Where-Object { $_ } | Select-Object -First 1 -$binOut = Join-Path $repoRoot "src\GUI\pcHealth\bin\Release\$tfm\$rid" +$binOut = Join-Path $repoRoot "src\Windows\GUI\pcHealth\bin\Release\$tfm\$rid" Copy-Item "$binOut\*" $guiStage -Recurse @@ -80,7 +80,7 @@ Write-Host '[3/4] Packaging ZIPs...' -ForegroundColor Yellow Compress-Archive -Path $guiStage -DestinationPath $guiZipPath -CompressionLevel Optimal # CLI — copy PS1 scripts as-is -Copy-Item (Join-Path $repoRoot 'src\CLI\*') $cliStage -Recurse +Copy-Item (Join-Path $repoRoot 'src\Windows\CLI\*') $cliStage -Recurse Compress-Archive -Path $cliStage -DestinationPath $cliZipPath -CompressionLevel Optimal # ── SHA256 hashes ───────────────────────────────────────────────────────────── diff --git a/development/tools/Invoke-BomFix.ps1 b/development/tools/Invoke-BomFix.ps1 index 66522e9..d753dd8 100644 --- a/development/tools/Invoke-BomFix.ps1 +++ b/development/tools/Invoke-BomFix.ps1 @@ -6,7 +6,7 @@ # # Usage: # pwsh -File development/tools/Invoke-BomFix.ps1 -# pwsh -File development/tools/Invoke-BomFix.ps1 -Path src/CLI +# pwsh -File development/tools/Invoke-BomFix.ps1 -Path src/Windows/CLI # pwsh -File development/tools/Invoke-BomFix.ps1 -WhatIf # ============================================================================ diff --git a/development/tools/Invoke-DotnetCheck.ps1 b/development/tools/Invoke-DotnetCheck.ps1 index 9c1daeb..18ba930 100644 --- a/development/tools/Invoke-DotnetCheck.ps1 +++ b/development/tools/Invoke-DotnetCheck.ps1 @@ -12,7 +12,7 @@ [CmdletBinding()] param( # Path to the .csproj. Defaults to the GUI project. - [string] $Project = (Join-Path $PSScriptRoot '..\..\src\GUI\pcHealth\pcHealth.csproj'), + [string] $Project = (Join-Path $PSScriptRoot '..\..\src\Windows\GUI\pcHealth\pcHealth.csproj'), # Auto-fix formatting instead of just checking. [switch] $Fix, diff --git a/development/tools/Invoke-ScriptAnalyzer.ps1 b/development/tools/Invoke-ScriptAnalyzer.ps1 index 4360a09..88a385d 100644 --- a/development/tools/Invoke-ScriptAnalyzer.ps1 +++ b/development/tools/Invoke-ScriptAnalyzer.ps1 @@ -4,7 +4,7 @@ # # Usage: # pwsh -File development/tools/Invoke-ScriptAnalyzer.ps1 -# pwsh -File development/tools/Invoke-ScriptAnalyzer.ps1 -Path src/CLI +# pwsh -File development/tools/Invoke-ScriptAnalyzer.ps1 -Path src/Windows/CLI # pwsh -File development/tools/Invoke-ScriptAnalyzer.ps1 -Severity Error # ============================================================================ diff --git a/pcHealth.sln b/pcHealth.sln index 70aad57..0acb4ef 100644 --- a/pcHealth.sln +++ b/pcHealth.sln @@ -6,7 +6,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GUI", "GUI", "{959DA961-515C-2AF9-5B2C-594145F24953}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "pcHealth", "src\GUI\pcHealth\pcHealth.csproj", "{55BEA32A-495F-1B1E-0981-D78127EB024C}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "pcHealth", "src\Windows\GUI\pcHealth\pcHealth.csproj", "{55BEA32A-495F-1B1E-0981-D78127EB024C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/src/CLI/Start.ps1 b/src/Windows/CLI/Start.ps1 similarity index 98% rename from src/CLI/Start.ps1 rename to src/Windows/CLI/Start.ps1 index a4a7d01..dbb6b06 100644 --- a/src/CLI/Start.ps1 +++ b/src/Windows/CLI/Start.ps1 @@ -32,7 +32,7 @@ if ($onLinux) { $isRoot = ("$(& id -u 2>$null)".Trim() -eq '0') if (-not $isRoot) { Write-Host '[!!] pcHealth must be run as root on Linux.' -ForegroundColor Red - Write-Host ' Run: sudo pwsh src/CLI/Start.ps1' -ForegroundColor Yellow + Write-Host ' Run: sudo pwsh src/Windows/CLI/Start.ps1' -ForegroundColor Yellow exit 1 } } diff --git a/src/CLI/app.ps1 b/src/Windows/CLI/app.ps1 similarity index 97% rename from src/CLI/app.ps1 rename to src/Windows/CLI/app.ps1 index a47ac89..b67f48b 100644 --- a/src/CLI/app.ps1 +++ b/src/Windows/CLI/app.ps1 @@ -21,7 +21,7 @@ if ($IsLinux) { $uid = "$(& id -u 2>$null)".Trim() if ($uid -ne '0') { Write-Host '[!!] pcHealth must be run as root on Linux.' -ForegroundColor Red - Write-Host ' Run: sudo pwsh src/CLI/Start.ps1' -ForegroundColor Yellow + Write-Host ' Run: sudo pwsh src/Windows/CLI/Start.ps1' -ForegroundColor Yellow exit 1 } $Global:PcPlatform = 'Linux' diff --git a/src/CLI/menus/Helpers.ps1 b/src/Windows/CLI/menus/Helpers.ps1 similarity index 100% rename from src/CLI/menus/Helpers.ps1 rename to src/Windows/CLI/menus/Helpers.ps1 diff --git a/src/CLI/menus/Main.ps1 b/src/Windows/CLI/menus/Main.ps1 similarity index 100% rename from src/CLI/menus/Main.ps1 rename to src/Windows/CLI/menus/Main.ps1 diff --git a/src/CLI/menus/Programs.ps1 b/src/Windows/CLI/menus/Programs.ps1 similarity index 100% rename from src/CLI/menus/Programs.ps1 rename to src/Windows/CLI/menus/Programs.ps1 diff --git a/src/CLI/menus/Tools.ps1 b/src/Windows/CLI/menus/Tools.ps1 similarity index 100% rename from src/CLI/menus/Tools.ps1 rename to src/Windows/CLI/menus/Tools.ps1 diff --git a/src/CLI/tools/Get-BatteryReport.ps1 b/src/Windows/CLI/tools/Get-BatteryReport.ps1 similarity index 100% rename from src/CLI/tools/Get-BatteryReport.ps1 rename to src/Windows/CLI/tools/Get-BatteryReport.ps1 diff --git a/src/CLI/tools/Get-HardwareInfo.ps1 b/src/Windows/CLI/tools/Get-HardwareInfo.ps1 similarity index 100% rename from src/CLI/tools/Get-HardwareInfo.ps1 rename to src/Windows/CLI/tools/Get-HardwareInfo.ps1 diff --git a/src/CLI/tools/Get-LicenseKey.ps1 b/src/Windows/CLI/tools/Get-LicenseKey.ps1 similarity index 100% rename from src/CLI/tools/Get-LicenseKey.ps1 rename to src/Windows/CLI/tools/Get-LicenseKey.ps1 diff --git a/src/CLI/tools/Get-Ninite.ps1 b/src/Windows/CLI/tools/Get-Ninite.ps1 similarity index 100% rename from src/CLI/tools/Get-Ninite.ps1 rename to src/Windows/CLI/tools/Get-Ninite.ps1 diff --git a/src/CLI/tools/Get-SystemInfo.ps1 b/src/Windows/CLI/tools/Get-SystemInfo.ps1 similarity index 100% rename from src/CLI/tools/Get-SystemInfo.ps1 rename to src/Windows/CLI/tools/Get-SystemInfo.ps1 diff --git a/src/CLI/tools/Invoke-AudioRestart.ps1 b/src/Windows/CLI/tools/Invoke-AudioRestart.ps1 similarity index 100% rename from src/CLI/tools/Invoke-AudioRestart.ps1 rename to src/Windows/CLI/tools/Invoke-AudioRestart.ps1 diff --git a/src/CLI/tools/Invoke-BootRepair.ps1 b/src/Windows/CLI/tools/Invoke-BootRepair.ps1 similarity index 100% rename from src/CLI/tools/Invoke-BootRepair.ps1 rename to src/Windows/CLI/tools/Invoke-BootRepair.ps1 diff --git a/src/CLI/tools/Invoke-DiskCleanup.ps1 b/src/Windows/CLI/tools/Invoke-DiskCleanup.ps1 similarity index 100% rename from src/CLI/tools/Invoke-DiskCleanup.ps1 rename to src/Windows/CLI/tools/Invoke-DiskCleanup.ps1 diff --git a/src/CLI/tools/Invoke-DiskOptimize.ps1 b/src/Windows/CLI/tools/Invoke-DiskOptimize.ps1 similarity index 100% rename from src/CLI/tools/Invoke-DiskOptimize.ps1 rename to src/Windows/CLI/tools/Invoke-DiskOptimize.ps1 diff --git a/src/CLI/tools/Invoke-HPUpdate.ps1 b/src/Windows/CLI/tools/Invoke-HPUpdate.ps1 similarity index 100% rename from src/CLI/tools/Invoke-HPUpdate.ps1 rename to src/Windows/CLI/tools/Invoke-HPUpdate.ps1 diff --git a/src/CLI/tools/Invoke-NetworkReset.ps1 b/src/Windows/CLI/tools/Invoke-NetworkReset.ps1 similarity index 100% rename from src/CLI/tools/Invoke-NetworkReset.ps1 rename to src/Windows/CLI/tools/Invoke-NetworkReset.ps1 diff --git a/src/CLI/tools/Invoke-PowerOptions.ps1 b/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 similarity index 100% rename from src/CLI/tools/Invoke-PowerOptions.ps1 rename to src/Windows/CLI/tools/Invoke-PowerOptions.ps1 diff --git a/src/CLI/tools/Invoke-ScanAndRepair.ps1 b/src/Windows/CLI/tools/Invoke-ScanAndRepair.ps1 similarity index 100% rename from src/CLI/tools/Invoke-ScanAndRepair.ps1 rename to src/Windows/CLI/tools/Invoke-ScanAndRepair.ps1 diff --git a/src/CLI/tools/Invoke-SystemUpdate.ps1 b/src/Windows/CLI/tools/Invoke-SystemUpdate.ps1 similarity index 100% rename from src/CLI/tools/Invoke-SystemUpdate.ps1 rename to src/Windows/CLI/tools/Invoke-SystemUpdate.ps1 diff --git a/src/CLI/tools/Invoke-WindowsUpdate.ps1 b/src/Windows/CLI/tools/Invoke-WindowsUpdate.ps1 similarity index 100% rename from src/CLI/tools/Invoke-WindowsUpdate.ps1 rename to src/Windows/CLI/tools/Invoke-WindowsUpdate.ps1 diff --git a/src/CLI/tools/Invoke-WingetRepair.ps1 b/src/Windows/CLI/tools/Invoke-WingetRepair.ps1 similarity index 100% rename from src/CLI/tools/Invoke-WingetRepair.ps1 rename to src/Windows/CLI/tools/Invoke-WingetRepair.ps1 diff --git a/src/CLI/tools/Open-BIOSPasswordTool.ps1 b/src/Windows/CLI/tools/Open-BIOSPasswordTool.ps1 similarity index 100% rename from src/CLI/tools/Open-BIOSPasswordTool.ps1 rename to src/Windows/CLI/tools/Open-BIOSPasswordTool.ps1 diff --git a/src/CLI/tools/Open-BatteryReport.ps1 b/src/Windows/CLI/tools/Open-BatteryReport.ps1 similarity index 100% rename from src/CLI/tools/Open-BatteryReport.ps1 rename to src/Windows/CLI/tools/Open-BatteryReport.ps1 diff --git a/src/CLI/tools/Open-CBSLog.ps1 b/src/Windows/CLI/tools/Open-CBSLog.ps1 similarity index 100% rename from src/CLI/tools/Open-CBSLog.ps1 rename to src/Windows/CLI/tools/Open-CBSLog.ps1 diff --git a/src/CLI/tools/Test-NetworkContinuous.ps1 b/src/Windows/CLI/tools/Test-NetworkContinuous.ps1 similarity index 100% rename from src/CLI/tools/Test-NetworkContinuous.ps1 rename to src/Windows/CLI/tools/Test-NetworkContinuous.ps1 diff --git a/src/CLI/tools/Test-NetworkShort.ps1 b/src/Windows/CLI/tools/Test-NetworkShort.ps1 similarity index 100% rename from src/CLI/tools/Test-NetworkShort.ps1 rename to src/Windows/CLI/tools/Test-NetworkShort.ps1 diff --git a/src/CLI/tools/Test-Traceroute.ps1 b/src/Windows/CLI/tools/Test-Traceroute.ps1 similarity index 100% rename from src/CLI/tools/Test-Traceroute.ps1 rename to src/Windows/CLI/tools/Test-Traceroute.ps1 diff --git a/src/CLI/tools/linux/Get-BatteryReport.ps1 b/src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 similarity index 100% rename from src/CLI/tools/linux/Get-BatteryReport.ps1 rename to src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 diff --git a/src/CLI/tools/linux/Get-SystemLogs.ps1 b/src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 similarity index 100% rename from src/CLI/tools/linux/Get-SystemLogs.ps1 rename to src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 diff --git a/src/CLI/tools/linux/Invoke-AudioRestart.ps1 b/src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-AudioRestart.ps1 rename to src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 diff --git a/src/CLI/tools/linux/Invoke-BootRepair.ps1 b/src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-BootRepair.ps1 rename to src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 diff --git a/src/CLI/tools/linux/Invoke-DiskCleanup.ps1 b/src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-DiskCleanup.ps1 rename to src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 diff --git a/src/CLI/tools/linux/Invoke-DiskOptimize.ps1 b/src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-DiskOptimize.ps1 rename to src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 diff --git a/src/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 b/src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 rename to src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 diff --git a/src/CLI/tools/linux/Invoke-NetworkReset.ps1 b/src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-NetworkReset.ps1 rename to src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 diff --git a/src/CLI/tools/linux/Invoke-ScanAndRepair.ps1 b/src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-ScanAndRepair.ps1 rename to src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 diff --git a/src/CLI/tools/linux/Invoke-SystemUpdate.ps1 b/src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-SystemUpdate.ps1 rename to src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 diff --git a/src/CLI/tools/linux/Invoke-Topgrade.ps1 b/src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 similarity index 100% rename from src/CLI/tools/linux/Invoke-Topgrade.ps1 rename to src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 diff --git a/src/GUI/.gitkeep b/src/Windows/GUI/.gitkeep similarity index 100% rename from src/GUI/.gitkeep rename to src/Windows/GUI/.gitkeep diff --git a/src/GUI/Start.ps1 b/src/Windows/GUI/Start.ps1 similarity index 98% rename from src/GUI/Start.ps1 rename to src/Windows/GUI/Start.ps1 index f766a25..3da63c7 100644 --- a/src/GUI/Start.ps1 +++ b/src/Windows/GUI/Start.ps1 @@ -11,7 +11,7 @@ $ErrorActionPreference = 'Stop' # $IsLinux / $IsMacOS are PS6+ variables; on PS 5.1 they are $null (falsy). if ($IsLinux -or $IsMacOS) { Write-Host '[!!] The pcHealth GUI is not available on Linux or macOS.' -ForegroundColor Red - Write-Host ' Use src/CLI/start.sh to run the CLI version.' -ForegroundColor Yellow + Write-Host ' Use src/Windows/CLI/Start.ps1 for the Windows CLI.' -ForegroundColor Yellow exit 1 } diff --git a/src/GUI/pcHealth/App.xaml b/src/Windows/GUI/pcHealth/App.xaml similarity index 100% rename from src/GUI/pcHealth/App.xaml rename to src/Windows/GUI/pcHealth/App.xaml diff --git a/src/GUI/pcHealth/App.xaml.cs b/src/Windows/GUI/pcHealth/App.xaml.cs similarity index 100% rename from src/GUI/pcHealth/App.xaml.cs rename to src/Windows/GUI/pcHealth/App.xaml.cs diff --git a/src/GUI/pcHealth/Assets/pcHealth.ico b/src/Windows/GUI/pcHealth/Assets/pcHealth.ico similarity index 100% rename from src/GUI/pcHealth/Assets/pcHealth.ico rename to src/Windows/GUI/pcHealth/Assets/pcHealth.ico diff --git a/src/GUI/pcHealth/Assets/pcHealth.png b/src/Windows/GUI/pcHealth/Assets/pcHealth.png similarity index 100% rename from src/GUI/pcHealth/Assets/pcHealth.png rename to src/Windows/GUI/pcHealth/Assets/pcHealth.png diff --git a/src/GUI/pcHealth/Assets/pcHealth.svg b/src/Windows/GUI/pcHealth/Assets/pcHealth.svg similarity index 100% rename from src/GUI/pcHealth/Assets/pcHealth.svg rename to src/Windows/GUI/pcHealth/Assets/pcHealth.svg diff --git a/src/GUI/pcHealth/GlobalUsings.cs b/src/Windows/GUI/pcHealth/GlobalUsings.cs similarity index 100% rename from src/GUI/pcHealth/GlobalUsings.cs rename to src/Windows/GUI/pcHealth/GlobalUsings.cs diff --git a/src/GUI/pcHealth/Helpers/DialogHelper.cs b/src/Windows/GUI/pcHealth/Helpers/DialogHelper.cs similarity index 100% rename from src/GUI/pcHealth/Helpers/DialogHelper.cs rename to src/Windows/GUI/pcHealth/Helpers/DialogHelper.cs diff --git a/src/GUI/pcHealth/Helpers/UiHelper.cs b/src/Windows/GUI/pcHealth/Helpers/UiHelper.cs similarity index 100% rename from src/GUI/pcHealth/Helpers/UiHelper.cs rename to src/Windows/GUI/pcHealth/Helpers/UiHelper.cs diff --git a/src/GUI/pcHealth/KeyExtractor.cs b/src/Windows/GUI/pcHealth/KeyExtractor.cs similarity index 100% rename from src/GUI/pcHealth/KeyExtractor.cs rename to src/Windows/GUI/pcHealth/KeyExtractor.cs diff --git a/src/GUI/pcHealth/MainWindow.xaml b/src/Windows/GUI/pcHealth/MainWindow.xaml similarity index 100% rename from src/GUI/pcHealth/MainWindow.xaml rename to src/Windows/GUI/pcHealth/MainWindow.xaml diff --git a/src/GUI/pcHealth/MainWindow.xaml.cs b/src/Windows/GUI/pcHealth/MainWindow.xaml.cs similarity index 100% rename from src/GUI/pcHealth/MainWindow.xaml.cs rename to src/Windows/GUI/pcHealth/MainWindow.xaml.cs diff --git a/src/GUI/pcHealth/Models/InfoRow.cs b/src/Windows/GUI/pcHealth/Models/InfoRow.cs similarity index 100% rename from src/GUI/pcHealth/Models/InfoRow.cs rename to src/Windows/GUI/pcHealth/Models/InfoRow.cs diff --git a/src/GUI/pcHealth/Models/ItemGroup.cs b/src/Windows/GUI/pcHealth/Models/ItemGroup.cs similarity index 100% rename from src/GUI/pcHealth/Models/ItemGroup.cs rename to src/Windows/GUI/pcHealth/Models/ItemGroup.cs diff --git a/src/GUI/pcHealth/Models/ProgramItem.cs b/src/Windows/GUI/pcHealth/Models/ProgramItem.cs similarity index 100% rename from src/GUI/pcHealth/Models/ProgramItem.cs rename to src/Windows/GUI/pcHealth/Models/ProgramItem.cs diff --git a/src/GUI/pcHealth/Models/ToolItem.cs b/src/Windows/GUI/pcHealth/Models/ToolItem.cs similarity index 100% rename from src/GUI/pcHealth/Models/ToolItem.cs rename to src/Windows/GUI/pcHealth/Models/ToolItem.cs diff --git a/src/GUI/pcHealth/NLog.config b/src/Windows/GUI/pcHealth/NLog.config similarity index 100% rename from src/GUI/pcHealth/NLog.config rename to src/Windows/GUI/pcHealth/NLog.config diff --git a/src/GUI/pcHealth/Pages/AudioRestartPage.xaml b/src/Windows/GUI/pcHealth/Pages/AudioRestartPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/AudioRestartPage.xaml rename to src/Windows/GUI/pcHealth/Pages/AudioRestartPage.xaml diff --git a/src/GUI/pcHealth/Pages/AudioRestartPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/AudioRestartPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/AudioRestartPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/AudioRestartPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/BIOSPasswordPage.xaml b/src/Windows/GUI/pcHealth/Pages/BIOSPasswordPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/BIOSPasswordPage.xaml rename to src/Windows/GUI/pcHealth/Pages/BIOSPasswordPage.xaml diff --git a/src/GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/BIOSPasswordPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/BatteryReportPage.xaml b/src/Windows/GUI/pcHealth/Pages/BatteryReportPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/BatteryReportPage.xaml rename to src/Windows/GUI/pcHealth/Pages/BatteryReportPage.xaml diff --git a/src/GUI/pcHealth/Pages/BatteryReportPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/BatteryReportPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/BatteryReportPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/BatteryReportPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/BootRepairPage.xaml b/src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/BootRepairPage.xaml rename to src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml diff --git a/src/GUI/pcHealth/Pages/BootRepairPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/BootRepairPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/CBSLogPage.xaml b/src/Windows/GUI/pcHealth/Pages/CBSLogPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/CBSLogPage.xaml rename to src/Windows/GUI/pcHealth/Pages/CBSLogPage.xaml diff --git a/src/GUI/pcHealth/Pages/CBSLogPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/CBSLogPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/CBSLogPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/CBSLogPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/DiskCleanupPage.xaml b/src/Windows/GUI/pcHealth/Pages/DiskCleanupPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/DiskCleanupPage.xaml rename to src/Windows/GUI/pcHealth/Pages/DiskCleanupPage.xaml diff --git a/src/GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/DiskCleanupPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/DiskOptimizationPage.xaml b/src/Windows/GUI/pcHealth/Pages/DiskOptimizationPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/DiskOptimizationPage.xaml rename to src/Windows/GUI/pcHealth/Pages/DiskOptimizationPage.xaml diff --git a/src/GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/DiskOptimizationPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/HPUpdatePage.xaml b/src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/HPUpdatePage.xaml rename to src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml diff --git a/src/GUI/pcHealth/Pages/HPUpdatePage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/HPUpdatePage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/HardwareInfoPage.xaml b/src/Windows/GUI/pcHealth/Pages/HardwareInfoPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/HardwareInfoPage.xaml rename to src/Windows/GUI/pcHealth/Pages/HardwareInfoPage.xaml diff --git a/src/GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/HardwareInfoPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/HealthPage.xaml b/src/Windows/GUI/pcHealth/Pages/HealthPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/HealthPage.xaml rename to src/Windows/GUI/pcHealth/Pages/HealthPage.xaml diff --git a/src/GUI/pcHealth/Pages/HealthPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/HealthPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/HealthPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/HealthPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/InfoPage.xaml b/src/Windows/GUI/pcHealth/Pages/InfoPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/InfoPage.xaml rename to src/Windows/GUI/pcHealth/Pages/InfoPage.xaml diff --git a/src/GUI/pcHealth/Pages/InfoPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/InfoPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/InfoPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/InfoPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/LicenseKeyPage.xaml b/src/Windows/GUI/pcHealth/Pages/LicenseKeyPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/LicenseKeyPage.xaml rename to src/Windows/GUI/pcHealth/Pages/LicenseKeyPage.xaml diff --git a/src/GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/LicenseKeyPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/NetworkContinuousPage.xaml b/src/Windows/GUI/pcHealth/Pages/NetworkContinuousPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkContinuousPage.xaml rename to src/Windows/GUI/pcHealth/Pages/NetworkContinuousPage.xaml diff --git a/src/GUI/pcHealth/Pages/NetworkContinuousPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/NetworkContinuousPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkContinuousPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/NetworkContinuousPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/NetworkPingPage.xaml b/src/Windows/GUI/pcHealth/Pages/NetworkPingPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkPingPage.xaml rename to src/Windows/GUI/pcHealth/Pages/NetworkPingPage.xaml diff --git a/src/GUI/pcHealth/Pages/NetworkPingPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/NetworkPingPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkPingPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/NetworkPingPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/NetworkResetPage.xaml b/src/Windows/GUI/pcHealth/Pages/NetworkResetPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkResetPage.xaml rename to src/Windows/GUI/pcHealth/Pages/NetworkResetPage.xaml diff --git a/src/GUI/pcHealth/Pages/NetworkResetPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/NetworkResetPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/NetworkResetPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/NetworkResetPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/NinitePage.xaml b/src/Windows/GUI/pcHealth/Pages/NinitePage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/NinitePage.xaml rename to src/Windows/GUI/pcHealth/Pages/NinitePage.xaml diff --git a/src/GUI/pcHealth/Pages/NinitePage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/NinitePage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/NinitePage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/NinitePage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml b/src/Windows/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml rename to src/Windows/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml diff --git a/src/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/OpenBatteryReportPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/PowerOptionsPage.xaml b/src/Windows/GUI/pcHealth/Pages/PowerOptionsPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/PowerOptionsPage.xaml rename to src/Windows/GUI/pcHealth/Pages/PowerOptionsPage.xaml diff --git a/src/GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/PowerOptionsPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/ProgramsPage.xaml b/src/Windows/GUI/pcHealth/Pages/ProgramsPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/ProgramsPage.xaml rename to src/Windows/GUI/pcHealth/Pages/ProgramsPage.xaml diff --git a/src/GUI/pcHealth/Pages/ProgramsPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/ProgramsPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/ProgramsPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/ProgramsPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/ScanRepairPage.xaml b/src/Windows/GUI/pcHealth/Pages/ScanRepairPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/ScanRepairPage.xaml rename to src/Windows/GUI/pcHealth/Pages/ScanRepairPage.xaml diff --git a/src/GUI/pcHealth/Pages/ScanRepairPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/ScanRepairPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/ScanRepairPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/ScanRepairPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/SettingsPage.xaml b/src/Windows/GUI/pcHealth/Pages/SettingsPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/SettingsPage.xaml rename to src/Windows/GUI/pcHealth/Pages/SettingsPage.xaml diff --git a/src/GUI/pcHealth/Pages/SettingsPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/SettingsPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/SettingsPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/SettingsPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/SystemInfoPage.xaml b/src/Windows/GUI/pcHealth/Pages/SystemInfoPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/SystemInfoPage.xaml rename to src/Windows/GUI/pcHealth/Pages/SystemInfoPage.xaml diff --git a/src/GUI/pcHealth/Pages/SystemInfoPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/SystemInfoPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/SystemInfoPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/SystemInfoPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/SystemUpdatePage.xaml b/src/Windows/GUI/pcHealth/Pages/SystemUpdatePage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/SystemUpdatePage.xaml rename to src/Windows/GUI/pcHealth/Pages/SystemUpdatePage.xaml diff --git a/src/GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/SystemUpdatePage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/ToolsPage.xaml b/src/Windows/GUI/pcHealth/Pages/ToolsPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/ToolsPage.xaml rename to src/Windows/GUI/pcHealth/Pages/ToolsPage.xaml diff --git a/src/GUI/pcHealth/Pages/ToolsPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/ToolsPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/ToolsPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/ToolsPage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/TraceroutePage.xaml b/src/Windows/GUI/pcHealth/Pages/TraceroutePage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/TraceroutePage.xaml rename to src/Windows/GUI/pcHealth/Pages/TraceroutePage.xaml diff --git a/src/GUI/pcHealth/Pages/TraceroutePage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/TraceroutePage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/TraceroutePage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/TraceroutePage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/WindowsUpdatePage.xaml b/src/Windows/GUI/pcHealth/Pages/WindowsUpdatePage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/WindowsUpdatePage.xaml rename to src/Windows/GUI/pcHealth/Pages/WindowsUpdatePage.xaml diff --git a/src/GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/WindowsUpdatePage.xaml.cs diff --git a/src/GUI/pcHealth/Pages/WingetRepairPage.xaml b/src/Windows/GUI/pcHealth/Pages/WingetRepairPage.xaml similarity index 100% rename from src/GUI/pcHealth/Pages/WingetRepairPage.xaml rename to src/Windows/GUI/pcHealth/Pages/WingetRepairPage.xaml diff --git a/src/GUI/pcHealth/Pages/WingetRepairPage.xaml.cs b/src/Windows/GUI/pcHealth/Pages/WingetRepairPage.xaml.cs similarity index 100% rename from src/GUI/pcHealth/Pages/WingetRepairPage.xaml.cs rename to src/Windows/GUI/pcHealth/Pages/WingetRepairPage.xaml.cs diff --git a/src/GUI/pcHealth/Services/AppSettings.cs b/src/Windows/GUI/pcHealth/Services/AppSettings.cs similarity index 100% rename from src/GUI/pcHealth/Services/AppSettings.cs rename to src/Windows/GUI/pcHealth/Services/AppSettings.cs diff --git a/src/GUI/pcHealth/Services/CliRunner.cs b/src/Windows/GUI/pcHealth/Services/CliRunner.cs similarity index 97% rename from src/GUI/pcHealth/Services/CliRunner.cs rename to src/Windows/GUI/pcHealth/Services/CliRunner.cs index 538689d..ad11a5c 100644 --- a/src/GUI/pcHealth/Services/CliRunner.cs +++ b/src/Windows/GUI/pcHealth/Services/CliRunner.cs @@ -22,14 +22,14 @@ private string GetToolsDir() var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir is not null) { - var candidate = Path.Combine(dir.FullName, "src", "CLI", "tools"); + var candidate = Path.Combine(dir.FullName, "src", "Windows", "CLI", "tools"); if (Directory.Exists(candidate)) return _toolsDir = candidate; dir = dir.Parent; } throw new DirectoryNotFoundException( - "Cannot locate src/CLI/tools.\n" + + "Cannot locate src/Windows/CLI/tools.\n" + "Make sure the app is run from within the pcHealth repository."); } diff --git a/src/GUI/pcHealth/Services/IAppSettings.cs b/src/Windows/GUI/pcHealth/Services/IAppSettings.cs similarity index 100% rename from src/GUI/pcHealth/Services/IAppSettings.cs rename to src/Windows/GUI/pcHealth/Services/IAppSettings.cs diff --git a/src/GUI/pcHealth/Services/ICliRunner.cs b/src/Windows/GUI/pcHealth/Services/ICliRunner.cs similarity index 100% rename from src/GUI/pcHealth/Services/ICliRunner.cs rename to src/Windows/GUI/pcHealth/Services/ICliRunner.cs diff --git a/src/GUI/pcHealth/Services/IProcessRunner.cs b/src/Windows/GUI/pcHealth/Services/IProcessRunner.cs similarity index 100% rename from src/GUI/pcHealth/Services/IProcessRunner.cs rename to src/Windows/GUI/pcHealth/Services/IProcessRunner.cs diff --git a/src/GUI/pcHealth/Services/IUpdateChecker.cs b/src/Windows/GUI/pcHealth/Services/IUpdateChecker.cs similarity index 100% rename from src/GUI/pcHealth/Services/IUpdateChecker.cs rename to src/Windows/GUI/pcHealth/Services/IUpdateChecker.cs diff --git a/src/GUI/pcHealth/Services/ProcessRunner.cs b/src/Windows/GUI/pcHealth/Services/ProcessRunner.cs similarity index 100% rename from src/GUI/pcHealth/Services/ProcessRunner.cs rename to src/Windows/GUI/pcHealth/Services/ProcessRunner.cs diff --git a/src/GUI/pcHealth/Services/UpdateChecker.cs b/src/Windows/GUI/pcHealth/Services/UpdateChecker.cs similarity index 100% rename from src/GUI/pcHealth/Services/UpdateChecker.cs rename to src/Windows/GUI/pcHealth/Services/UpdateChecker.cs diff --git a/src/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/BIOSPasswordViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/BIOSPasswordViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/BIOSPasswordViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/BIOSPasswordViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/BatteryReportViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/BatteryReportViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/BatteryReportViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/BatteryReportViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/BootRepairViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/BootRepairViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/BootRepairViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/BootRepairViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/CBSLogViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/CBSLogViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/CBSLogViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/CBSLogViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/DiskCleanupViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/DiskOptimizationViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/DiskOptimizationViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/DiskOptimizationViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/DiskOptimizationViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/HardwareInfoViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/HardwareInfoViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/HardwareInfoViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/HardwareInfoViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/HealthModels.cs b/src/Windows/GUI/pcHealth/ViewModels/HealthModels.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/HealthModels.cs rename to src/Windows/GUI/pcHealth/ViewModels/HealthModels.cs diff --git a/src/GUI/pcHealth/ViewModels/HealthViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/HealthViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/HealthViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/HealthViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/InfoViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/InfoViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/InfoViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/InfoViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/LicenseKeyViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/NetworkContinuousViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/NetworkContinuousViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/NetworkContinuousViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/NetworkContinuousViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/NetworkPingViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/NetworkPingViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/NetworkPingViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/NetworkPingViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/NetworkResetViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/NetworkResetViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/NetworkResetViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/NetworkResetViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/NiniteViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/NiniteViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/NiniteViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/NiniteViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/OpenBatteryReportViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/OpenBatteryReportViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/OpenBatteryReportViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/OpenBatteryReportViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/PowerOptionsViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/PowerOptionsViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/PowerOptionsViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/PowerOptionsViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/ProgramsViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/ProgramsViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/ProgramsViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/ProgramsViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/ScanRepairViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/ScanRepairViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/ScanRepairViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/ScanRepairViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/SettingsViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/SettingsViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/SettingsViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/SettingsViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/SystemInfoViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/SystemInfoViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/SystemInfoViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/SystemInfoViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/ToolsViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/ToolsViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/ToolsViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/ToolsViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/TracerouteViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/TracerouteViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/TracerouteViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/TracerouteViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/WindowsUpdateViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/WindowsUpdateViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/WindowsUpdateViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/WindowsUpdateViewModel.cs diff --git a/src/GUI/pcHealth/ViewModels/WingetRepairViewModel.cs b/src/Windows/GUI/pcHealth/ViewModels/WingetRepairViewModel.cs similarity index 100% rename from src/GUI/pcHealth/ViewModels/WingetRepairViewModel.cs rename to src/Windows/GUI/pcHealth/ViewModels/WingetRepairViewModel.cs diff --git a/src/GUI/pcHealth/app.manifest b/src/Windows/GUI/pcHealth/app.manifest similarity index 100% rename from src/GUI/pcHealth/app.manifest rename to src/Windows/GUI/pcHealth/app.manifest diff --git a/src/GUI/pcHealth/pcHealth.csproj b/src/Windows/GUI/pcHealth/pcHealth.csproj similarity index 93% rename from src/GUI/pcHealth/pcHealth.csproj rename to src/Windows/GUI/pcHealth/pcHealth.csproj index f6fbe3c..242a28d 100644 --- a/src/GUI/pcHealth/pcHealth.csproj +++ b/src/Windows/GUI/pcHealth/pcHealth.csproj @@ -2,7 +2,7 @@ - $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\..\VERSION').Trim()) + $([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)..\..\..\..\VERSION').Trim()) @@ -10,7 +10,7 @@ net10.0-windows10.0.26100.0 10.0.19041.0 pcHealth @@ -50,7 +50,7 @@ PreserveNewest - + PreserveNewest From 7623855dc5bd9d797900ad245fe275a81c08dbd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:31:49 +0000 Subject: [PATCH 07/43] feat(linux): add the python linux app Linux gets its own stack. WinUI 3 cannot come along, and PowerShell 7 is not installed on a Linux machine until someone installs it -- a poor first step for a tool you reach for because something is already broken. Python 3 is on every distro pcHealth targets, so this side is Python, with GTK4 and libadwaita for the desktop app. All 18 Linux tools are ported: system and hardware info, battery, logs, ping, traceroute, network reset, audio restart, disk cleanup and trim, scan+repair, package updates, topgrade, firmware and boot repair. A tool emits styled lines and asks questions through a ToolContext, so the same function runs under the terminal menu and inside the GTK window. Neither front-end runs as root: privilege is raised per action through pkexec, because a root process cannot reach the user's Wayland session and a root-owned toolkit is a bad idea regardless. assets/tools.json is the shared catalogue both stacks read, so the menus cannot drift apart. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .gitignore | 9 + assets/tools.json | 52 ++++ src/Linux/README.md | 86 +++++++ src/Linux/pchealth/__init__.py | 5 + src/Linux/pchealth/__main__.py | 49 ++++ src/Linux/pchealth/catalog.py | 66 +++++ src/Linux/pchealth/cli/__init__.py | 0 src/Linux/pchealth/cli/menu.py | 148 +++++++++++ src/Linux/pchealth/cli/programs.py | 96 +++++++ src/Linux/pchealth/cli/theme.py | 75 ++++++ src/Linux/pchealth/gui/__init__.py | 0 src/Linux/pchealth/gui/app.py | 56 +++++ src/Linux/pchealth/gui/dialogs.py | 87 +++++++ src/Linux/pchealth/gui/window.py | 248 +++++++++++++++++++ src/Linux/pchealth/system.py | 358 +++++++++++++++++++++++++++ src/Linux/pchealth/tools/__init__.py | 41 +++ src/Linux/pchealth/tools/audio.py | 59 +++++ src/Linux/pchealth/tools/base.py | 63 +++++ src/Linux/pchealth/tools/battery.py | 109 ++++++++ src/Linux/pchealth/tools/boot.py | 227 +++++++++++++++++ src/Linux/pchealth/tools/cleanup.py | 248 +++++++++++++++++++ src/Linux/pchealth/tools/firmware.py | 94 +++++++ src/Linux/pchealth/tools/hardware.py | 246 ++++++++++++++++++ src/Linux/pchealth/tools/logs.py | 62 +++++ src/Linux/pchealth/tools/network.py | 92 +++++++ src/Linux/pchealth/tools/power.py | 44 ++++ src/Linux/pchealth/tools/sysinfo.py | 177 +++++++++++++ src/Linux/pchealth/tools/updates.py | 147 +++++++++++ src/Linux/pchealth/version.py | 32 +++ src/Linux/pyproject.toml | 58 +++++ 30 files changed, 3034 insertions(+) create mode 100644 assets/tools.json create mode 100644 src/Linux/README.md create mode 100644 src/Linux/pchealth/__init__.py create mode 100644 src/Linux/pchealth/__main__.py create mode 100644 src/Linux/pchealth/catalog.py create mode 100644 src/Linux/pchealth/cli/__init__.py create mode 100644 src/Linux/pchealth/cli/menu.py create mode 100644 src/Linux/pchealth/cli/programs.py create mode 100644 src/Linux/pchealth/cli/theme.py create mode 100644 src/Linux/pchealth/gui/__init__.py create mode 100644 src/Linux/pchealth/gui/app.py create mode 100644 src/Linux/pchealth/gui/dialogs.py create mode 100644 src/Linux/pchealth/gui/window.py create mode 100644 src/Linux/pchealth/system.py create mode 100644 src/Linux/pchealth/tools/__init__.py create mode 100644 src/Linux/pchealth/tools/audio.py create mode 100644 src/Linux/pchealth/tools/base.py create mode 100644 src/Linux/pchealth/tools/battery.py create mode 100644 src/Linux/pchealth/tools/boot.py create mode 100644 src/Linux/pchealth/tools/cleanup.py create mode 100644 src/Linux/pchealth/tools/firmware.py create mode 100644 src/Linux/pchealth/tools/hardware.py create mode 100644 src/Linux/pchealth/tools/logs.py create mode 100644 src/Linux/pchealth/tools/network.py create mode 100644 src/Linux/pchealth/tools/power.py create mode 100644 src/Linux/pchealth/tools/sysinfo.py create mode 100644 src/Linux/pchealth/tools/updates.py create mode 100644 src/Linux/pchealth/version.py create mode 100644 src/Linux/pyproject.toml diff --git a/.gitignore b/.gitignore index ab4bf07..18e6c92 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,12 @@ coverage.xml .env.*.local /.claude .claude* + +# Python (src/Linux) +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ diff --git a/assets/tools.json b/assets/tools.json new file mode 100644 index 0000000..62b0259 --- /dev/null +++ b/assets/tools.json @@ -0,0 +1,52 @@ +{ + "_comment": [ + "Shared tool catalogue. One source of truth for the tool list so the", + "PowerShell menus, the WinUI GUI and the Linux Python app cannot drift", + "apart. Order here is menu order; numbering is assigned per platform", + "after filtering, so it stays sequential with no gaps.", + "", + "platforms -- which OS shows the entry", + "needsMutableOS -- hidden on image-based systems (Silverblue, Bazzite,", + " Kinoite, MicroOS): /usr is read-only and the", + " bootloader belongs to the deployment", + "powershellScript -- path under src/Windows/CLI/tools/ (the PowerShell", + " implementation; it still covers Linux too)", + "linuxTool -- tool id in src/Linux/pchealth/tools/" + ], + "tools": [ + { "id": "system-info", "name": "System Information", "category": "Information", "platforms": ["windows", "linux"], "powershellScript": "Get-SystemInfo.ps1", "linuxTool": "system-info" }, + { "id": "hardware-info", "name": "Hardware Information", "category": "Information", "platforms": ["windows", "linux"], "powershellScript": "Get-HardwareInfo.ps1", "linuxTool": "hardware-info" }, + { "id": "scan-repair-windows", "name": "Scan + Repair", "note": "SFC + DISM combined", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-ScanAndRepair.ps1" }, + { "id": "battery-report-windows", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Get-BatteryReport.ps1" }, + { "id": "windows-update", "name": "Windows Update", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-WindowsUpdate.ps1" }, + { "id": "disk-optimize-windows", "name": "Disk Optimization", "category": "Disk", "platforms": ["windows"], "powershellScript": "Invoke-DiskOptimize.ps1" }, + { "id": "disk-cleanup-windows", "name": "Disk Cleanup", "category": "Disk", "platforms": ["windows"], "powershellScript": "Invoke-DiskCleanup.ps1" }, + { "id": "ping-short", "name": "Short Ping Test", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-NetworkShort.ps1", "linuxTool": "ping-short" }, + { "id": "ping-continuous", "name": "Continuous Ping Test", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-NetworkContinuous.ps1", "linuxTool": "ping-continuous" }, + { "id": "traceroute", "name": "Traceroute to Google", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-Traceroute.ps1", "linuxTool": "traceroute" }, + { "id": "network-reset-windows", "name": "Reset Network Stack", "category": "Network", "platforms": ["windows"], "powershellScript": "Invoke-NetworkReset.ps1" }, + { "id": "system-update-windows", "name": "Update all packages", "note": "winget", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-SystemUpdate.ps1" }, + { "id": "hp-update", "name": "Update HP Drivers", "note": "HP only", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-HPUpdate.ps1" }, + { "id": "audio-restart-windows", "name": "Restart Audio Drivers", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Invoke-AudioRestart.ps1" }, + { "id": "open-battery-report", "name": "Open Battery Report", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Open-BatteryReport.ps1" }, + { "id": "open-cbs-log", "name": "Open CBS Log", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Open-CBSLog.ps1" }, + { "id": "ninite", "name": "Get Ninite", "note": "Edge, Chrome, VLC, 7-Zip", "category": "Updates", "platforms": ["windows"], "powershellScript": "Get-Ninite.ps1" }, + { "id": "license-key", "name": "Windows License Key", "category": "Information", "platforms": ["windows"], "powershellScript": "Get-LicenseKey.ps1" }, + { "id": "bios-password", "name": "BIOS Password Recovery", "category": "Security", "platforms": ["windows", "linux"], "powershellScript": "Open-BIOSPasswordTool.ps1", "linuxTool": "bios-password" }, + { "id": "boot-repair-windows", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-BootRepair.ps1" }, + { "id": "power-options", "name": "Shutdown / Reboot / Log Off", "category": "System", "platforms": ["windows", "linux"], "powershellScript": "Invoke-PowerOptions.ps1", "linuxTool": "power-options" }, + { "id": "winget-repair", "name": "Repair Winget", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-WingetRepair.ps1" }, + + { "id": "system-update", "name": "Update all packages", "note": "apt / dnf / pacman / zypper", "category": "Updates", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-SystemUpdate.ps1", "linuxTool": "system-update" }, + { "id": "topgrade", "name": "Topgrade", "note": "full system upgrade", "category": "Updates", "platforms": ["linux"], "powershellScript": "linux/Invoke-Topgrade.ps1", "linuxTool": "topgrade" }, + { "id": "battery-report", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["linux"], "powershellScript": "linux/Get-BatteryReport.ps1", "linuxTool": "battery-report" }, + { "id": "scan-repair", "name": "Scan + Repair", "note": "package integrity", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-ScanAndRepair.ps1", "linuxTool": "scan-repair" }, + { "id": "disk-optimize", "name": "Disk Optimization", "note": "SSD trim", "category": "Disk", "platforms": ["linux"], "powershellScript": "linux/Invoke-DiskOptimize.ps1", "linuxTool": "disk-optimize" }, + { "id": "firmware-update", "name": "Firmware Update", "note": "fwupd / LVFS", "category": "Updates", "platforms": ["linux"], "powershellScript": "linux/Invoke-FirmwareUpdate.ps1", "linuxTool": "firmware-update" }, + { "id": "boot-repair", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-BootRepair.ps1", "linuxTool": "boot-repair" }, + { "id": "disk-cleanup", "name": "Disk Cleanup", "note": "cache, journal, flatpak", "category": "Disk", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-DiskCleanup.ps1", "linuxTool": "disk-cleanup" }, + { "id": "audio-restart", "name": "Restart Audio", "note": "PipeWire / PulseAudio", "category": "Hardware", "platforms": ["linux"], "powershellScript": "linux/Invoke-AudioRestart.ps1", "linuxTool": "audio-restart" }, + { "id": "network-reset", "name": "Reset Network Stack", "category": "Network", "platforms": ["linux"], "powershellScript": "linux/Invoke-NetworkReset.ps1", "linuxTool": "network-reset" }, + { "id": "system-logs", "name": "View System Logs", "note": "journalctl", "category": "Information", "platforms": ["linux"], "powershellScript": "linux/Get-SystemLogs.ps1", "linuxTool": "system-logs" } + ] +} diff --git a/src/Linux/README.md b/src/Linux/README.md new file mode 100644 index 0000000..e731918 --- /dev/null +++ b/src/Linux/README.md @@ -0,0 +1,86 @@ +# pcHealth for Linux + +The Linux half of pcHealth: a terminal menu and a GTK4 desktop app over the +same set of tools. The Windows half lives in `src/Windows` and is PowerShell 7 +plus WinUI 3. + +## Why Python here + +The Windows stack cannot come along: WinUI 3 is Windows-only, and PowerShell 7 +is not installed on a Linux machine until someone installs it. That is a poor +first step for a tool you reach for *because* something is broken, so this side +uses what every target distro already ships — Python 3 — and GTK4 with +libadwaita for the desktop app. + +The tool list itself is shared: both stacks read `assets/tools.json` in the repo +root, so the menus cannot drift apart. + +## Requirements + +| | Needs | +|---|---| +| Terminal app | Python 3.11+ — nothing else | +| Desktop app | PyGObject, GTK 4, libadwaita | +| Kernel | 6.0 or newer | + +```bash +# Desktop app dependencies, if you want it +sudo dnf install python3-gobject gtk4 libadwaita # Fedora / RHEL +sudo apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 # Debian / Ubuntu +sudo pacman -S python-gobject gtk4 libadwaita # Arch / CachyOS +sudo zypper install python3-gobject gtk4 libadwaita # openSUSE +``` + +## Running + +```bash +# Terminal, straight from the repo -- no install step +python3 -m pchealth + +# Desktop app +python3 -m pchealth.gui.app + +# Or install the entry points +pip install . # adds: pchealth, pchealth-gui +pip install '.[gui]' # also pulls PyGObject from PyPI (needs a compiler) +``` + +## Privileges + +Most tools need root, but neither front-end asks you to run the whole app as +root. Actions elevate one at a time through `pkexec`, falling back to `sudo` +where polkit is absent. Running the terminal app with `sudo` works too and +simply skips the per-action prompt. + +The desktop app in particular must **not** be started with sudo: a root process +cannot reach your Wayland session without loosening the display's access +control, and a root-owned toolkit is a bad idea on its own. + +## Layout + +``` +pchealth/ + system.py process, privilege and platform helpers -- every other + module goes through here to touch the system + catalog.py reads the shared tool catalogue + tools/ one module per area; a tool is a function over a ToolContext + cli/ terminal menus and theming + gui/ GTK4 / libadwaita front-end +``` + +A tool never talks to the terminal or to GTK. It emits styled lines and asks +questions through the `ToolContext` it is handed, which is why the same tool +runs in both front-ends. + +## Development + +```bash +python3 -m ruff check . +python3 -m ruff format --check . +python3 -m mypy pchealth +``` + +Adding a tool means three things: an entry in `assets/tools.json` with +`"linuxTool"`, a function in `pchealth/tools/`, and a line in the `REGISTRY` in +`pchealth/tools/__init__.py`. CI fails if the catalogue lists a tool the +registry cannot run. diff --git a/src/Linux/pchealth/__init__.py b/src/Linux/pchealth/__init__.py new file mode 100644 index 0000000..6d059ac --- /dev/null +++ b/src/Linux/pchealth/__init__.py @@ -0,0 +1,5 @@ +"""pcHealth for Linux.""" + +from .version import get_version + +__all__ = ["get_version"] diff --git a/src/Linux/pchealth/__main__.py b/src/Linux/pchealth/__main__.py new file mode 100644 index 0000000..137eac9 --- /dev/null +++ b/src/Linux/pchealth/__main__.py @@ -0,0 +1,49 @@ +"""Entry point for the Linux CLI. + +Mirrors what src/Windows/CLI/Start.ps1 does on the Windows side: check the +platform floor, say something useful when it is not met, then hand over to the +menu. There is no dependency bootstrap to do -- Python 3 is already installed +on every distro pcHealth targets, which is the reason this side is Python. +""" + +from __future__ import annotations + +import sys + +from . import system +from .cli import menu, theme +from .version import get_version + +MINIMUM_KERNEL_MAJOR = 6 + + +def main() -> int: + if sys.platform != "linux": + print("pcHealth for Linux runs on Linux only.", file=sys.stderr) + print("On Windows, use src/Windows/CLI/Start.ps1.", file=sys.stderr) + return 1 + + major = system.kernel_major() + if major is None: + theme.write(f"Could not parse the kernel version ({system.kernel_release()}).", "error") + return 1 + if major < MINIMUM_KERNEL_MAJOR: + theme.write(f"pcHealth cannot run on kernel {system.kernel_release()}.", "error") + theme.write(f"Minimum required: kernel {MINIMUM_KERNEL_MAJOR}.0.", "error") + theme.write("https://www.kernel.org/", "muted") + return 1 + + if "--version" in sys.argv[1:]: + print(f"pcHealth {get_version()}") + return 0 + + try: + return menu.run() + except BrokenPipeError: + # `pchealth | head` closes the pipe early. That is not an error, but + # Python would otherwise print a traceback on the way out. + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Linux/pchealth/catalog.py b/src/Linux/pchealth/catalog.py new file mode 100644 index 0000000..c96bee0 --- /dev/null +++ b/src/Linux/pchealth/catalog.py @@ -0,0 +1,66 @@ +"""The shared tool catalogue. + +Reads assets/tools.json from the repo root -- the same file the PowerShell +menus read -- so the tool list cannot drift between the two stacks. An +installed copy ships its own catalogue next to the package. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path + +from . import system + + +@dataclass(frozen=True) +class Tool: + id: str + name: str + category: str + note: str = "" + needs_mutable_os: bool = False + + @property + def label(self) -> str: + return f"{self.name} ({self.note})" if self.note else self.name + + +def _catalogue_path() -> Path: + packaged = Path(__file__).resolve().parent / "tools.json" + if packaged.exists(): + return packaged + # pchealth -> Linux -> src -> repo root + return Path(__file__).resolve().parents[3] / "assets" / "tools.json" + + +@lru_cache(maxsize=1) +def load() -> list[Tool]: + """Every Linux tool in the catalogue, in menu order. + + A malformed catalogue is a packaging bug, not a user error, so it raises + rather than silently presenting an empty menu. + """ + raw = json.loads(_catalogue_path().read_text(encoding="utf-8")) + tools = [] + for entry in raw["tools"]: + if "linux" not in entry.get("platforms", []): + continue + tools.append( + Tool( + id=entry["linuxTool"], + name=entry["name"], + category=entry.get("category", "Other"), + note=entry.get("note", ""), + needs_mutable_os=entry.get("needsMutableOS", False), + ) + ) + return tools + + +def active() -> list[Tool]: + """The tools that make sense on this machine right now.""" + image_based = system.is_image_based() + return [tool for tool in load() if not (tool.needs_mutable_os and image_based)] diff --git a/src/Linux/pchealth/cli/__init__.py b/src/Linux/pchealth/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Linux/pchealth/cli/menu.py b/src/Linux/pchealth/cli/menu.py new file mode 100644 index 0000000..504e08c --- /dev/null +++ b/src/Linux/pchealth/cli/menu.py @@ -0,0 +1,148 @@ +"""The terminal menus.""" + +from __future__ import annotations + +from .. import catalog, system +from ..tools import REGISTRY, Cancelled, ToolContext +from ..version import get_version +from . import programs, theme + +REPO_URL = "https://github.com/REALSDEALS/pcHealth" +RELEASES_URL = f"{REPO_URL}/releases" + + +def _terminal_context() -> ToolContext: + def ask(prompt: str) -> str: + try: + return input(f" {prompt}: ") + except EOFError as exc: + raise Cancelled("no input available") from exc + + def confirm(prompt: str) -> bool: + return ask(f"{prompt} (y/n)").strip().lower() in ("y", "yes") + + return ToolContext(emit=theme.write, ask=ask, confirm=confirm) + + +def _run_tool(tool: catalog.Tool) -> None: + # The tool prints its own heading, so the menu only clears the screen -- + # printing the name here as well would show it twice. + theme.clear() + theme.write(" pcHealth", "muted") + theme.write() + implementation = REGISTRY.get(tool.id) + if implementation is None: + theme.write(f"No implementation registered for '{tool.id}'.", "error") + return + try: + implementation(_terminal_context()) + except Cancelled: + theme.write() + theme.write("Cancelled.", "muted") + except KeyboardInterrupt: + theme.write() + theme.write("Stopped.", "muted") + except OSError as exc: + theme.write() + theme.write(f"[!!] Tool error: {exc}", "error") + + +def _tools_menu() -> str: + tools = catalog.active() + + while True: + theme.header("Tools") + for index, tool in enumerate(tools, start=1): + theme.option(str(index), tool.name, tool.note) + + theme.write() + nav_programs = len(tools) + 1 + nav_main = len(tools) + 2 + nav_exit = len(tools) + 3 + theme.option(str(nav_programs), "Programs Menu") + theme.option(str(nav_main), "Back to Main Menu") + theme.option(str(nav_exit), "Exit") + theme.write() + + choice = input(" Choice: ").strip() + if not choice.isdigit(): + theme.write("Invalid choice.", "error") + continue + + number = int(choice) + if number == nav_programs: + return "programs" + if number == nav_main: + return "main" + if number == nav_exit: + return "exit" + if 1 <= number <= len(tools): + _run_tool(tools[number - 1]) + theme.write() + nav = input(" [1] Back to Tools [2] Main Menu [3] Exit: ").strip() + if nav == "2": + return "main" + if nav == "3": + return "exit" + continue + + theme.write("Invalid choice.", "error") + + +def _main_menu() -> str: + theme.header("Main Menu", f"Linux -- v{get_version()}") + + theme.write(" Thanks for downloading and using pcHealth!") + theme.write(" Made by REALSDEALS - Licensed under GNU-3", "muted") + theme.write() + + if not system.is_root(): + theme.write(" Not running as root -- each action will ask for elevation.", "warn") + theme.write(" Run `sudo pchealth` to be asked once instead.", "muted") + theme.write() + + theme.option("1", "Tools") + theme.option("2", "Programs") + theme.write() + theme.option("3", "Go to repository") + theme.option("4", "Check for pre-releases") + theme.write() + theme.option("5", "Exit") + theme.write() + + choice = input(" Choice: ").strip() + if choice == "1": + return "tools" + if choice == "2": + return "programs" + if choice == "3": + system.open_url(REPO_URL) + return "main" + if choice == "4": + system.open_url(RELEASES_URL) + return "main" + if choice == "5": + return "exit" + + theme.write("Invalid choice.", "error") + return "main" + + +def run() -> int: + target = "main" + while True: + try: + if target == "tools": + target = _tools_menu() + elif target == "programs": + target = programs.show() + elif target == "exit": + theme.write() + theme.write(" Goodbye.", "muted") + return 0 + else: + target = _main_menu() + except (KeyboardInterrupt, EOFError): + theme.write() + theme.write(" Goodbye.", "muted") + return 0 diff --git a/src/Linux/pchealth/cli/programs.py b/src/Linux/pchealth/cli/programs.py new file mode 100644 index 0000000..629680f --- /dev/null +++ b/src/Linux/pchealth/cli/programs.py @@ -0,0 +1,96 @@ +"""The Programs menu: install the diagnostic packages a technician wants.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .. import system +from . import theme + + +@dataclass(frozen=True) +class Package: + name: str + package: str + # What the menu probes for the [installed] marker: one PATH lookup instead + # of a different "is this present" query per package manager. + binary: str + note: str + + +PACKAGES: tuple[Package, ...] = ( + Package("htop", "htop", "htop", "process viewer"), + Package("iotop", "iotop", "iotop", "I/O monitor"), + Package("smartmontools", "smartmontools", "smartctl", "disk SMART data"), + Package("stress-ng", "stress-ng", "stress-ng", "stress test"), + Package("nmap", "nmap", "nmap", "network scanner"), +) + + +def show() -> str: + """Run the Programs menu. Returns the next screen: main, tools or exit.""" + manager = system.package_manager() + + while True: + theme.header("Programs") + + if system.is_image_based(): + theme.write("This is an image-based system (ostree).", "warn") + theme.write("Install these with Homebrew, Distrobox or a Flatpak instead:", "warn") + theme.write(" brew install htop # or: distrobox enter", "muted") + theme.write() + elif not manager: + theme.write("No supported package manager found (apt/dnf/pacman/zypper).", "error") + theme.write() + + for index, package in enumerate(PACKAGES, start=1): + marker = "[installed]" if system.has(package.binary) else "" + theme.option(str(index), f"{package.name} ({package.note})", marker) + + theme.write() + nav_tools = len(PACKAGES) + 1 + nav_main = len(PACKAGES) + 2 + nav_exit = len(PACKAGES) + 3 + theme.option(str(nav_tools), "Tools Menu") + theme.option(str(nav_main), "Back to Main Menu") + theme.option(str(nav_exit), "Exit") + theme.write() + + choice = input(" Choice: ").strip() + if not choice.isdigit(): + theme.write("Invalid choice.", "error") + continue + + number = int(choice) + if number == nav_tools: + return "tools" + if number == nav_main: + return "main" + if number == nav_exit: + return "exit" + if not 1 <= number <= len(PACKAGES): + theme.write("Invalid choice.", "error") + continue + + package = PACKAGES[number - 1] + if system.has(package.binary): + theme.write(f"{package.name} is already installed.", "ok") + input("\n Press Enter to continue...") + continue + if not manager or system.is_image_based(): + theme.write(f"Cannot install {package.name} on this system.", "error") + input("\n Press Enter to continue...") + continue + + theme.write() + theme.write(f"[>>] Installing {package.name}...", "accent") + rc = system.stream_root( + [manager.cmd, *manager.install, package.package], + lambda line: theme.write(f" {line}", "muted"), + ) + theme.write() + if rc == 0: + theme.write(f"[OK] {package.name} installed.", "ok") + else: + theme.write(f"[!!] Installation exited with code {rc}.", "error") + input("\n Press Enter to continue...") diff --git a/src/Linux/pchealth/cli/theme.py b/src/Linux/pchealth/cli/theme.py new file mode 100644 index 0000000..3237624 --- /dev/null +++ b/src/Linux/pchealth/cli/theme.py @@ -0,0 +1,75 @@ +"""Terminal styling. + +ANSI only -- no curses, no third-party dependency. The Linux app has to run on +a machine that is already having a bad day, so it asks nothing of the system +beyond a terminal that understands colour, and drops the colour when it does +not (a pipe, a log file, NO_COLOR). +""" + +from __future__ import annotations + +import os +import shutil +import sys + +RESET = "\033[0m" + +_CODES = { + "head": "\033[1;36m", + "info": "", + "ok": "\033[32m", + "warn": "\033[33m", + "error": "\033[31m", + "muted": "\033[90m", + "accent": "\033[36m", +} + + +def colour_enabled() -> bool: + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("TERM") == "dumb": + return False + return sys.stdout.isatty() + + +def paint(text: str, style: str) -> str: + if not colour_enabled(): + return text + code = _CODES.get(style, "") + return f"{code}{text}{RESET}" if code else text + + +def write(text: str = "", style: str = "info") -> None: + print(paint(text, style)) + + +def width(default: int = 80) -> int: + return shutil.get_terminal_size((default, 24)).columns + + +def clear() -> None: + if colour_enabled(): + # Home the cursor and clear, rather than shelling out to `clear`. + print("\033[H\033[2J", end="") + + +def rule(char: str = "=") -> None: + write(char * min(width(), 70), "muted") + + +def header(title: str, subtitle: str = "") -> None: + clear() + rule() + write(f" pcHealth -- {title}", "head") + if subtitle: + write(f" {subtitle}", "muted") + rule() + write() + + +def option(key: str, label: str, note: str = "") -> None: + line = f" [{key}]".ljust(7) + label + if note: + line += paint(f" ({note})", "muted") if colour_enabled() else f" ({note})" + print(line) diff --git a/src/Linux/pchealth/gui/__init__.py b/src/Linux/pchealth/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/Linux/pchealth/gui/app.py b/src/Linux/pchealth/gui/app.py new file mode 100644 index 0000000..a4da1bf --- /dev/null +++ b/src/Linux/pchealth/gui/app.py @@ -0,0 +1,56 @@ +"""GTK4 / libadwaita front-end. + +The window runs unprivileged on purpose. A root-owned GUI cannot reach the +user's Wayland session without loosening the display's own access control, and +a toolkit running as root is a bad idea regardless. Privilege is raised per +action through pkexec instead -- see system.elevated(). +""" + +from __future__ import annotations + +import sys + +APP_ID = "nl.realsdeals.pcHealth" + +_MISSING_GTK = """pcHealth GUI needs PyGObject with GTK 4 and libadwaita. + +Install it with your package manager: + Fedora / RHEL: dnf install python3-gobject gtk4 libadwaita + Debian / Ubuntu: apt install python3-gi gir1.2-gtk-4.0 gir1.2-adw-1 + Arch / CachyOS: pacman -S python-gobject gtk4 libadwaita + openSUSE: zypper install python3-gobject gtk4 libadwaita + +The terminal version needs none of this -- run: pchealth +""" + + +def main() -> int: + if sys.platform != "linux": + print("pcHealth GUI runs on Linux only.", file=sys.stderr) + return 1 + + try: + import gi + + gi.require_version("Gtk", "4.0") + gi.require_version("Adw", "1") + from gi.repository import Adw + except (ImportError, ValueError): + print(_MISSING_GTK, file=sys.stderr) + return 1 + + from .window import MainWindow + + class PcHealthApplication(Adw.Application): + def __init__(self) -> None: + super().__init__(application_id=APP_ID) + + def do_activate(self) -> None: + window = self.props.active_window or MainWindow(self) + window.present() + + return PcHealthApplication().run(sys.argv) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Linux/pchealth/gui/dialogs.py b/src/Linux/pchealth/gui/dialogs.py new file mode 100644 index 0000000..6260caa --- /dev/null +++ b/src/Linux/pchealth/gui/dialogs.py @@ -0,0 +1,87 @@ +"""Dialogs a tool can ask for from its worker thread. + +A tool runs off the main loop, but GTK may only be touched from it. Each helper +therefore hops to the main loop, shows the dialog, and blocks the worker on an +Event until the answer comes back. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +# Adw.AlertDialog arrived in libadwaita 1.5 and replaced Adw.MessageDialog. +# Distros ship both, so pick whichever this system actually has. +_ALERT = getattr(Adw, "AlertDialog", None) or Adw.MessageDialog +_USES_ALERT_DIALOG = hasattr(Adw, "AlertDialog") + + +def _present(dialog: Any, parent: Gtk.Window) -> None: + if _USES_ALERT_DIALOG: + dialog.present(parent) + else: + dialog.set_transient_for(parent) + dialog.present() + + +def confirm(parent: Gtk.Window, question: str) -> bool: + """Ask a yes/no question from a worker thread and wait for the answer.""" + done = threading.Event() + answer = False + + def build() -> bool: + dialog = _ALERT(heading="pcHealth", body=question) + dialog.add_response("no", "Cancel") + dialog.add_response("yes", "Continue") + dialog.set_response_appearance("yes", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("no") + dialog.set_close_response("no") + + def on_response(_dialog: Any, response: str) -> None: + nonlocal answer + answer = response == "yes" + done.set() + + dialog.connect("response", on_response) + _present(dialog, parent) + return GLib.SOURCE_REMOVE + + GLib.idle_add(build) + done.wait() + return answer + + +def ask(parent: Gtk.Window, prompt: str) -> str: + """Ask for a line of text from a worker thread and wait for the answer.""" + done = threading.Event() + answer = "" + + def build() -> bool: + entry = Gtk.Entry(activates_default=True) + dialog = _ALERT(heading="pcHealth", body=prompt) + dialog.set_extra_child(entry) + dialog.add_response("cancel", "Cancel") + dialog.add_response("ok", "OK") + dialog.set_response_appearance("ok", Adw.ResponseAppearance.SUGGESTED) + dialog.set_default_response("ok") + dialog.set_close_response("cancel") + + def on_response(_dialog: Any, response: str) -> None: + nonlocal answer + answer = entry.get_text() if response == "ok" else "" + done.set() + + dialog.connect("response", on_response) + _present(dialog, parent) + return GLib.SOURCE_REMOVE + + GLib.idle_add(build) + done.wait() + return answer diff --git a/src/Linux/pchealth/gui/window.py b/src/Linux/pchealth/gui/window.py new file mode 100644 index 0000000..8bbe0f8 --- /dev/null +++ b/src/Linux/pchealth/gui/window.py @@ -0,0 +1,248 @@ +"""The main window: tool list on the left, tool output on the right.""" + +from __future__ import annotations + +import threading + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, GLib, Gtk, Pango # noqa: E402 + +from .. import catalog, system # noqa: E402 +from ..tools import REGISTRY, Cancelled, ToolContext # noqa: E402 +from ..version import get_version # noqa: E402 +from . import dialogs # noqa: E402 + +# Style name -> text colour. Kept close to the terminal palette so the two +# front-ends read the same, and defined per theme so it stays legible in both. +_COLOURS_LIGHT = { + "head": "#1c71d8", + "ok": "#26794e", + "warn": "#a35a00", + "error": "#c01c28", + "muted": "#5e5c64", +} +_COLOURS_DARK = { + "head": "#78aeed", + "ok": "#8ff0a4", + "warn": "#f8e45c", + "error": "#ff938c", + "muted": "#9a9996", +} + + +class TerminalView(Gtk.ScrolledWindow): + """A read-only, monospaced text view that tool output is appended to.""" + + def __init__(self) -> None: + super().__init__(hexpand=True, vexpand=True) + self._view = Gtk.TextView( + editable=False, + cursor_visible=False, + monospace=True, + wrap_mode=Gtk.WrapMode.WORD_CHAR, + top_margin=12, + bottom_margin=12, + left_margin=12, + right_margin=12, + ) + self._buffer = self._view.get_buffer() + self.set_child(self._view) + + dark = Adw.StyleManager.get_default().get_dark() + palette = _COLOURS_DARK if dark else _COLOURS_LIGHT + for name, colour in palette.items(): + weight = Pango.Weight.BOLD if name == "head" else Pango.Weight.NORMAL + self._buffer.create_tag(name, foreground=colour, weight=weight) + + def clear(self) -> None: + self._buffer.set_text("") + + def append(self, text: str, style: str) -> None: + """Append one line. Safe to call from any thread.""" + + def write() -> bool: + end = self._buffer.get_end_iter() + if style in ("head", "ok", "warn", "error", "muted"): + self._buffer.insert_with_tags_by_name(end, text + "\n", style) + else: + self._buffer.insert(end, text + "\n") + # Keep the newest line in view without stealing focus. + mark = self._buffer.create_mark(None, self._buffer.get_end_iter(), False) + self._view.scroll_mark_onscreen(mark) + self._buffer.delete_mark(mark) + return GLib.SOURCE_REMOVE + + GLib.idle_add(write) + + +class MainWindow(Adw.ApplicationWindow): + def __init__(self, application: Adw.Application) -> None: + super().__init__( + application=application, title="pcHealth", default_width=1100, default_height=720 + ) + + self._worker: threading.Thread | None = None + self._stop = threading.Event() + + self._output = TerminalView() + self._run_button = Gtk.Button( + label="Run", css_classes=["suggested-action"], sensitive=False + ) + self._stop_button = Gtk.Button(label="Stop", sensitive=False) + self._status = Gtk.Label(label="Select a tool", xalign=0, css_classes=["dim-label"]) + self._list = Gtk.ListBox(css_classes=["navigation-sidebar"]) + + self._tools = catalog.active() + self._build_ui() + + # -- Layout --------------------------------------------------------------- + + def _build_ui(self) -> None: + self._list.set_selection_mode(Gtk.SelectionMode.SINGLE) + self._list.connect("row-selected", self._on_tool_selected) + + current_category = "" + for tool in self._tools: + if tool.category != current_category: + current_category = tool.category + header = Gtk.Label( + label=tool.category.upper(), + xalign=0, + css_classes=["dim-label", "caption-heading"], + margin_top=12, + margin_start=12, + margin_bottom=4, + ) + row = Gtk.ListBoxRow(child=header, selectable=False, activatable=False) + self._list.append(row) + + row = Gtk.ListBoxRow() + # GTK rows carry no payload of their own, so the tool rides along. + row.tool = tool + box = Gtk.Box( + orientation=Gtk.Orientation.VERTICAL, + spacing=2, + margin_top=8, + margin_bottom=8, + margin_start=12, + margin_end=12, + ) + box.append(Gtk.Label(label=tool.name, xalign=0)) + if tool.note: + box.append( + Gtk.Label(label=tool.note, xalign=0, css_classes=["dim-label", "caption"]) + ) + row.set_child(box) + self._list.append(row) + + sidebar_scroll = Gtk.ScrolledWindow(child=self._list, width_request=260, vexpand=True) + + sidebar = Adw.ToolbarView() + sidebar.add_top_bar( + Adw.HeaderBar( + title_widget=Adw.WindowTitle(title="pcHealth", subtitle=f"v{get_version()}") + ) + ) + sidebar.set_content(sidebar_scroll) + + self._run_button.connect("clicked", self._on_run) + self._stop_button.connect("clicked", self._on_stop) + + actions = Gtk.Box( + orientation=Gtk.Orientation.HORIZONTAL, + spacing=6, + margin_top=6, + margin_bottom=6, + margin_start=12, + margin_end=12, + ) + actions.append(self._status) + actions.append(Gtk.Box(hexpand=True)) + actions.append(self._stop_button) + actions.append(self._run_button) + + content_header = Adw.HeaderBar() + if not system.is_root(): + # Elevation happens per action through pkexec, so say so once here + # rather than refusing to start. + content_header.pack_end( + Gtk.Label(label="Actions ask for elevation", css_classes=["dim-label", "caption"]) + ) + + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + content_box.append(actions) + content_box.append(Gtk.Separator()) + content_box.append(self._output) + + content = Adw.ToolbarView() + content.add_top_bar(content_header) + content.set_content(content_box) + + split = Adw.NavigationSplitView( + sidebar=Adw.NavigationPage(child=sidebar, title="Tools"), + content=Adw.NavigationPage(child=content, title="Output"), + ) + self.set_content(split) + + # -- Running a tool ------------------------------------------------------- + + def _selected_tool(self) -> catalog.Tool | None: + row = self._list.get_selected_row() + return getattr(row, "tool", None) if row else None + + def _on_tool_selected(self, _list: Gtk.ListBox, _row: Gtk.ListBoxRow | None) -> None: + tool = self._selected_tool() + running = self._worker is not None and self._worker.is_alive() + self._run_button.set_sensitive(tool is not None and not running) + if tool: + self._status.set_label(tool.label) + + def _on_stop(self, _button: Gtk.Button) -> None: + self._stop.set() + self._status.set_label("Stopping...") + + def _on_run(self, _button: Gtk.Button) -> None: + tool = self._selected_tool() + if tool is None or (self._worker and self._worker.is_alive()): + return + + implementation = REGISTRY.get(tool.id) + if implementation is None: + self._output.append(f"No implementation registered for '{tool.id}'.", "error") + return + + self._output.clear() + self._stop.clear() + self._run_button.set_sensitive(False) + self._stop_button.set_sensitive(True) + self._status.set_label(f"Running {tool.name}...") + + context = ToolContext( + emit=self._output.append, + ask=lambda prompt: dialogs.ask(self, prompt), + confirm=lambda prompt: dialogs.confirm(self, prompt), + should_stop=self._stop.is_set, + ) + + def work() -> None: + try: + implementation(context) + except Cancelled: + self._output.append("Cancelled.", "muted") + except OSError as exc: + self._output.append(f"[!!] Tool error: {exc}", "error") + finally: + GLib.idle_add(self._finish, tool.name) + + self._worker = threading.Thread(target=work, daemon=True, name=f"pchealth-{tool.id}") + self._worker.start() + + def _finish(self, name: str) -> bool: + self._stop_button.set_sensitive(False) + self._run_button.set_sensitive(self._selected_tool() is not None) + self._status.set_label(f"{name} finished") + return GLib.SOURCE_REMOVE diff --git a/src/Linux/pchealth/system.py b/src/Linux/pchealth/system.py new file mode 100644 index 0000000..c96b51d --- /dev/null +++ b/src/Linux/pchealth/system.py @@ -0,0 +1,358 @@ +"""Process, privilege and platform helpers. + +This is the Linux counterpart of the PowerShell CLI's Helpers.ps1. The rules +that shaped that file apply here too: a missing command is the normal case, not +an edge case. Containers and WSL have no systemd, minimal installs have no +lspci or mokutil, and an image-based system has no package manager to speak of. +Every helper here returns None or an empty result instead of raising. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from collections.abc import Callable, Sequence +from dataclasses import dataclass, field +from pathlib import Path + +# Returned when the command itself could not be found, matching the shell +# convention so callers can treat it like any other failing exit code. +COMMAND_NOT_FOUND = 127 + + +@dataclass(frozen=True) +class Result: + returncode: int + stdout: str = "" + stderr: str = "" + + @property + def ok(self) -> bool: + return self.returncode == 0 + + +def which(command: str) -> str | None: + return shutil.which(command) + + +def has(command: str) -> bool: + return shutil.which(command) is not None + + +def run( + argv: Sequence[str], *, timeout: float | None = None, stdin_text: str | None = None +) -> Result: + """Run a command and capture its output. Never raises on a missing binary.""" + if not argv or not has(argv[0]): + return Result(COMMAND_NOT_FOUND, "", f"{argv[0] if argv else ''}: not found") + try: + completed = subprocess.run( + list(argv), + capture_output=True, + text=True, + timeout=timeout, + input=stdin_text, + check=False, + ) + except subprocess.TimeoutExpired: + return Result(124, "", f"{argv[0]}: timed out after {timeout}s") + except OSError as exc: + return Result(COMMAND_NOT_FOUND, "", f"{argv[0]}: {exc}") + return Result(completed.returncode, completed.stdout, completed.stderr) + + +def output(argv: Sequence[str], *, timeout: float | None = None) -> str | None: + """Trimmed stdout, or None when the command is missing, fails or says nothing. + + The PowerShell side learned this the hard way: `(& cmd args).Trim()` throws + on a missing command and aborts the whole tool rather than one field. + """ + result = run(argv, timeout=timeout) + if not result.ok: + return None + text = result.stdout.strip() + return text or None + + +def stream( + argv: Sequence[str], + on_line: Callable[[str], None], + *, + timeout: float | None = None, + should_stop: Callable[[], bool] | None = None, +) -> int: + """Run a command, handing each output line to on_line as it arrives. + + Long-running tools (fstrim, fwupdmgr, a package upgrade) must not look + frozen, so stdout and stderr are merged and forwarded line by line rather + than collected and printed at the end. + """ + if not argv or not has(argv[0]): + on_line(f"{argv[0] if argv else ''}: not found") + return COMMAND_NOT_FOUND + try: + process = subprocess.Popen( + list(argv), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except OSError as exc: + on_line(f"{argv[0]}: {exc}") + return COMMAND_NOT_FOUND + + assert process.stdout is not None + try: + for line in process.stdout: + on_line(line.rstrip("\n")) + if should_stop is not None and should_stop(): + process.terminate() + break + return process.wait(timeout=timeout) + except subprocess.TimeoutExpired: + process.kill() + on_line(f"{argv[0]}: timed out after {timeout}s") + return 124 + except KeyboardInterrupt: + process.terminate() + raise + finally: + process.stdout.close() + + +# -- Privilege ---------------------------------------------------------------- + + +def is_root() -> bool: + return os.geteuid() == 0 + + +def elevated(argv: Sequence[str]) -> list[str]: + """Prefix a command with whatever will elevate it, or leave it alone. + + The GUI must never run as root -- on Wayland a root process cannot reach + the user's display, and a root-owned toolkit is a security problem on its + own. So privilege is raised per action instead of per session: pkexec when + polkit is available, sudo as the fallback for a terminal-only system. + """ + if is_root(): + return list(argv) + if has("pkexec"): + return ["pkexec", *argv] + if has("sudo"): + return ["sudo", *argv] + return list(argv) + + +def run_root(argv: Sequence[str], *, timeout: float | None = None) -> Result: + return run(elevated(argv), timeout=timeout) + + +def stream_root( + argv: Sequence[str], + on_line: Callable[[str], None], + *, + timeout: float | None = None, + should_stop: Callable[[], bool] | None = None, +) -> int: + return stream(elevated(argv), on_line, timeout=timeout, should_stop=should_stop) + + +# -- Platform ----------------------------------------------------------------- + + +def kernel_release() -> str: + return os.uname().release + + +def kernel_major() -> int | None: + head = kernel_release().split(".", 1)[0] + try: + return int(head) + except ValueError: + return None + + +def read_text(path: str | Path) -> str | None: + """Read a sysfs or procfs file. Absent and unreadable are both normal.""" + try: + return Path(path).read_text(encoding="utf-8", errors="replace").strip() + except OSError: + return None + + +def distro_info() -> dict[str, str]: + """Parse /etc/os-release. ID and ID_LIKE are lowercased, names keep casing.""" + info: dict[str, str] = {} + raw = read_text("/etc/os-release") + for line in (raw or "").splitlines(): + key, sep, value = line.partition("=") + if not sep or not key.isidentifier(): + continue + info[key] = value.strip().strip('"').strip("'") + + info["ID"] = info.get("ID", "").lower() + info["ID_LIKE"] = info.get("ID_LIKE", "").lower() + info.setdefault("NAME", "Linux") + info.setdefault("PRETTY_NAME", info["NAME"]) + return info + + +def is_image_based() -> bool: + """True on ostree systems: Silverblue, Bazzite, Kinoite, openSUSE MicroOS. + + /usr is read-only there and the bootloader belongs to the deployment, so + tools that manage packages or boot files are hidden rather than taught a + second dialect -- bootc and rpm-ostree own that work. + """ + return Path("/run/ostree-booted").exists() or Path("/ostree").exists() + + +@dataclass(frozen=True) +class DesktopUser: + name: str + uid: str + home: str + dbus: str + + +def desktop_user() -> DesktopUser | None: + """Resolve the human behind the session, not the root the tool runs as. + + Under sudo or pkexec the process environment describes root, so anything + touching the desktop session -- audio, topgrade, the thumbnail cache, log + off -- has to ask who actually logged in. + """ + name = os.environ.get("SUDO_USER") or os.environ.get("PKEXEC_UID") or "" + if name.isdigit(): + # PKEXEC_UID is a uid, not a name. + name = output(["id", "-un", name]) or "" + if not name: + name = os.environ.get("USER") or "" + if not name: + name = output(["id", "-un"]) or "" + if not name: + return None + + uid = output(["id", "-u", name]) or "" + + home = "" + passwd = output(["getent", "passwd", name]) + if passwd: + fields = passwd.split(":") + if len(fields) > 5: + home = fields[5] + if not home: + home = "/root" if name == "root" else f"/home/{name}" + + # The session bus `systemctl --user` needs. An inherited address is passed + # on to `env` as key=value, so reject anything that is not a D-Bus + # transport and derive the standard path instead. + dbus = os.environ.get("DBUS_SESSION_BUS_ADDRESS", "") + if not dbus.startswith(("unix:", "tcp:", "nonce-tcp:", "autolaunch:")): + dbus = f"unix:path=/run/user/{uid}/bus" + + return DesktopUser(name=name, uid=uid, home=home, dbus=dbus) + + +def run_as_user(user: DesktopUser, argv: Sequence[str], extra_env: Sequence[str] = ()) -> Result: + """Drop privileges to the desktop user, forwarding their session bus. + + Each environment value is a separate argv token for `env` rather than text + spliced into a shell command, so a hostile DISPLAY cannot become a command. + """ + env_args = [f"DBUS_SESSION_BUS_ADDRESS={user.dbus}", *extra_env] + return run(["sudo", "-u", user.name, "env", *env_args, *argv]) + + +# -- Package manager ---------------------------------------------------------- + + +@dataclass(frozen=True) +class PackageManager: + cmd: str + refresh: list[str] | None + list_updates: list[str] + update: list[str] + install: list[str] + # Verify names its own command: rpm and debsums do the checking, not the + # manager itself. + verify: list[str] = field(default_factory=list) + + +_DEFINITIONS: dict[str, PackageManager] = { + "apt": PackageManager( + "apt", + ["update"], + ["list", "--upgradable"], + ["upgrade", "-y"], + ["install", "-y"], + ["debsums", "-s"], + ), + "dnf": PackageManager( + "dnf", None, ["check-update"], ["upgrade", "-y"], ["install", "-y"], ["rpm", "-Va"] + ), + "pacman": PackageManager( + "pacman", + ["-Sy"], + ["-Qu"], + ["-Syu", "--noconfirm"], + ["-S", "--noconfirm"], + ["pacman", "-Qkk"], + ), + "zypper": PackageManager( + "zypper", ["refresh"], ["list-updates"], ["update", "-y"], ["install", "-y"], ["rpm", "-Va"] + ), +} + +_FAMILIES: tuple[tuple[tuple[str, ...], str], ...] = ( + (("debian", "ubuntu", "mint", "linuxmint", "pop", "elementary", "zorin", "kali"), "apt"), + (("fedora", "rhel", "centos", "almalinux", "rocky"), "dnf"), + (("arch", "cachyos", "manjaro", "endeavouros", "artix", "garuda"), "pacman"), + (("suse", "sles", "opensuse"), "zypper"), +) + + +def package_manager() -> PackageManager | None: + """Pick the manager by distro family, never by which binary is on PATH. + + A Distrobox export or Homebrew readily puts apt and pacman on a Fedora box, + and picking the first one found would run Debian commands against an rpm + system. + """ + info = distro_info() + family = f"{info['ID']} {info['ID_LIKE']}" + + name: str | None = None + for keys, manager in _FAMILIES: + if any(key in family for key in keys): + name = manager + break + + if name is None: + # Unrecognised distro: fall back to whatever is actually installed. + name = next((key for key in sorted(_DEFINITIONS) if has(key)), None) + + if name is None or not has(name): + return None + return _DEFINITIONS[name] + + +def open_url(url: str) -> bool: + """Open a URL in the desktop user's browser. + + Running as root means xdg-open would launch the browser as root, into a + session that may not even accept it. Drop back to the user who logged in, + with their session bus, the same way the audio and topgrade tools do. + """ + if not url.startswith(("http://", "https://")): + return False + if not has("xdg-open"): + return False + + user = desktop_user() + if user and is_root() and user.name != "root" and has("sudo"): + return run_as_user(user, ["xdg-open", url]).ok + return run(["xdg-open", url]).ok diff --git a/src/Linux/pchealth/tools/__init__.py b/src/Linux/pchealth/tools/__init__.py new file mode 100644 index 0000000..c22c50a --- /dev/null +++ b/src/Linux/pchealth/tools/__init__.py @@ -0,0 +1,41 @@ +"""Tool registry: catalogue id -> implementation.""" + +from __future__ import annotations + +from . import ( + audio, + battery, + boot, + cleanup, + firmware, + hardware, + logs, + network, + power, + sysinfo, + updates, +) +from .base import Cancelled, ToolContext, ToolFunc + +REGISTRY: dict[str, ToolFunc] = { + "system-info": sysinfo.system_info, + "hardware-info": hardware.hardware_info, + "ping-short": network.ping_short, + "ping-continuous": network.ping_continuous, + "traceroute": network.traceroute, + "network-reset": network.network_reset, + "bios-password": sysinfo.bios_password, + "power-options": power.power_options, + "system-update": updates.system_update, + "topgrade": updates.topgrade, + "battery-report": battery.battery_report, + "scan-repair": cleanup.scan_repair, + "disk-optimize": cleanup.disk_optimize, + "firmware-update": firmware.firmware_update, + "boot-repair": boot.boot_repair, + "disk-cleanup": cleanup.disk_cleanup, + "audio-restart": audio.audio_restart, + "system-logs": logs.system_logs, +} + +__all__ = ["REGISTRY", "Cancelled", "ToolContext", "ToolFunc"] diff --git a/src/Linux/pchealth/tools/audio.py b/src/Linux/pchealth/tools/audio.py new file mode 100644 index 0000000..79df52c --- /dev/null +++ b/src/Linux/pchealth/tools/audio.py @@ -0,0 +1,59 @@ +"""Restart the audio server. + +PipeWire and PulseAudio live in the user's session, not root's, so every call +is dropped to the desktop user with their session bus forwarded. +""" + +from __future__ import annotations + +import time + +from .. import system +from .base import ToolContext + +PIPEWIRE_UNITS = ("pipewire", "pipewire-pulse", "wireplumber") + + +def audio_restart(ctx: ToolContext) -> None: + ctx.heading("Restart Audio") + + user = system.desktop_user() + if not user: + ctx.line("Could not determine the desktop user.", "error") + return + + # Exact match: `is-active` answers "inactive" too, which a substring test + # would happily accept. + state = system.run_as_user(user, ["systemctl", "--user", "is-active", "pipewire"]) + is_pipewire = state.stdout.strip() == "active" + + if is_pipewire: + ctx.line("Detected: PipeWire", "muted") + ctx.line() + for unit in PIPEWIRE_UNITS: + ctx.line(f"[>>] Restarting {unit}...", "info") + result = system.run_as_user(user, ["systemctl", "--user", "restart", unit]) + if result.ok: + ctx.line("[OK] Done.", "ok") + else: + ctx.line(f"[!!] Exit code {result.returncode}.", "error") + if result.stderr.strip(): + ctx.line(f" {result.stderr.strip()}", "muted") + elif system.has("pulseaudio"): + ctx.line("Detected: PulseAudio", "muted") + ctx.line() + ctx.line("[>>] Restarting PulseAudio...", "info") + # Kill then start as two invocations to avoid a shell compound command. + system.run_as_user(user, ["pulseaudio", "--kill"]) + time.sleep(0.5) + result = system.run_as_user(user, ["pulseaudio", "--start"]) + if result.ok: + ctx.line("[OK] Done.", "ok") + else: + ctx.line(f"[!!] Exit code {result.returncode}.", "error") + else: + ctx.line("No supported audio server found (PipeWire or PulseAudio).", "error") + return + + ctx.line() + ctx.line("Audio services restarted.", "ok") diff --git a/src/Linux/pchealth/tools/base.py b/src/Linux/pchealth/tools/base.py new file mode 100644 index 0000000..dce5454 --- /dev/null +++ b/src/Linux/pchealth/tools/base.py @@ -0,0 +1,63 @@ +"""What a tool is. + +A tool never talks to the terminal or to GTK directly. It emits styled lines +and asks questions through the context it is handed, so the same tool runs +under the CLI menu and inside the GUI without knowing which one it is in. +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence +from dataclasses import dataclass + +# head -- section heading +# info -- ordinary text +# ok -- something succeeded +# warn -- something the user should read before continuing +# error -- something failed +# muted -- detail, command output, footnotes +Style = str + + +class Cancelled(Exception): + """Raised when the user backs out of a prompt. Never an error.""" + + +def _never() -> bool: + return False + + +@dataclass(frozen=True) +class ToolContext: + emit: Callable[[str, Style], None] + ask: Callable[[str], str] + confirm: Callable[[str], bool] + # Long-running tools poll this so a GUI can stop a continuous ping without + # the tool knowing a GUI exists. The CLI leaves it at the default and lets + # Ctrl+C do the same job. + should_stop: Callable[[], bool] = _never + + def line(self, text: str = "", style: Style = "info") -> None: + self.emit(text, style) + + def heading(self, title: str) -> None: + self.emit(title, "head") + + def rows(self, pairs: Sequence[tuple[str, str]], style: Style = "info") -> None: + """Print a label/value block with the values lined up.""" + if not pairs: + return + width = max(len(label) for label, _ in pairs) + for label, value in pairs: + self.emit(f"{label.ljust(width)} {value}", style) + + def command_output(self, rc: int, *, ok: str, failed: str | None = None) -> None: + if rc == 0: + self.emit(ok, "ok") + else: + self.emit(failed or f"Exit code {rc}.", "error") + + +# A tool is just a function over a context. The return value is unused: what +# the user sees is what the tool emitted. +ToolFunc = Callable[[ToolContext], None] diff --git a/src/Linux/pchealth/tools/battery.py b/src/Linux/pchealth/tools/battery.py new file mode 100644 index 0000000..93589b9 --- /dev/null +++ b/src/Linux/pchealth/tools/battery.py @@ -0,0 +1,109 @@ +"""Battery report, straight from the kernel's power_supply class. + +No external tool needed: upower and acpi both read these same sysfs files. +""" + +from __future__ import annotations + +from pathlib import Path + +from .. import system +from .base import ToolContext + +SUPPLY_ROOT = Path("/sys/class/power_supply") + + +def _attribute(directory: Path, *names: str) -> str | None: + """Read the first attribute that exists. + + Names vary by driver -- energy_* on one laptop, charge_* on the next -- and + any of them may be absent entirely. + """ + for name in names: + value = system.read_text(directory / name) + if value: + return value + return None + + +def _micro(raw: str | None) -> str: + """sysfs reports micro-units throughout.""" + if not raw: + return "N/A" + try: + return f"{int(raw) / 1e6:.2f}" + except ValueError: + return "N/A" + + +def _verdict(health: float) -> tuple[str, str]: + if health >= 80: + return "Good -- the battery holds most of its design capacity.", "ok" + if health >= 60: + return "Worn -- noticeably reduced runtime.", "warn" + return "Poor -- consider replacing the battery.", "error" + + +def battery_report(ctx: ToolContext) -> None: + ctx.heading("Battery Report") + + if not SUPPLY_ROOT.exists(): + ctx.line(f"{SUPPLY_ROOT} not found -- this kernel exposes no power supplies.", "error") + return + + try: + candidates = sorted(SUPPLY_ROOT.iterdir()) + except OSError as exc: + ctx.line(f"Could not read {SUPPLY_ROOT}: {exc}", "error") + return + + batteries = [d for d in candidates if _attribute(d, "type") == "Battery"] + if not batteries: + ctx.line("No battery detected -- this looks like a desktop system.", "warn") + return + + for battery in batteries: + # Drivers report either energy (uWh) or charge (uAh); the health ratio + # holds for both as long as full and design come from the same pair. + full = _attribute(battery, "energy_full", "charge_full") + design = _attribute(battery, "energy_full_design", "charge_full_design") + unit = "Wh" if (battery / "energy_full").exists() else "Ah" + + health: float | None = None + try: + if full and design and float(design) > 0: + health = round(float(full) / float(design) * 100, 1) + except ValueError: + health = None + + cycles = _attribute(battery, "cycle_count") + power = _attribute(battery, "power_now", "current_now") + capacity = _attribute(battery, "capacity") + + ctx.rows( + [ + ("Battery", battery.name), + ("Manufacturer", _attribute(battery, "manufacturer") or "N/A"), + ("Model", _attribute(battery, "model_name") or "N/A"), + ("Technology", _attribute(battery, "technology") or "N/A"), + ("Status", _attribute(battery, "status") or "N/A"), + ("Charge", f"{capacity}%" if capacity else "N/A"), + (f"Full ({unit})", _micro(full)), + (f"Design ({unit})", _micro(design)), + (f"Now ({unit})", _micro(_attribute(battery, "energy_now", "charge_now"))), + ("Voltage (V)", _micro(_attribute(battery, "voltage_now"))), + ("Draw", f"{_micro(power)} {'W' if unit == 'Wh' else 'A'}" if power else "N/A"), + ("Cycle Count", cycles or "Not reported by driver"), + ("Health", f"{health}%" if health is not None else "N/A"), + ] + ) + ctx.line() + + if health is not None: + message, style = _verdict(health) + ctx.line(message, style) + if not cycles: + ctx.line( + "[*] Many laptop batteries do not expose a cycle count to the kernel.", "muted" + ) + ctx.line() diff --git a/src/Linux/pchealth/tools/boot.py b/src/Linux/pchealth/tools/boot.py new file mode 100644 index 0000000..e17a5c2 --- /dev/null +++ b/src/Linux/pchealth/tools/boot.py @@ -0,0 +1,227 @@ +"""Boot repair for UEFI systems. + +UEFI only, deliberately. Repairing a legacy BIOS/MBR setup means writing raw +boot code to the disk -- a different and far riskier operation than +reinstalling an EFI binary onto the ESP. + +Every repair runs the bootloader's own official command. pcHealth never writes +boot sectors itself and never guesses which loader you use. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from .. import system +from .base import ToolContext + +ESP_CANDIDATES = ("/efi", "/boot/efi", "/boot") + + +@dataclass(frozen=True) +class Loader: + name: str + present: bool + commands: list[list[str]] + + @property + def state(self) -> str: + return "installed on this ESP" if self.present else "tooling present, not installed here" + + +def _efi_names() -> tuple[str, str]: + """The EFI binary name and GRUB target for this machine. + + Both follow the firmware's bitness, not the CPU's: a 64-bit CPU can ship + 32-bit UEFI firmware, and BOOTX64 will not boot there. + """ + machine = os.uname().machine + if machine.startswith(("aarch64", "arm64")): + return "BOOTAA64.EFI", "arm64-efi" + bits = system.read_text("/sys/firmware/efi/fw_platform_size") or "64" + if bits == "32": + return "BOOTIA32.EFI", "i386-efi" + return "BOOTX64.EFI", "x86_64-efi" + + +def _find_esp() -> str | None: + """Only trust a mounted vfat partition. + + Mounting one ourselves would mean picking a candidate by guesswork, on the + one filesystem where a wrong guess is fatal. + """ + for candidate in ESP_CANDIDATES: + if system.output(["findmnt", "-rno", "FSTYPE", "--target", candidate]) == "vfat": + return candidate + return None + + +def _detect_loaders(esp: str, efi_name: str, grub_target: str) -> list[Loader]: + loaders: list[Loader] = [] + esp_path = Path(esp) + + if (esp_path / "EFI/systemd").exists() or system.has("bootctl"): + loaders.append( + Loader( + name="systemd-boot", + present=(esp_path / "EFI/systemd").exists(), + commands=[["bootctl", "install", f"--esp-path={esp}"]], + ) + ) + + grub_cmd = next((c for c in ("grub-install", "grub2-install") if system.has(c)), None) + if grub_cmd: + # Fedora/RHEL name everything grub2-* and keep the config in /boot/grub2. + is_grub2 = grub_cmd == "grub2-install" + mkconfig = "grub2-mkconfig" if is_grub2 else "grub-mkconfig" + grub_dir = "/boot/grub2" if is_grub2 else "/boot/grub" + boot_id = system.distro_info()["ID"] or "linux" + loaders.append( + Loader( + name="GRUB", + present=Path(grub_dir).exists() or (esp_path / "EFI/grub").exists(), + commands=[ + [ + grub_cmd, + f"--target={grub_target}", + f"--efi-directory={esp}", + f"--bootloader-id={boot_id}", + ], + [mkconfig, "-o", str(Path(grub_dir) / "grub.cfg")], + ], + ) + ) + + # Limine has no upstream UEFI installer -- the documented procedure is to + # copy the EFI binary onto the ESP. Distros ship a helper, so prefer that. + helper = next((c for c in ("limine-update", "limine-install") if system.has(c)), None) + limine_source = Path("/usr/share/limine") / efi_name + if helper or limine_source.exists(): + if helper: + commands = [[helper]] + else: + # Never `limine bios-install` here: that writes an MBR stage and is + # documented as BIOS-only. + commands = [ + ["mkdir", "-p", str(esp_path / "EFI/BOOT")], + ["cp", str(limine_source), str(esp_path / "EFI/BOOT" / efi_name)], + ] + configs = ("limine.conf", "limine/limine.conf", "boot/limine/limine.conf") + loaders.append( + Loader( + name="Limine", + present=(esp_path / "EFI/BOOT" / efi_name).exists() + or any((esp_path / name).exists() for name in configs), + commands=commands, + ) + ) + + return loaders + + +def boot_repair(ctx: ToolContext) -> None: + ctx.heading("Boot Repair (UEFI)") + ctx.line("WARNING: This operation modifies boot-critical files.", "warn") + ctx.line("Incorrect use can render the system unbootable.", "warn") + ctx.line("Only proceed if you understand what you are doing.", "warn") + ctx.line() + + # On ostree systems the bootloader entries are generated from the + # deployments. Reinstalling by hand fights whatever produced them. + if system.is_image_based(): + ctx.line("This is an image-based system (ostree).", "error") + ctx.line("Its bootloader is managed by the deployment, not by hand.", "warn") + ctx.line("Roll back to a working deployment instead:", "warn") + ctx.line(" rpm-ostree status # list deployments", "muted") + ctx.line(" rpm-ostree rollback # boot the previous one", "muted") + return + + if not Path("/sys/firmware/efi").exists(): + ctx.line("This system booted in legacy BIOS mode (no /sys/firmware/efi).", "error") + ctx.line("pcHealth only repairs UEFI bootloaders.", "warn") + return + + efi_name, grub_target = _efi_names() + esp = _find_esp() + if not esp: + ctx.line("No mounted EFI System Partition found at /efi, /boot/efi or /boot.", "error") + ctx.line("Mount it first, then run this tool again. Candidates:", "warn") + listing = system.output(["lsblk", "-o", "NAME,SIZE,FSTYPE,PARTTYPENAME,MOUNTPOINT"]) + for line in (listing or "").splitlines(): + if "EFI System" in line or "vfat" in line or line.startswith("NAME"): + ctx.line(f" {line}", "muted") + return + + bits = system.read_text("/sys/firmware/efi/fw_platform_size") or "64" + ctx.rows( + [ + ("Firmware:", f"UEFI ({bits}-bit, {os.uname().machine})"), + ("ESP:", esp), + ("EFI binary:", efi_name), + ], + "muted", + ) + ctx.line() + + loaders = _detect_loaders(esp, efi_name, grub_target) + if not loaders: + ctx.line("No supported bootloader found (systemd-boot, GRUB or Limine).", "error") + ctx.line("Install your bootloader's package first, then run this tool again.", "warn") + return + + ctx.line("Detected bootloaders:", "info") + for index, loader in enumerate(loaders, start=1): + ctx.line(f" [{index}] {loader.name} ({loader.state})") + ctx.line(" [B] Cancel") + ctx.line() + + choice = ctx.ask("Which bootloader should be repaired?").strip().upper() + if choice == "B": + ctx.line("Cancelled.", "muted") + return + if not choice.isdigit() or not 1 <= int(choice) <= len(loaders): + ctx.line("Invalid choice.", "error") + return + loader = loaders[int(choice) - 1] + + ctx.line() + ctx.line("These commands will run as root:", "warn") + for command in loader.commands: + ctx.line(f" {' '.join(command)}") + ctx.line() + + # Two confirmations, same as the Windows tool: this is the one place where + # a mistaken keystroke leaves the machine unbootable. + if ctx.ask("Type 'yes' to continue or anything else to cancel").strip().lower() != "yes": + ctx.line("Cancelled.", "muted") + return + if ctx.ask("Last chance -- type 'CONFIRM' in capitals to proceed").strip() != "CONFIRM": + ctx.line("Cancelled.", "muted") + return + + ctx.line() + for command in loader.commands: + ctx.line(f"[>>] {' '.join(command)}", "info") + rc = system.stream_root(command, lambda line: ctx.line(f" {line}", "muted")) + if rc != 0: + ctx.line() + ctx.line(f"[!!] Failed with exit code {rc} -- stopping here.", "error") + ctx.line("The system may still boot from its existing entry. Do not reboot", "warn") + ctx.line("until you have resolved this, and keep a live USB to hand.", "warn") + return + + ctx.line(f"[OK] {loader.name} reinstalled on {esp}.", "ok") + ctx.line() + + # A copied EFI binary with no firmware boot entry still leaves an + # unbootable machine, so show the entries before the user reboots. + if system.has("efibootmgr"): + ctx.line("Current firmware boot entries:", "info") + entries = system.run_root(["efibootmgr"]).stdout + for line in entries.splitlines(): + ctx.line(f" {line}", "muted") + ctx.line() + + ctx.line("Verify the entry above before rebooting.", "warn") diff --git a/src/Linux/pchealth/tools/cleanup.py b/src/Linux/pchealth/tools/cleanup.py new file mode 100644 index 0000000..599002d --- /dev/null +++ b/src/Linux/pchealth/tools/cleanup.py @@ -0,0 +1,248 @@ +"""Disk cleanup, SSD trim and the package-integrity scan.""" + +from __future__ import annotations + +from pathlib import Path + +from .. import system +from .base import ToolContext + +FS_ERROR_PATTERNS = ( + "EXT4-fs error", + "XFS", + "BTRFS error", + "I/O error", + "read-only", +) +MAX_FINDINGS_SHOWN = 20 + + +def _step(ctx: ToolContext, label: str, argv: list[str]) -> None: + ctx.line(f"[>>] {label}", "info") + rc = system.stream_root(argv, lambda line: ctx.line(f" {line}", "muted")) + if rc == 0: + ctx.line("[OK] Done.", "ok") + else: + ctx.line(f"[--] Exit code {rc} (may be non-fatal).", "muted") + ctx.line() + + +# -- Disk cleanup ------------------------------------------------------------- + + +def _clean_packages(ctx: ToolContext, distro_id: str, distro_like: str) -> None: + family = f"{distro_id} {distro_like}" + + if any( + key in family for key in ("arch", "cachyos", "manjaro", "endeavouros", "artix", "garuda") + ): + if system.has("paccache"): + _step(ctx, "Clearing pacman cache (keeping last 2 versions)...", ["paccache", "-rk2"]) + # Passing an empty list to pacman -Rns exits non-zero and reads like a + # failure, so only run it when there is something to remove. + orphans = system.output(["pacman", "-Qdtq"]) + if orphans: + _step( + ctx, + "Removing unneeded pacman dependencies...", + ["pacman", "-Rns", *orphans.split(), "--noconfirm"], + ) + else: + ctx.line("[--] No unneeded pacman dependencies found, skipping.", "muted") + ctx.line() + elif any( + key in family for key in ("debian", "ubuntu", "mint", "pop", "elementary", "zorin", "kali") + ): + _step(ctx, "Removing unneeded apt packages...", ["apt", "autoremove", "-y"]) + _step(ctx, "Cleaning apt cache...", ["apt", "autoclean"]) + elif any(key in family for key in ("fedora", "rhel", "centos", "almalinux", "rocky")): + _step(ctx, "Removing unneeded dnf packages...", ["dnf", "autoremove", "-y"]) + _step(ctx, "Cleaning dnf cache...", ["dnf", "clean", "all"]) + elif "suse" in family: + _step(ctx, "Cleaning zypper cache...", ["zypper", "clean", "--all"]) + else: + ctx.line("[--] Package cache: distro not recognised, skipping.", "muted") + ctx.line() + + +def _clear_thumbnails(ctx: ToolContext) -> None: + # $HOME under sudo is root's, so resolve the desktop user's cache instead. + user = system.desktop_user() + if not user: + return + thumbnails = Path(user.home) / ".cache" / "thumbnails" + if not thumbnails.is_dir(): + return + + files = [p for p in thumbnails.rglob("*") if p.is_file()] + size_mb = sum(p.stat().st_size for p in files) / 1048576 if files else 0.0 + + ctx.line(f"[>>] Clearing thumbnail cache ({size_mb:.1f} MB)...", "info") + removed = 0 + for path in files: + try: + path.unlink() + removed += 1 + except OSError: + continue + ctx.line(f"[OK] Removed {removed} file(s).", "ok") + ctx.line() + + +def disk_cleanup(ctx: ToolContext) -> None: + ctx.heading("Disk Cleanup") + + info = system.distro_info() + ctx.line(f"Distro: {info['PRETTY_NAME']}", "muted") + ctx.line() + + _clean_packages(ctx, info["ID"], info["ID_LIKE"]) + + if system.has("journalctl"): + _step( + ctx, + "Vacuuming journal logs (keeping last 7 days)...", + ["journalctl", "--vacuum-time=7d"], + ) + + if system.has("flatpak"): + _step( + ctx, "Removing unused Flatpak runtimes...", ["flatpak", "uninstall", "--unused", "-y"] + ) + + _clear_thumbnails(ctx) + + ctx.line("Disk cleanup complete.", "ok") + + +# -- Disk optimization -------------------------------------------------------- + + +def _block_devices() -> list[tuple[str, str]]: + """Real disks and whether they spin, skipping loop/ram/zram/optical.""" + devices: list[tuple[str, str]] = [] + try: + entries = sorted(Path("/sys/block").iterdir()) + except OSError: + return devices + + for entry in entries: + if entry.name.startswith(("loop", "ram", "zram", "sr")): + continue + rotational = system.read_text(entry / "queue" / "rotational") + kind = {"0": "SSD / NVMe", "1": "HDD"}.get(rotational or "", "Unknown") + devices.append((entry.name, kind)) + return devices + + +def disk_optimize(ctx: ToolContext) -> None: + ctx.heading("Disk Optimization") + + # Linux filesystems do not fragment the way NTFS does, so the useful half + # of what dfrgui does on Windows is discarding unused blocks on an SSD. + devices = _block_devices() + if devices: + ctx.rows(devices) + ctx.line() + if not any(kind == "SSD / NVMe" for _, kind in devices): + ctx.line("No solid-state device detected -- there is nothing to trim.", "warn") + ctx.line("Linux filesystems do not need defragmenting.", "muted") + return + + if not system.has("fstrim"): + ctx.line("fstrim not found. Install util-linux.", "error") + return + + # Many distros already run fstrim.timer weekly; say so rather than + # implying the manual run was necessary. + if system.output(["systemctl", "is-enabled", "fstrim.timer"]) == "enabled": + ctx.line("Note: fstrim.timer is enabled, so this already runs weekly.", "muted") + ctx.line() + + ctx.line("[>>] Trimming all mounted filesystems that support it...", "info") + ctx.line(" This can take a minute on a large or nearly full disk.", "muted") + ctx.line() + + rc = system.stream_root( + ["fstrim", "--all", "--verbose"], lambda line: ctx.line(f" {line}", "muted") + ) + ctx.line() + ctx.command_output(rc, ok="[OK] Trim complete.", failed=f"[!!] fstrim exited with code {rc}.") + + +# -- Scan + repair ------------------------------------------------------------ + + +def scan_repair(ctx: ToolContext) -> None: + ctx.heading("Scan + Repair (package integrity)") + + manager = system.package_manager() + if not manager or not manager.verify: + ctx.line("No supported package manager found (apt/dnf/pacman/zypper).", "error") + return + + verify_cmd, *verify_args = manager.verify + if not system.has(verify_cmd): + ctx.line( + f"{verify_cmd} is not installed -- it does the checking, not {manager.cmd}.", "error" + ) + ctx.line( + f"Install it with: {manager.cmd} {' '.join(manager.install)} {verify_cmd}", "muted" + ) + return + + # -- Filesystem errors ---------------------------------------------------- + # Read-only on purpose: fsck cannot safely touch a mounted root, so report + # what the kernel already saw and let the user repair from a live image. + ctx.line("[>>] Step 1/2 -- Checking the kernel log for filesystem errors...", "info") + dmesg = system.run_root(["dmesg", "--level=err,warn"]) + fs_errors = [ + line + for line in dmesg.stdout.splitlines() + if any(pattern in line for pattern in FS_ERROR_PATTERNS) + ] + + if fs_errors: + ctx.line("[!!] The kernel has logged filesystem errors:", "error") + ctx.line() + for line in fs_errors[-10:]: + ctx.line(f" {line}", "muted") + ctx.line() + ctx.line("Run fsck from a live image -- it cannot repair a mounted root.", "warn") + else: + ctx.line("[OK] No filesystem errors in the kernel log.", "ok") + ctx.line() + + # -- Package integrity ---------------------------------------------------- + ctx.line(f"[>>] Step 2/2 -- Verifying installed packages with {verify_cmd}...", "info") + ctx.line(" This reads every packaged file and takes several minutes.", "muted") + ctx.line() + + if not ctx.confirm("Start the verification?"): + ctx.line("Skipped.", "muted") + return + + ctx.line() + # Merge stderr: debsums reports every changed file there, so dropping it + # would turn a corrupted system into a clean bill of health. + findings: list[str] = [] + system.stream_root([verify_cmd, *verify_args], findings.append, should_stop=ctx.should_stop) + findings = [line.strip() for line in findings if line.strip()] + + if not findings: + ctx.line("[OK] Every packaged file matches the package database.", "ok") + return + + ctx.line(f"[!!] {len(findings)} file(s) no longer match their package:", "error") + ctx.line() + for line in findings[:MAX_FINDINGS_SHOWN]: + ctx.line(f" {line}", "muted") + if len(findings) > MAX_FINDINGS_SHOWN: + ctx.line(f" ... and {len(findings) - MAX_FINDINGS_SHOWN} more", "muted") + + ctx.line() + ctx.line("Config files you edited yourself show up here too -- that is expected.", "muted") + ctx.line( + f"Repair a package with: {manager.cmd} {' '.join(manager.install)} --reinstall ", + "muted", + ) diff --git a/src/Linux/pchealth/tools/firmware.py b/src/Linux/pchealth/tools/firmware.py new file mode 100644 index 0000000..77649b0 --- /dev/null +++ b/src/Linux/pchealth/tools/firmware.py @@ -0,0 +1,94 @@ +"""Firmware updates through fwupd / LVFS. + +The vendor-neutral counterpart to HP Image Assistant on Windows: fwupd ships +BIOS, dock, SSD and peripheral firmware for most vendors. +""" + +from __future__ import annotations + +from .. import system +from .base import ToolContext + +_INSTALL_HINTS = ( + " Debian / Ubuntu: apt install fwupd", + " Fedora / RHEL: dnf install fwupd", + " Arch / CachyOS: pacman -S fwupd", + " openSUSE: zypper install fwupd", +) + +# fwupd is a daemon; its CLI still exits having printed nothing useful when it +# is masked or not running. An empty update list must never become an +# invitation to flash firmware. +_DAEMON_DOWN = ("Failed to connect to daemon", "Failed to load daemon", "could not be activated") +_REFRESH_FAILED = ("Failed to download", "transient failure", "Failed to connect") +_NO_UPDATES = ( + "No updatable devices", + "No updates available", + "Devices with no available firmware updates", +) + + +def firmware_update(ctx: ToolContext) -> None: + ctx.heading("Firmware Update (fwupd / LVFS)") + + if not system.has("fwupdmgr"): + ctx.line("fwupdmgr is not installed.", "error") + ctx.line() + ctx.line("Install it with your package manager:", "muted") + for hint in _INSTALL_HINTS: + ctx.line(hint, "muted") + return + + ctx.line("[>>] Refreshing firmware metadata from LVFS...", "info") + # --force refreshes even when the cached metadata is still considered fresh. + refresh = system.run_root(["fwupdmgr", "refresh", "--force"]) + refresh_text = refresh.stdout + refresh.stderr + for line in refresh_text.splitlines(): + ctx.line(f" {line}", "muted") + # Without fresh metadata the verdict below reflects whatever was cached, + # which may be months old -- say so rather than reporting "up to date". + stale = any(marker in refresh_text for marker in _REFRESH_FAILED) + + ctx.line() + ctx.line("[>>] Checking for firmware updates...", "info") + ctx.line() + updates = system.run_root(["fwupdmgr", "get-updates"]) + updates_text = updates.stdout + updates.stderr + + if any(marker in updates_text for marker in _DAEMON_DOWN): + ctx.line("Could not reach the fwupd daemon.", "error") + ctx.line("Start it with: systemctl start fwupd", "muted") + return + + # fwupdmgr exits non-zero when there is simply nothing to do, so read text. + if not updates_text.strip() or any(marker in updates_text for marker in _NO_UPDATES): + if stale: + ctx.line("No updates found, but the LVFS metadata could not be refreshed.", "warn") + ctx.line( + "This answer is based on cached data -- check again once you are online.", "muted" + ) + else: + ctx.line("All firmware is up to date.", "ok") + return + + for line in updates_text.splitlines(): + ctx.line(f" {line}", "muted") + + ctx.line() + ctx.line("[!] Firmware updates carry real risk. Do not power the machine off", "warn") + ctx.line(" while one is running, and plug in the charger on a laptop.", "warn") + ctx.line() + + if not ctx.confirm("Install these firmware updates?"): + ctx.line("Cancelled.", "muted") + return + + ctx.line() + ctx.line("[>>] Installing firmware updates...", "info") + rc = system.stream_root(["fwupdmgr", "update"], lambda line: ctx.line(f" {line}", "muted")) + ctx.line() + if rc == 0: + ctx.line("[OK] Firmware update complete.", "ok") + ctx.line("Some devices only apply the update on the next reboot.", "muted") + else: + ctx.line(f"[!!] fwupdmgr exited with code {rc}.", "error") diff --git a/src/Linux/pchealth/tools/hardware.py b/src/Linux/pchealth/tools/hardware.py new file mode 100644 index 0000000..17bb9cc --- /dev/null +++ b/src/Linux/pchealth/tools/hardware.py @@ -0,0 +1,246 @@ +"""Hardware information: CPU, GPU, storage (SMART), RAM and temperatures.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from .. import system +from .base import ToolContext + +GPU_PATTERN = re.compile( + r"^[\w:.]+\s+(?:VGA compatible controller|Display controller|3D controller):\s*(.+)$" +) +# SSD wear-levelling attributes, in the order vendors actually use them. +SSD_LIFE_ATTRIBUTES = (231, 202, 177) + + +def _lscpu() -> dict[str, str]: + values: dict[str, str] = {} + for line in (system.output(["lscpu"]) or "").splitlines(): + key, sep, value = line.partition(":") + if sep: + values[key.strip()] = value.strip() + return values + + +def _meminfo() -> dict[str, int]: + values: dict[str, int] = {} + for line in (system.read_text("/proc/meminfo") or "").splitlines(): + key, sep, rest = line.partition(":") + if not sep: + continue + number = rest.strip().split(" ", 1)[0] + if number.isdigit(): + values[key] = int(number) + return values + + +def _gb(kib: int) -> str: + return f"{kib / 1048576:.2f}" + + +def _smart_json(argv: list[str]) -> dict[str, Any] | None: + """smartctl exits non-zero for a disk with warnings, so ignore the code. + + Its JSON is still complete in that case -- that is the whole point of + asking for JSON rather than parsing the human-readable report. + """ + result = system.run_root(argv) + if not result.stdout.strip(): + return None + try: + parsed = json.loads(result.stdout) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def _cpu_section(ctx: ToolContext) -> None: + ctx.heading("CPU") + data = _lscpu() + if not data: + ctx.line("lscpu not available. Install util-linux.", "warn") + return + + max_mhz = data.get("CPU max MHz", "") + try: + speed = f"{round(float(max_mhz.replace(',', '.')))} MHz" if max_mhz else "N/A" + except ValueError: + speed = "N/A" + + ctx.rows( + [ + ("CPU Name", data.get("Model name", "N/A")), + ("Architecture", data.get("Architecture", "N/A")), + ("Cores", data.get("Core(s) per socket", "N/A")), + ("Threads", data.get("CPU(s)", "N/A")), + ("Max Speed", speed), + ("L1d Cache", data.get("L1d cache", "N/A")), + ("L1i Cache", data.get("L1i cache", "N/A")), + ("L2 Cache", data.get("L2 cache", "N/A")), + ("L3 Cache", data.get("L3 cache", "N/A")), + ("Virtualization", data.get("Virtualization", "N/A")), + ] + ) + + +def _gpu_section(ctx: ToolContext) -> None: + ctx.heading("GPU") + listing = system.output(["lspci"]) + if listing is None: + ctx.line("lspci not available. Install pciutils.", "warn") + return + + found = [ + match.group(1).strip() + for line in listing.splitlines() + if (match := GPU_PATTERN.match(line)) + ] + if not found: + ctx.line("No GPU found via lspci.", "warn") + return + for gpu in found: + ctx.line(f" {gpu}") + + +def _storage_section(ctx: ToolContext) -> None: + ctx.heading("Storage") + + if not system.has("smartctl"): + listing = system.output(["lsblk", "-d", "-o", "NAME,SIZE,TYPE,MODEL"]) + if listing: + for line in listing.splitlines(): + ctx.line(f" {line}", "muted") + ctx.line() + ctx.line("Install smartmontools for life %, temperature and power-on hours.", "muted") + else: + ctx.line("Storage section skipped -- neither smartctl nor lsblk available.", "warn") + return + + scan = _smart_json(["smartctl", "--scan", "--json"]) + devices = scan.get("devices", []) if scan else [] + if not devices: + ctx.line("smartctl scan found no devices.", "warn") + return + + rows: list[tuple[str, str]] = [] + for device in devices: + name = device.get("name") + kind = device.get("type", "") + if not name: + continue + argv = ["smartctl", "-a", name, "--json"] + if kind and kind != "auto": + argv += ["-d", kind] + data = _smart_json(argv) + if not data or not data.get("model_name"): + continue + + is_nvme = kind == "nvme" + rotation = data.get("rotation_rate", 0) or 0 + media = "SSD" if is_nvme or rotation == 0 else "HDD" + + life = "N/A" + if is_nvme: + used = data.get("nvme_smart_health_information_log", {}).get("percentage_used") + if used is not None: + life = f"{max(0, 100 - int(used))}%" + elif media == "SSD": + table = data.get("ata_smart_attributes", {}).get("table", []) + attribute = next((a for a in table if a.get("id") in SSD_LIFE_ATTRIBUTES), None) + if attribute: + life = f"{attribute.get('value')}%" + + capacity = data.get("capacity", {}).get("bytes") + temperature = data.get("temperature", {}).get("current") + hours = data.get("power_on_time", {}).get("hours") + passed = data.get("smart_status", {}).get("passed") + health = "Healthy" if passed is True else "FAILING" if passed is False else "Unknown" + + rows.append( + ( + str(data["model_name"]), + f"{media} {round(capacity / 1024**3) if capacity else 'N/A'} GB " + f"{temperature if temperature is not None else 'N/A'} C " + f"{hours if hours is not None else 'N/A'} h " + f"life {life} {health}", + ) + ) + + if rows: + ctx.rows(rows) + else: + ctx.line("No usable SMART data.", "warn") + + +def _memory_section(ctx: ToolContext) -> None: + ctx.heading("Memory (RAM)") + memory = _meminfo() + total = memory.get("MemTotal") + if not total: + ctx.line("RAM information not available.", "warn") + return + + available = memory.get("MemAvailable", 0) + buff_cache = memory.get("Buffers", 0) + memory.get("Cached", 0) + memory.get("SReclaimable", 0) + swap_total = memory.get("SwapTotal", 0) + swap_free = memory.get("SwapFree", 0) + + ctx.rows( + [ + ("Total (GB)", _gb(total)), + ("Used (GB)", _gb(total - available)), + ("Available (GB)", _gb(available)), + ("Buff/Cache (GB)", _gb(buff_cache)), + ("Swap Total (GB)", _gb(swap_total)), + ("Swap Used (GB)", _gb(swap_total - swap_free)), + ] + ) + + +def _sensors_section(ctx: ToolContext) -> None: + """Straight from the kernel's hwmon class. + + The same source lm-sensors reads, so nothing needs to be installed. + """ + ctx.heading("Sensors (Temperatures)") + root = Path("/sys/class/hwmon") + if not root.exists(): + ctx.line("No hwmon sensors exposed by this kernel.", "muted") + return + + readings: list[tuple[str, str]] = [] + try: + chips = sorted(root.iterdir()) + except OSError: + chips = [] + + for chip in chips: + chip_name = system.read_text(chip / "name") or chip.name + for entry in sorted(chip.glob("temp*_input")): + raw = system.read_text(entry) + if not raw or not raw.lstrip("-").isdigit(): + continue + label = system.read_text(entry.with_name(entry.name.replace("_input", "_label"))) + # hwmon reports millidegrees Celsius. + readings.append((f"{chip_name} {label or entry.stem}", f"{int(raw) / 1000:.1f} C")) + + if readings: + ctx.rows(readings) + else: + ctx.line("No temperature readings available.", "muted") + + +def hardware_info(ctx: ToolContext) -> None: + _cpu_section(ctx) + ctx.line() + _gpu_section(ctx) + ctx.line() + _storage_section(ctx) + ctx.line() + _memory_section(ctx) + ctx.line() + _sensors_section(ctx) diff --git a/src/Linux/pchealth/tools/logs.py b/src/Linux/pchealth/tools/logs.py new file mode 100644 index 0000000..2b4c2ca --- /dev/null +++ b/src/Linux/pchealth/tools/logs.py @@ -0,0 +1,62 @@ +"""Recent error and warning entries from the systemd journal.""" + +from __future__ import annotations + +from .. import system +from .base import ToolContext + +_VIEWS: dict[str, tuple[str, list[str]]] = { + "1": ("Errors from today", ["journalctl", "--priority=err", "--since=today", "--no-pager"]), + "2": ( + "Last 100 error/warning entries", + ["journalctl", "--priority=warning", "-n", "100", "--no-pager"], + ), + "3": ("Boot messages (current boot)", ["journalctl", "-b", "--no-pager", "-n", "100"]), + "4": ("Kernel messages", ["dmesg", "--level=err,warn"]), +} + + +def system_logs(ctx: ToolContext) -> None: + ctx.heading("System Logs (journalctl)") + + if not system.has("journalctl"): + ctx.line("journalctl not found. This system may not use systemd.", "error") + return + + for key, (label, _) in _VIEWS.items(): + ctx.line(f" [{key}] {label}") + ctx.line(" [5] Failed services") + ctx.line(" [B] Back") + ctx.line() + + choice = ctx.ask("Choice").strip().upper() + + if choice == "B": + return + + if choice == "5": + ctx.line("[>>] Failed systemd units...", "info") + ctx.line() + failed = system.output(["systemctl", "--failed", "--no-legend", "--no-pager"]) + if failed: + for line in failed.splitlines(): + ctx.line(f" {line}", "muted") + ctx.line() + ctx.line("Inspect one with: journalctl -u -b", "muted") + else: + ctx.line("No failed units.", "ok") + return + + view = _VIEWS.get(choice) + if not view: + ctx.line("Invalid choice.", "error") + return + + label, argv = view + ctx.line(f"[>>] {label}...", "info") + ctx.line() + # The journal is root-readable only for system messages; a plain user sees + # their own entries and nothing else, which silently looks like a clean log. + system.stream_root( + argv, lambda line: ctx.line(f" {line}", "muted"), should_stop=ctx.should_stop + ) diff --git a/src/Linux/pchealth/tools/network.py b/src/Linux/pchealth/tools/network.py new file mode 100644 index 0000000..d276fa5 --- /dev/null +++ b/src/Linux/pchealth/tools/network.py @@ -0,0 +1,92 @@ +"""Ping, traceroute and the network stack reset.""" + +from __future__ import annotations + +import contextlib + +from .. import system +from .base import ToolContext + +PING_TARGET = "8.8.8.8" +TRACE_TARGET = "google.com" + + +def ping_short(ctx: ToolContext) -> None: + ctx.heading(f"Short Ping Test ({PING_TARGET}, 4 packets)") + # -w caps the total run: without it an unreachable host with a slow DNS + # path can sit there far longer than four packets suggest. + rc = system.stream( + ["ping", "-c", "4", "-W", "2", "-w", "15", PING_TARGET], + lambda line: ctx.line(f" {line}", "muted"), + ) + ctx.line() + if rc == 0: + ctx.line("Host is reachable.", "ok") + else: + ctx.line("No usable reply. Check your network connection.", "error") + + +def ping_continuous(ctx: ToolContext) -> None: + ctx.heading(f"Continuous Ping Test ({PING_TARGET})") + ctx.line("Press Ctrl+C to stop.", "muted") + ctx.line() + # Ctrl+C is how this tool is meant to end, not a failure. + with contextlib.suppress(KeyboardInterrupt): + system.stream( + ["ping", PING_TARGET], + lambda line: ctx.line(f" {line}", "muted"), + should_stop=ctx.should_stop, + ) + ctx.line() + ctx.line("Ping test stopped.", "muted") + + +def traceroute(ctx: ToolContext) -> None: + ctx.heading(f"Traceroute to {TRACE_TARGET} (max 30 hops)") + command = next((c for c in ("traceroute", "tracepath") if system.has(c)), None) + if not command: + ctx.line("Neither traceroute nor tracepath is installed.", "warn") + ctx.line("Install via: apt install traceroute (or dnf / pacman / zypper)", "muted") + return + system.stream( + [command, TRACE_TARGET], + lambda line: ctx.line(f" {line}", "muted"), + should_stop=ctx.should_stop, + ) + + +def network_reset(ctx: ToolContext) -> None: + ctx.heading("Reset Network Stack") + if not system.has("systemctl"): + ctx.line("systemctl not found. This system may not use systemd.", "error") + return + + ctx.line("Note: the network connection will drop briefly.", "warn") + ctx.line() + if not ctx.confirm("Restart networking now?"): + ctx.line("Cancelled.", "muted") + return + + manager_active = system.output(["systemctl", "is-active", "NetworkManager"]) == "active" + unit = "NetworkManager" if manager_active or system.has("nmcli") else "systemd-networkd" + + ctx.line(f"[>>] Restarting {unit}...", "info") + rc = system.stream_root( + ["systemctl", "restart", unit], lambda line: ctx.line(f" {line}", "muted") + ) + ctx.command_output(rc, ok="[OK] Done.") + + if system.has("resolvectl"): + flush = ["resolvectl", "flush-caches"] + elif system.has("systemd-resolve"): + flush = ["systemd-resolve", "--flush-caches"] + else: + flush = [] + + if flush: + ctx.line("[>>] Flushing DNS cache...", "info") + rc = system.stream_root(flush, lambda line: ctx.line(f" {line}", "muted")) + ctx.command_output(rc, ok="[OK] Done.") + + ctx.line() + ctx.line("Network reset complete.", "ok") diff --git a/src/Linux/pchealth/tools/power.py b/src/Linux/pchealth/tools/power.py new file mode 100644 index 0000000..fdc5850 --- /dev/null +++ b/src/Linux/pchealth/tools/power.py @@ -0,0 +1,44 @@ +"""Shutdown, reboot and log off.""" + +from __future__ import annotations + +from .. import system +from .base import ToolContext + + +def power_options(ctx: ToolContext) -> None: + ctx.heading("Power Options") + ctx.line(" [1] Log Off") + ctx.line(" [2] Restart") + ctx.line(" [3] Shutdown") + ctx.line(" [B] Cancel") + ctx.line() + + choice = ctx.ask("Choice").strip().upper() + + if choice == "1": + # Under sudo the environment describes root; log off the human instead. + user = system.desktop_user() + if not user: + ctx.line("Could not determine the desktop user.", "error") + return + if not ctx.confirm(f"Log off {user.name}?"): + ctx.line("Cancelled.", "muted") + return + # loginctl ends the session cleanly, unlike killing the processes. + rc = system.run_root(["loginctl", "terminate-user", user.name]).returncode + ctx.command_output(rc, ok="[OK] Session ended.") + elif choice == "2": + if not ctx.confirm("Restart the system?"): + ctx.line("Cancelled.", "muted") + return + system.run_root(["shutdown", "-r", "now"]) + elif choice == "3": + if not ctx.confirm("Shut down the system?"): + ctx.line("Cancelled.", "muted") + return + system.run_root(["shutdown", "-h", "now"]) + elif choice == "B": + ctx.line("Cancelled.", "muted") + else: + ctx.line("Invalid choice.", "error") diff --git a/src/Linux/pchealth/tools/sysinfo.py b/src/Linux/pchealth/tools/sysinfo.py new file mode 100644 index 0000000..3f0384c --- /dev/null +++ b/src/Linux/pchealth/tools/sysinfo.py @@ -0,0 +1,177 @@ +"""System information, and the BIOS password link.""" + +from __future__ import annotations + +import os +import socket + +from .. import system +from .base import ToolContext + +_SECURE_BOOT_NOTE = ( + "[*] Secure Boot shows the UEFI firmware state only. Actual enforcement " + "depends on shim/MOK setup and varies per distro." +) + + +def _meminfo() -> dict[str, int]: + values: dict[str, int] = {} + for line in (system.read_text("/proc/meminfo") or "").splitlines(): + key, sep, rest = line.partition(":") + if not sep: + continue + number = rest.strip().split(" ", 1)[0] + if number.isdigit(): + values[key] = int(number) + return values + + +def _cpu_model() -> str: + for line in (system.read_text("/proc/cpuinfo") or "").splitlines(): + key, sep, value = line.partition(":") + # x86 reports "model name"; arm64 has no such field and uses "Model". + if sep and key.strip() in ("model name", "Model"): + return value.strip() + return "N/A" + + +def _machine_model() -> str: + vendor = system.read_text("/sys/class/dmi/id/sys_vendor") + model = system.read_text("/sys/class/dmi/id/product_name") + if vendor and model: + return f"{vendor} {model}" + return model or "N/A" + + +def _secure_boot() -> str: + state = system.output(["mokutil", "--sb-state"]) + if state: + lowered = state.lower() + if "enabled" in lowered: + return "Enabled" + if "disabled" in lowered: + return "Disabled" + return state + + # No mokutil: read the EFI variable the kernel exposes. The first four + # bytes are the variable attributes; the fifth is the flag itself. + efivars = "/sys/firmware/efi/efivars" + try: + names = [name for name in os.listdir(efivars) if name.startswith("SecureBoot-")] + except OSError: + return "N/A" + for name in names: + try: + with open(os.path.join(efivars, name), "rb") as handle: + raw = handle.read(5) + except OSError: + return "Unknown" + if len(raw) >= 5: + return "Enabled" if raw[4] == 1 else "Disabled" + return "N/A" + + +def _package_count() -> str: + for command, argv, label in ( + ("pacman", ["pacman", "-Q"], "pacman"), + ("dpkg", ["dpkg-query", "-f", "${binary:Package}\n", "-W"], "dpkg"), + ("rpm", ["rpm", "-qa"], "rpm"), + ): + if not system.has(command): + continue + listing = system.output(argv) + if listing is not None: + return f"{len(listing.splitlines())} ({label})" + return "N/A" + + +def _timezone() -> str: + # timedatectl is unavailable without systemd (containers, WSL, OpenRC). + zone = system.output(["timedatectl", "show", "--property=Timezone", "--value"]) + if zone: + return zone + return os.environ.get("TZ") or system.output(["date", "+%Z"]) or "N/A" + + +def _session_type() -> str: + if os.environ.get("WAYLAND_DISPLAY"): + return "Wayland" + if os.environ.get("DISPLAY"): + return "X11" + return "Unknown" + + +def system_info(ctx: ToolContext) -> None: + ctx.heading("System Information") + + memory = _meminfo() + total_kib = memory.get("MemTotal") + available_kib = memory.get("MemAvailable") + total_gb = f"{total_kib / 1048576:.2f}" if total_kib else "N/A" + used_gb = ( + f"{(total_kib - available_kib) / 1048576:.2f}" + if total_kib and available_kib is not None + else "N/A" + ) + + uname = os.uname() + user = system.desktop_user() + shell = os.environ.get("SHELL", "Unknown").rsplit("/", 1)[-1] + firmware = "UEFI" if os.path.exists("/sys/firmware/efi") else "Legacy BIOS" + + ctx.rows( + [ + ("Computer Name", socket.gethostname()), + ("Machine", _machine_model()), + ("OS Name", system.distro_info()["PRETTY_NAME"]), + ("Kernel", uname.release), + ("Architecture", uname.machine), + ("CPU", _cpu_model()), + ("RAM Used (GB)", used_gb), + ("RAM Total (GB)", total_gb), + ("Firmware", firmware), + ("Secure Boot", f"{_secure_boot()} [*]"), + ("Uptime", system.output(["uptime", "-p"]) or "N/A"), + ("Last Boot", system.output(["uptime", "-s"]) or "N/A"), + ( + "Desktop", + os.environ.get("XDG_CURRENT_DESKTOP") + or os.environ.get("DESKTOP_SESSION") + or "Unknown", + ), + ("Session", _session_type()), + ("Shell", shell), + ("Packages", _package_count()), + ("Timezone", _timezone()), + ("User", user.name if user else "N/A"), + ] + ) + ctx.line() + ctx.line(_SECURE_BOOT_NOTE, "muted") + + +def bios_password(ctx: ToolContext) -> None: + """Links to bios-pw.org. Credits: @bacher09 -- pwgen-for-bios.""" + ctx.heading("BIOS Password Recovery") + ctx.line("This tool links to bios-pw.org -- a website that generates") + ctx.line("recovery codes for locked BIOS passwords.") + ctx.line("Credits for this tool go to: @bacher09", "muted") + ctx.line() + ctx.line(" [1] Visit bios-pw.org (recovery tool)") + ctx.line(" [2] Visit repository (learn more about how it works)") + ctx.line(" [B] Back") + ctx.line() + + choice = ctx.ask("Choice").strip().upper() + urls = { + "1": "https://bios-pw.org", + "2": "https://github.com/bacher09/pwgen-for-bios", + } + if choice == "B": + return + url = urls.get(choice) + if not url: + ctx.line("Invalid choice.", "error") + return + if not system.open_url(url): + ctx.line(f"Could not open a browser. Visit: {url}", "warn") diff --git a/src/Linux/pchealth/tools/updates.py b/src/Linux/pchealth/tools/updates.py new file mode 100644 index 0000000..c8e3dfa --- /dev/null +++ b/src/Linux/pchealth/tools/updates.py @@ -0,0 +1,147 @@ +"""Package updates: the distro's own manager, and topgrade.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .. import system +from .base import ToolContext + +PREVIEW_LINES = 15 + +# topgrade is interactive (pacnew prompts and the like), so it gets a real +# terminal rather than a captured pipe. +TERMINALS: dict[str, list[str]] = { + "gnome-terminal": ["--wait", "--"], + "konsole": ["--hold", "-e"], + "alacritty": ["-e"], + "ptyxis": ["--"], + "kitty": [], + "xfce4-terminal": ["--hold", "-e"], + "xterm": ["-hold", "-e"], +} + + +def _reboot_required() -> bool: + if system.has("needs-restarting"): + # Exits non-zero when a reboot is needed. + return not system.run(["needs-restarting", "-r"]).ok + return Path("/var/run/reboot-required").exists() + + +def system_update(ctx: ToolContext) -> None: + ctx.heading("Update all packages") + + manager = system.package_manager() + if not manager: + ctx.line("No supported package manager found (apt/dnf/pacman/zypper).", "error") + return + + ctx.line(f"Package manager: {manager.cmd}", "muted") + ctx.line() + + if manager.refresh: + ctx.line("[>>] Refreshing package index...", "info") + refresh = system.run_root([manager.cmd, *manager.refresh]) + if not refresh.ok: + ctx.line( + f"[!!] Refresh failed (exit code {refresh.returncode}). Check your network.", + "error", + ) + return + + ctx.line("[>>] Checking for available updates...", "info") + ctx.line() + # dnf check-update exits 100 when updates exist and 0 when there are none; + # pacman -Qu exits 1 on an empty list. Judge by output, not exit code. + listing = system.run_root([manager.cmd, *manager.list_updates]) + lines = [ + line.strip() + for line in listing.stdout.splitlines() + if line.strip() and not line.startswith(("Listing", "Last metadata")) + ] + + if not lines: + ctx.line("Everything is already up to date.", "ok") + return + + for line in lines[:PREVIEW_LINES]: + ctx.line(f" {line}", "muted") + if len(lines) > PREVIEW_LINES: + ctx.line(f" ... and {len(lines) - PREVIEW_LINES} more", "muted") + ctx.line() + ctx.line(f"{len(lines)} update(s) available.", "info") + ctx.line() + + if not ctx.confirm("Proceed with updating all packages?"): + ctx.line("Update cancelled.", "muted") + return + + ctx.line() + ctx.line("[>>] Updating all packages...", "info") + rc = system.stream_root( + [manager.cmd, *manager.update], lambda line: ctx.line(f" {line}", "muted") + ) + ctx.line() + if rc != 0: + ctx.line(f"[!!] Update exited with code {rc}.", "error") + return + + ctx.line("[OK] Update complete.", "ok") + # Kernel and glibc updates only take effect after a restart. + if _reboot_required(): + ctx.line("[!] A reboot is required to finish this update.", "warn") + + +def topgrade(ctx: ToolContext) -> None: + ctx.heading("Topgrade -- Full System Upgrade") + + if not system.has("topgrade"): + ctx.line("topgrade is not installed.", "error") + ctx.line() + ctx.line("Install it with your package manager:", "muted") + ctx.line(" Arch / CachyOS / Manjaro: pacman -S topgrade", "muted") + ctx.line(" Debian / Ubuntu / Fedora: cargo install topgrade", "muted") + return + + user = system.desktop_user() + if not user: + ctx.line("Could not determine the desktop user.", "error") + return + + ctx.line("topgrade will upgrade:", "muted") + ctx.line(" packages, flatpak, VS Code extensions, uv tools,", "muted") + ctx.line(" gcloud, helm, firmware, and more.", "muted") + ctx.line() + + # Reconstruct the session environment so GNOME Shell extensions and + # session-aware tools work when topgrade is spawned from a root context + # that did not inherit the graphical session. Each value is a separate + # argv token for `env`, so a hostile DISPLAY cannot become a command. + session_env = [ + f"DBUS_SESSION_BUS_ADDRESS={user.dbus}", + f"WAYLAND_DISPLAY={os.environ.get('WAYLAND_DISPLAY', 'wayland-0')}", + f"DISPLAY={os.environ.get('DISPLAY', ':0')}", + ] + # Fixed literal -- the shell is only here to hold the window open after. + run_command = [ + "sudo", + "-u", + user.name, + "env", + *session_env, + "bash", + "-c", + 'topgrade; echo; read -r -p "Press Enter to close..."', + ] + + for terminal, args in TERMINALS.items(): + if not system.has(terminal): + continue + ctx.line(f"[>>] Opening topgrade in {terminal}...", "info") + system.run([terminal, *args, *run_command]) + return + + ctx.line("No supported terminal emulator found.", "error") + ctx.line("Install one of: " + ", ".join(TERMINALS), "muted") diff --git a/src/Linux/pchealth/version.py b/src/Linux/pchealth/version.py new file mode 100644 index 0000000..d8ae683 --- /dev/null +++ b/src/Linux/pchealth/version.py @@ -0,0 +1,32 @@ +"""Version lookup. + +The repo-root VERSION file is the single source of truth, the same one the +WinUI project bakes into its assembly and the one hatch stamps into the wheel +at build time. An installed copy has no repo around it, so it falls back to +that stamped metadata; the constant below only survives a broken install. +""" + +from importlib import metadata +from pathlib import Path + +__version__ = "0.0.0" + + +def _from_repo() -> str | None: + # pchealth/version.py -> pchealth -> Linux -> src -> repo root + candidate = Path(__file__).resolve().parents[3] / "VERSION" + try: + text = candidate.read_text(encoding="utf-8").strip() + except OSError: + return None + return text or None + + +def get_version() -> str: + repo = _from_repo() + if repo: + return repo + try: + return metadata.version("pchealth") + except metadata.PackageNotFoundError: + return __version__ diff --git a/src/Linux/pyproject.toml b/src/Linux/pyproject.toml new file mode 100644 index 0000000..91f077c --- /dev/null +++ b/src/Linux/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "pchealth" +description = "pcHealth for Linux -- terminal and GTK4 health toolkit" +readme = "README.md" +license = { text = "GPL-3.0-or-later" } +requires-python = ">=3.11" +dynamic = ["version"] +dependencies = [] + +# PyGObject is packaged by every target distro. Installing it from PyPI needs a +# compiler and the GObject headers, so the GUI extra exists for virtualenvs -- +# on a normal system the distro package is the right answer. +[project.optional-dependencies] +gui = ["PyGObject>=3.46"] + +[project.scripts] +pchealth = "pchealth.__main__:main" +pchealth-gui = "pchealth.gui.app:main" + +# Version comes from the repo-root VERSION file, the same one the WinUI project +# bakes into its assembly, so a built wheel cannot disagree with the rest of +# the repository. +[tool.hatch.version] +path = "../../VERSION" +pattern = "(?P[^\\s]+)" + +# The catalogue lives at the repo root so both stacks read the same file. An +# installed copy has no repo around it, so it travels inside the package and +# catalog.py prefers that copy when it is there. +[tool.hatch.build.targets.wheel.force-include] +"../../assets/tools.json" = "pchealth/tools.json" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF"] + +[tool.mypy] +python_version = "3.11" +strict = true +[[tool.mypy.overrides]] +module = ["gi.*"] +ignore_missing_imports = true + +# PyGObject ships no type stubs, so every GTK symbol is Any. Under strict mode +# that turns each widget subclass and each GLib.SOURCE_REMOVE into an error +# about Any rather than a real type problem, so the two rules that only fire +# because of the missing stubs are relaxed for the GUI layer alone. +[[tool.mypy.overrides]] +module = ["pchealth.gui.*"] +disallow_subclassing_any = false +warn_return_any = false From a79b0183040eb3bd2af718516c6ed95e379e3478 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:31:49 +0000 Subject: [PATCH 08/43] ci: lint and type-check the linux app ruff and mypy on src/Linux, plus a guard that the shared catalogue and the Python registry list the same tools -- a mismatch would show a menu entry that cannot run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .github/workflows/ci-cd.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index a422755..f47fd6e 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -21,6 +21,7 @@ concurrency: # # dotnet-build → dotnet build (GUI/ WinUI 3 app) # dotnet-format → dotnet format --verify-no-changes (code style) +# python-lint → ruff + mypy (Linux app, src/Linux) # pr-title → conventional commits (PR title format) # commit-lint → conventional commits (commit message format) # markdown-lint → markdownlint (README, SECURITY, .github docs) @@ -83,6 +84,40 @@ jobs: - name: Check formatting run: dotnet format "src/Windows/GUI/pcHealth/pcHealth.csproj" --verify-no-changes --verbosity diagnostic + # ---------------------------------------------------------- + # PYTHON LINT + # ruff (lint + format) and mypy for the Linux app. + # Runs on Linux because that is the only platform it targets. + # The runner image already ships Python, so no setup step is needed. + # ---------------------------------------------------------- + python-lint: + name: Python lint (Linux app) + runs-on: ubuntu-latest + permissions: + contents: read + defaults: + run: + working-directory: src/Linux + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install tooling + run: python3 -m pip install --disable-pip-version-check ruff mypy + + - name: Lint + run: python3 -m ruff check . + + - name: Check formatting + run: python3 -m ruff format --check . + + - name: Type check + run: python3 -m mypy pchealth + + # Guards the split: the shared catalogue and the Python registry must + # list the same tools, or the menu shows an entry that cannot run. + - name: Catalogue matches registry + run: python3 -c "from pchealth import catalog; from pchealth.tools import REGISTRY; missing = [t.id for t in catalog.load() if t.id not in REGISTRY]; assert not missing, f'no implementation for {missing}'; print(f'{len(REGISTRY)} tools wired')" + # ---------------------------------------------------------- # PR TITLE CHECK # Enforces conventional commit format in pull request titles. From b9b3cfc68c94cc545862bfc1cfa521bb644dfdef Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:38:38 +0000 Subject: [PATCH 09/43] refactor(cli): make the powershell cli windows-only Linux now has its own stack in src/Linux, so the PowerShell side no longer carries a second platform. tools/linux/ and every $IsLinux branch are gone, along with the helpers that only ever served them: Get-PcDesktopUser, Get-PcPackageManager, Get-LinuxDistroInfo, Test-PcImageBasedSystem and Get-PcCommandOutput. Nothing is lost: all 18 Linux tools were ported to Python first, with the same names and behaviour, and the originals stay in this repository's history. Also fixes the VERSION lookup in app.ps1, which still pointed two directories up after the move and so resolved to src/ instead of the repo root. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .github/labeler.yml | 1 - assets/tools.json | 69 ++- src/Windows/CLI/Start.ps1 | 160 +++---- src/Windows/CLI/app.ps1 | 89 ++-- src/Windows/CLI/menus/Helpers.ps1 | 179 +------ src/Windows/CLI/menus/Programs.ps1 | 101 +--- src/Windows/CLI/menus/Tools.ps1 | 71 ++- src/Windows/CLI/tools/Get-HardwareInfo.ps1 | 438 ++++++------------ src/Windows/CLI/tools/Get-SystemInfo.ps1 | 222 +++------ src/Windows/CLI/tools/Invoke-PowerOptions.ps1 | 70 +-- src/Windows/CLI/tools/Test-Traceroute.ps1 | 31 +- .../CLI/tools/linux/Get-BatteryReport.ps1 | 87 ---- .../CLI/tools/linux/Get-SystemLogs.ps1 | 56 --- .../CLI/tools/linux/Invoke-AudioRestart.ps1 | 63 --- .../CLI/tools/linux/Invoke-BootRepair.ps1 | 209 --------- .../CLI/tools/linux/Invoke-DiskCleanup.ps1 | 89 ---- .../CLI/tools/linux/Invoke-DiskOptimize.ps1 | 56 --- .../CLI/tools/linux/Invoke-FirmwareUpdate.ps1 | 78 ---- .../CLI/tools/linux/Invoke-NetworkReset.ps1 | 45 -- .../CLI/tools/linux/Invoke-ScanAndRepair.ps1 | 73 --- .../CLI/tools/linux/Invoke-SystemUpdate.ps1 | 71 --- .../CLI/tools/linux/Invoke-Topgrade.ps1 | 66 --- 22 files changed, 397 insertions(+), 1927 deletions(-) delete mode 100644 src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 delete mode 100644 src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 diff --git a/.github/labeler.yml b/.github/labeler.yml index 0936190..75b2561 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -80,7 +80,6 @@ "linux": - changed-files: - any-glob-to-any-file: - - "src/Windows/CLI/tools/linux/**" - "src/Linux/**" # ── Language labels ─────────────────────────────────────────────────────────── diff --git a/assets/tools.json b/assets/tools.json index 62b0259..c3d2f6c 100644 --- a/assets/tools.json +++ b/assets/tools.json @@ -9,44 +9,43 @@ "needsMutableOS -- hidden on image-based systems (Silverblue, Bazzite,", " Kinoite, MicroOS): /usr is read-only and the", " bootloader belongs to the deployment", - "powershellScript -- path under src/Windows/CLI/tools/ (the PowerShell", - " implementation; it still covers Linux too)", + "windowsScript -- path under src/Windows/CLI/tools/", "linuxTool -- tool id in src/Linux/pchealth/tools/" ], "tools": [ - { "id": "system-info", "name": "System Information", "category": "Information", "platforms": ["windows", "linux"], "powershellScript": "Get-SystemInfo.ps1", "linuxTool": "system-info" }, - { "id": "hardware-info", "name": "Hardware Information", "category": "Information", "platforms": ["windows", "linux"], "powershellScript": "Get-HardwareInfo.ps1", "linuxTool": "hardware-info" }, - { "id": "scan-repair-windows", "name": "Scan + Repair", "note": "SFC + DISM combined", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-ScanAndRepair.ps1" }, - { "id": "battery-report-windows", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Get-BatteryReport.ps1" }, - { "id": "windows-update", "name": "Windows Update", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-WindowsUpdate.ps1" }, - { "id": "disk-optimize-windows", "name": "Disk Optimization", "category": "Disk", "platforms": ["windows"], "powershellScript": "Invoke-DiskOptimize.ps1" }, - { "id": "disk-cleanup-windows", "name": "Disk Cleanup", "category": "Disk", "platforms": ["windows"], "powershellScript": "Invoke-DiskCleanup.ps1" }, - { "id": "ping-short", "name": "Short Ping Test", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-NetworkShort.ps1", "linuxTool": "ping-short" }, - { "id": "ping-continuous", "name": "Continuous Ping Test", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-NetworkContinuous.ps1", "linuxTool": "ping-continuous" }, - { "id": "traceroute", "name": "Traceroute to Google", "category": "Network", "platforms": ["windows", "linux"], "powershellScript": "Test-Traceroute.ps1", "linuxTool": "traceroute" }, - { "id": "network-reset-windows", "name": "Reset Network Stack", "category": "Network", "platforms": ["windows"], "powershellScript": "Invoke-NetworkReset.ps1" }, - { "id": "system-update-windows", "name": "Update all packages", "note": "winget", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-SystemUpdate.ps1" }, - { "id": "hp-update", "name": "Update HP Drivers", "note": "HP only", "category": "Updates", "platforms": ["windows"], "powershellScript": "Invoke-HPUpdate.ps1" }, - { "id": "audio-restart-windows", "name": "Restart Audio Drivers", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Invoke-AudioRestart.ps1" }, - { "id": "open-battery-report", "name": "Open Battery Report", "category": "Hardware", "platforms": ["windows"], "powershellScript": "Open-BatteryReport.ps1" }, - { "id": "open-cbs-log", "name": "Open CBS Log", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Open-CBSLog.ps1" }, - { "id": "ninite", "name": "Get Ninite", "note": "Edge, Chrome, VLC, 7-Zip", "category": "Updates", "platforms": ["windows"], "powershellScript": "Get-Ninite.ps1" }, - { "id": "license-key", "name": "Windows License Key", "category": "Information", "platforms": ["windows"], "powershellScript": "Get-LicenseKey.ps1" }, - { "id": "bios-password", "name": "BIOS Password Recovery", "category": "Security", "platforms": ["windows", "linux"], "powershellScript": "Open-BIOSPasswordTool.ps1", "linuxTool": "bios-password" }, - { "id": "boot-repair-windows", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-BootRepair.ps1" }, - { "id": "power-options", "name": "Shutdown / Reboot / Log Off", "category": "System", "platforms": ["windows", "linux"], "powershellScript": "Invoke-PowerOptions.ps1", "linuxTool": "power-options" }, - { "id": "winget-repair", "name": "Repair Winget", "category": "Maintenance", "platforms": ["windows"], "powershellScript": "Invoke-WingetRepair.ps1" }, + { "id": "system-info", "name": "System Information", "category": "Information", "platforms": ["windows", "linux"], "windowsScript": "Get-SystemInfo.ps1", "linuxTool": "system-info" }, + { "id": "hardware-info", "name": "Hardware Information", "category": "Information", "platforms": ["windows", "linux"], "windowsScript": "Get-HardwareInfo.ps1", "linuxTool": "hardware-info" }, + { "id": "scan-repair-windows", "name": "Scan + Repair", "note": "SFC + DISM combined", "category": "Maintenance", "platforms": ["windows"], "windowsScript": "Invoke-ScanAndRepair.ps1" }, + { "id": "battery-report-windows", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["windows"], "windowsScript": "Get-BatteryReport.ps1" }, + { "id": "windows-update", "name": "Windows Update", "category": "Updates", "platforms": ["windows"], "windowsScript": "Invoke-WindowsUpdate.ps1" }, + { "id": "disk-optimize-windows", "name": "Disk Optimization", "category": "Disk", "platforms": ["windows"], "windowsScript": "Invoke-DiskOptimize.ps1" }, + { "id": "disk-cleanup-windows", "name": "Disk Cleanup", "category": "Disk", "platforms": ["windows"], "windowsScript": "Invoke-DiskCleanup.ps1" }, + { "id": "ping-short", "name": "Short Ping Test", "category": "Network", "platforms": ["windows", "linux"], "windowsScript": "Test-NetworkShort.ps1", "linuxTool": "ping-short" }, + { "id": "ping-continuous", "name": "Continuous Ping Test", "category": "Network", "platforms": ["windows", "linux"], "windowsScript": "Test-NetworkContinuous.ps1", "linuxTool": "ping-continuous" }, + { "id": "traceroute", "name": "Traceroute to Google", "category": "Network", "platforms": ["windows", "linux"], "windowsScript": "Test-Traceroute.ps1", "linuxTool": "traceroute" }, + { "id": "network-reset-windows", "name": "Reset Network Stack", "category": "Network", "platforms": ["windows"], "windowsScript": "Invoke-NetworkReset.ps1" }, + { "id": "system-update-windows", "name": "Update all packages", "note": "winget", "category": "Updates", "platforms": ["windows"], "windowsScript": "Invoke-SystemUpdate.ps1" }, + { "id": "hp-update", "name": "Update HP Drivers", "note": "HP only", "category": "Updates", "platforms": ["windows"], "windowsScript": "Invoke-HPUpdate.ps1" }, + { "id": "audio-restart-windows", "name": "Restart Audio Drivers", "category": "Hardware", "platforms": ["windows"], "windowsScript": "Invoke-AudioRestart.ps1" }, + { "id": "open-battery-report", "name": "Open Battery Report", "category": "Hardware", "platforms": ["windows"], "windowsScript": "Open-BatteryReport.ps1" }, + { "id": "open-cbs-log", "name": "Open CBS Log", "category": "Maintenance", "platforms": ["windows"], "windowsScript": "Open-CBSLog.ps1" }, + { "id": "ninite", "name": "Get Ninite", "note": "Edge, Chrome, VLC, 7-Zip", "category": "Updates", "platforms": ["windows"], "windowsScript": "Get-Ninite.ps1" }, + { "id": "license-key", "name": "Windows License Key", "category": "Information", "platforms": ["windows"], "windowsScript": "Get-LicenseKey.ps1" }, + { "id": "bios-password", "name": "BIOS Password Recovery", "category": "Security", "platforms": ["windows", "linux"], "windowsScript": "Open-BIOSPasswordTool.ps1", "linuxTool": "bios-password" }, + { "id": "boot-repair-windows", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["windows"], "windowsScript": "Invoke-BootRepair.ps1" }, + { "id": "power-options", "name": "Shutdown / Reboot / Log Off", "category": "System", "platforms": ["windows", "linux"], "windowsScript": "Invoke-PowerOptions.ps1", "linuxTool": "power-options" }, + { "id": "winget-repair", "name": "Repair Winget", "category": "Maintenance", "platforms": ["windows"], "windowsScript": "Invoke-WingetRepair.ps1" }, - { "id": "system-update", "name": "Update all packages", "note": "apt / dnf / pacman / zypper", "category": "Updates", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-SystemUpdate.ps1", "linuxTool": "system-update" }, - { "id": "topgrade", "name": "Topgrade", "note": "full system upgrade", "category": "Updates", "platforms": ["linux"], "powershellScript": "linux/Invoke-Topgrade.ps1", "linuxTool": "topgrade" }, - { "id": "battery-report", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["linux"], "powershellScript": "linux/Get-BatteryReport.ps1", "linuxTool": "battery-report" }, - { "id": "scan-repair", "name": "Scan + Repair", "note": "package integrity", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-ScanAndRepair.ps1", "linuxTool": "scan-repair" }, - { "id": "disk-optimize", "name": "Disk Optimization", "note": "SSD trim", "category": "Disk", "platforms": ["linux"], "powershellScript": "linux/Invoke-DiskOptimize.ps1", "linuxTool": "disk-optimize" }, - { "id": "firmware-update", "name": "Firmware Update", "note": "fwupd / LVFS", "category": "Updates", "platforms": ["linux"], "powershellScript": "linux/Invoke-FirmwareUpdate.ps1", "linuxTool": "firmware-update" }, - { "id": "boot-repair", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-BootRepair.ps1", "linuxTool": "boot-repair" }, - { "id": "disk-cleanup", "name": "Disk Cleanup", "note": "cache, journal, flatpak", "category": "Disk", "platforms": ["linux"], "needsMutableOS": true, "powershellScript": "linux/Invoke-DiskCleanup.ps1", "linuxTool": "disk-cleanup" }, - { "id": "audio-restart", "name": "Restart Audio", "note": "PipeWire / PulseAudio", "category": "Hardware", "platforms": ["linux"], "powershellScript": "linux/Invoke-AudioRestart.ps1", "linuxTool": "audio-restart" }, - { "id": "network-reset", "name": "Reset Network Stack", "category": "Network", "platforms": ["linux"], "powershellScript": "linux/Invoke-NetworkReset.ps1", "linuxTool": "network-reset" }, - { "id": "system-logs", "name": "View System Logs", "note": "journalctl", "category": "Information", "platforms": ["linux"], "powershellScript": "linux/Get-SystemLogs.ps1", "linuxTool": "system-logs" } + { "id": "system-update", "name": "Update all packages", "note": "apt / dnf / pacman / zypper", "category": "Updates", "platforms": ["linux"], "needsMutableOS": true, "linuxTool": "system-update" }, + { "id": "topgrade", "name": "Topgrade", "note": "full system upgrade", "category": "Updates", "platforms": ["linux"], "linuxTool": "topgrade" }, + { "id": "battery-report", "name": "Battery Report", "note": "laptop only", "category": "Hardware", "platforms": ["linux"], "linuxTool": "battery-report" }, + { "id": "scan-repair", "name": "Scan + Repair", "note": "package integrity", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "linuxTool": "scan-repair" }, + { "id": "disk-optimize", "name": "Disk Optimization", "note": "SSD trim", "category": "Disk", "platforms": ["linux"], "linuxTool": "disk-optimize" }, + { "id": "firmware-update", "name": "Firmware Update", "note": "fwupd / LVFS", "category": "Updates", "platforms": ["linux"], "linuxTool": "firmware-update" }, + { "id": "boot-repair", "name": "Boot Repair", "note": "UEFI - caution!", "category": "Maintenance", "platforms": ["linux"], "needsMutableOS": true, "linuxTool": "boot-repair" }, + { "id": "disk-cleanup", "name": "Disk Cleanup", "note": "cache, journal, flatpak", "category": "Disk", "platforms": ["linux"], "needsMutableOS": true, "linuxTool": "disk-cleanup" }, + { "id": "audio-restart", "name": "Restart Audio", "note": "PipeWire / PulseAudio", "category": "Hardware", "platforms": ["linux"], "linuxTool": "audio-restart" }, + { "id": "network-reset", "name": "Reset Network Stack", "category": "Network", "platforms": ["linux"], "linuxTool": "network-reset" }, + { "id": "system-logs", "name": "View System Logs", "note": "journalctl", "category": "Information", "platforms": ["linux"], "linuxTool": "system-logs" } ] } diff --git a/src/Windows/CLI/Start.ps1 b/src/Windows/CLI/Start.ps1 index dbb6b06..9565087 100644 --- a/src/Windows/CLI/Start.ps1 +++ b/src/Windows/CLI/Start.ps1 @@ -1,95 +1,72 @@ #Requires -Version 5.1 # ============================================================================ -# pcHealth -- CLI Launcher -# PS5.1-compatible bootstrap: enforces PS7, admin/root, and optional deps. -# On Windows: runs under PS5 → installs PS7 if needed → relaunches in PS7. -# On Linux: pwsh (PS7) is assumed pre-installed; checks root + kernel. +# pcHealth -- Windows CLI Launcher +# PS5.1-compatible bootstrap: enforces PS7, admin rights and optional deps. +# Runs under PS5 → installs PS7 if needed → relaunches in PS7. +# On Linux, use src/Linux instead: python3 -m pchealth # ============================================================================ $ErrorActionPreference = 'Stop' -$onLinux = ($PSVersionTable.PSEdition -eq 'Core') -and [bool]$IsLinux $isPwsh7 = $PSVersionTable.PSVersion.Major -ge 7 -# -- Linux: kernel version check + root guard --------------------------------- -if ($onLinux) { - # Start.ps1 runs before Helpers.ps1 is loaded, so guard the null here: - # calling .Trim() on a missing command's output throws before the check below. - $kernelStr = "$(& uname -r 2>$null)".Trim() - if (-not $kernelStr) { - Write-Host "[!!] Could not determine kernel version (uname -r returned nothing)." -ForegroundColor Red - Read-Host 'Press Enter to exit' - exit 1 - } - $kernelMajor = [int]($kernelStr -split '[.-]')[0] - if ($kernelMajor -lt 6) { - Write-Host "[!!] pcHealth cannot run on kernel $kernelStr." -ForegroundColor Red - Write-Host " Minimum required: kernel 6.0." -ForegroundColor Red - Write-Host " https://www.kernel.org/" -ForegroundColor DarkGray - Read-Host 'Press Enter to exit' - exit 1 - } +# $IsLinux / $IsMacOS are PS6+ variables; on PS 5.1 they are $null (falsy). +if ($IsLinux -or $IsMacOS) { + Write-Host '[!!] This is the Windows CLI.' -ForegroundColor Red + Write-Host ' On Linux, use src/Linux instead: python3 -m pchealth' -ForegroundColor Yellow + exit 1 +} - $isRoot = ("$(& id -u 2>$null)".Trim() -eq '0') - if (-not $isRoot) { - Write-Host '[!!] pcHealth must be run as root on Linux.' -ForegroundColor Red - Write-Host ' Run: sudo pwsh src/Windows/CLI/Start.ps1' -ForegroundColor Yellow - exit 1 - } +# -- Build check, elevate, relaunch in PS7 ------------------------------------ +# Windows support floors -- see README.md and SECURITY.md. +# >= 26200 recommended : the build every release is tested on +# >= 19045 supported : Windows 10 22H2 and Windows 11 +# < 19045 blocked : WinUI 3 does not render below 22H2, so the GUI +# cannot follow the CLI down and the two floors +# are kept identical rather than drifting apart +$recommendedBuild = 26200 # Windows 11 25H2 +$hardMinimumBuild = 19045 # Windows 10 22H2 +$build = [System.Environment]::OSVersion.Version.Build + +if ($build -lt $hardMinimumBuild) { + Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red + Write-Host " Minimum required: build $hardMinimumBuild (Windows 10 version 22H2)." -ForegroundColor Red + Write-Host " https://learn.microsoft.com/en-us/windows/release-health/release-information" -ForegroundColor DarkGray + Read-Host 'Press Enter to exit' + exit 1 +} elseif ($build -lt $recommendedBuild) { + Write-Host '' + Write-Host "[!] Windows build $build is supported; $recommendedBuild (11 25H2) is recommended." -ForegroundColor Yellow + Write-Host " https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information" -ForegroundColor DarkGray } -# -- Windows: build check, elevate, relaunch in PS7 --------------------------- -if (-not $onLinux) { - # Windows support floors -- see README.md and SECURITY.md. - # >= 26200 recommended : the build every release is tested on - # >= 19045 supported : Windows 10 22H2 and Windows 11 - # < 19045 blocked : WinUI 3 does not render below 22H2, so the GUI - # cannot follow the CLI down and the two floors - # are kept identical rather than drifting apart - $recommendedBuild = 26200 # Windows 11 25H2 - $hardMinimumBuild = 19045 # Windows 10 22H2 - $build = [System.Environment]::OSVersion.Version.Build - - if ($build -lt $hardMinimumBuild) { - Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build $hardMinimumBuild (Windows 10 version 22H2)." -ForegroundColor Red - Write-Host " https://learn.microsoft.com/en-us/windows/release-health/release-information" -ForegroundColor DarkGray - Read-Host 'Press Enter to exit' - exit 1 - } elseif ($build -lt $recommendedBuild) { - Write-Host '' - Write-Host "[!] Windows build $build is supported; $recommendedBuild (11 25H2) is recommended." -ForegroundColor Yellow - Write-Host " https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information" -ForegroundColor DarkGray - } +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator +) +if (-not $isAdmin) { + $shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { 'pwsh' } else { 'powershell' } + $shellCmd = Get-Command $shell -ErrorAction SilentlyContinue + if (-not $shellCmd) { Write-Host "[!!] Shell '$shell' not found." -ForegroundColor Red; exit 1 } + Start-Process -FilePath $shellCmd.Source ` + -ArgumentList "-ExecutionPolicy Bypass -NoProfile -File `"$PSCommandPath`"" ` + -Verb RunAs + exit +} - $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( - [Security.Principal.WindowsBuiltInRole]::Administrator - ) - if (-not $isAdmin) { - $shell = if (Get-Command pwsh -ErrorAction SilentlyContinue) { 'pwsh' } else { 'powershell' } - $shellCmd = Get-Command $shell -ErrorAction SilentlyContinue - if (-not $shellCmd) { Write-Host "[!!] Shell '$shell' not found." -ForegroundColor Red; exit 1 } - Start-Process -FilePath $shellCmd.Source ` +# Relaunch in PS7 if elevation landed in PS5 (pattern from WinDeploy) +if (-not $isPwsh7) { + $pwshExe = "$env:ProgramFiles\PowerShell\7\pwsh.exe" + if (-not (Test-Path $pwshExe)) { + $pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue + $pwshExe = if ($pwshCmd) { $pwshCmd.Source } else { $null } + } + if ($pwshExe) { + Write-Host '[pcHealth] Relaunching in PowerShell 7...' -ForegroundColor Yellow + Start-Process -FilePath $pwshExe ` -ArgumentList "-ExecutionPolicy Bypass -NoProfile -File `"$PSCommandPath`"" ` - -Verb RunAs + -Wait -NoNewWindow exit } - - # Relaunch in PS7 if elevation landed in PS5 (pattern from WinDeploy) - if (-not $isPwsh7) { - $pwshExe = "$env:ProgramFiles\PowerShell\7\pwsh.exe" - if (-not (Test-Path $pwshExe)) { - $pwshCmd = Get-Command pwsh -ErrorAction SilentlyContinue - $pwshExe = if ($pwshCmd) { $pwshCmd.Source } else { $null } - } - if ($pwshExe) { - Write-Host '[pcHealth] Relaunching in PowerShell 7...' -ForegroundColor Yellow - Start-Process -FilePath $pwshExe ` - -ArgumentList "-ExecutionPolicy Bypass -NoProfile -File `"$PSCommandPath`"" ` - -Wait -NoNewWindow - exit - } - # Fall through — pwsh not found yet; installer below will handle it. - } + # Fall through — pwsh not found yet; installer below will handle it. } # -- Dependency check ---------------------------------------------------------- @@ -108,21 +85,16 @@ function Write-DepStatus($label, $ok, [bool]$Optional = $false) { } } -# On Linux, pwsh is already running — trivially satisfied. -$pwshOk = $onLinux -or [bool](Get-Command pwsh -ErrorAction SilentlyContinue) +$pwshOk = [bool](Get-Command pwsh -ErrorAction SilentlyContinue) -$smartctlOk = if ($onLinux) { - [bool](Get-Command smartctl -ErrorAction SilentlyContinue) -} else { - (Test-Path (Join-Path $env:ProgramFiles 'smartmontools\bin\smartctl.exe')) -or - [bool](Get-Command smartctl -ErrorAction SilentlyContinue) -} +$smartctlOk = (Test-Path (Join-Path $env:ProgramFiles 'smartmontools\bin\smartctl.exe')) -or + [bool](Get-Command smartctl -ErrorAction SilentlyContinue) -if (-not $onLinux) { Write-DepStatus 'PowerShell 7' $pwshOk } +Write-DepStatus 'PowerShell 7' $pwshOk Write-DepStatus -label 'smartmontools' -ok $smartctlOk -Optional $true -# -- Install PowerShell 7 (Windows only) -------------------------------------- -if (-not $onLinux -and -not $pwshOk) { +# -- Install PowerShell 7 ----------------------------------------------------- +if (-not $pwshOk) { Write-Host '' Write-Host '[pcHealth] PowerShell 7 is required to run this application.' -ForegroundColor Yellow @@ -164,15 +136,9 @@ if (-not $smartctlOk) { Write-Host '[pcHealth] smartmontools is recommended for full SMART disk health data.' -ForegroundColor Yellow Write-Host ' Without it, life %, temperature and power-on hours are unavailable.' -ForegroundColor DarkGray - $prompt = if ($onLinux) { ' Install now? [Y/N]' } else { ' Install now via winget? [Y/N]' } - $answer = Read-Host $prompt + $answer = Read-Host ' Install now via winget? [Y/N]' if ($answer -match '^[Yy]') { - if ($onLinux) { - if (Get-Command apt-get -ErrorAction SilentlyContinue) { apt-get install -y smartmontools } - elseif (Get-Command dnf -ErrorAction SilentlyContinue) { dnf install -y smartmontools } - elseif (Get-Command pacman -ErrorAction SilentlyContinue) { pacman -S --noconfirm smartmontools } - else { Write-Host '[!!] No supported package manager found. Install smartmontools manually.' -ForegroundColor Yellow } - } elseif (Get-Command winget -ErrorAction SilentlyContinue) { + if (Get-Command winget -ErrorAction SilentlyContinue) { winget install --source winget --id smartmontools.smartmontools -e --silent ` --accept-package-agreements --accept-source-agreements $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + diff --git a/src/Windows/CLI/app.ps1 b/src/Windows/CLI/app.ps1 index b67f48b..9f51532 100644 --- a/src/Windows/CLI/app.ps1 +++ b/src/Windows/CLI/app.ps1 @@ -1,71 +1,51 @@ #Requires -Version 7.0 # ============================================================================ -# pcHealth -- CLI -# Auto-detects platform (Windows/Linux) and loads menus. +# pcHealth -- Windows CLI +# Checks the Windows build and loads the menus. # ============================================================================ $ErrorActionPreference = 'Stop' -# -- Platform detection + version guards --------------------------------------- -if ($IsLinux) { - # Also checked in Start.ps1; repeated here as safety net for direct invocation. - $kernelVersion = (uname -r) - $kernelMajor = [int]($kernelVersion -split '[\.\-]')[0] - if ($kernelMajor -lt 6) { - Write-Host "[!!] pcHealth cannot run on kernel $kernelVersion." -ForegroundColor Red - Write-Host " Minimum required: kernel 6.0." -ForegroundColor Red - exit 1 - } - # Also checked in Start.ps1; repeated here so tools can rely on being root - # and call the package manager and systemctl directly, without sudo. - $uid = "$(& id -u 2>$null)".Trim() - if ($uid -ne '0') { - Write-Host '[!!] pcHealth must be run as root on Linux.' -ForegroundColor Red - Write-Host ' Run: sudo pwsh src/Windows/CLI/Start.ps1' -ForegroundColor Yellow - exit 1 - } - $Global:PcPlatform = 'Linux' - $Global:PcPlatformLabel = 'Linux' -} elseif ($IsWindows) { - # Also checked in Start.ps1 before elevation; repeated here as safety net. - # Only the hard floor is enforced here -- the "recommended build" note lives - # in Start.ps1 so a normal launch does not print it twice. - $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 19045) { - Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red - Write-Host " Minimum required: build 19045 (Windows 10 version 22H2)." -ForegroundColor Red - Write-Host " Please upgrade your system." -ForegroundColor Yellow - exit 1 - } - $Global:PcPlatform = 'Windows' - $Global:PcPlatformLabel = 'Windows' -} else { - Write-Host "[!!] Unsupported platform. pcHealth supports Windows and Linux only." -ForegroundColor Red +# -- Platform guard + version check -------------------------------------------- +if (-not $IsWindows) { + Write-Host '[!!] This is the Windows CLI. On Linux, use src/Linux instead:' -ForegroundColor Red + Write-Host ' python3 -m pchealth' -ForegroundColor Yellow exit 1 } -# Console resize -- Windows only. Terminal width/height on Linux is managed by -# the shell and cannot be set programmatically via RawUI on most hosts. -if (-not $IsLinux) { - try { - $ui = $Host.UI.RawUI - $buf = $ui.BufferSize - $buf.Width = 220 - $ui.BufferSize = $buf - $win = $ui.WindowSize - $win.Width = [Math]::Min(220, $ui.MaxPhysicalWindowSize.Width) - $win.Height = [Math]::Min(50, $ui.MaxPhysicalWindowSize.Height) - $ui.WindowSize = $win - } catch { - Write-Verbose "Console resize skipped on non-interactive host: $_" - } +# Also checked in Start.ps1 before elevation; repeated here as safety net. +# Only the hard floor is enforced here -- the "recommended build" note lives +# in Start.ps1 so a normal launch does not print it twice. +$build = [System.Environment]::OSVersion.Version.Build +if ($build -lt 19045) { + Write-Host "[!!] pcHealth cannot run on Windows build $build." -ForegroundColor Red + Write-Host " Minimum required: build 19045 (Windows 10 version 22H2)." -ForegroundColor Red + Write-Host " Please upgrade your system." -ForegroundColor Yellow + exit 1 +} + +$Global:PcPlatform = 'Windows' +$Global:PcPlatformLabel = 'Windows' + +try { + $ui = $Host.UI.RawUI + $buf = $ui.BufferSize + $buf.Width = 220 + $ui.BufferSize = $buf + $win = $ui.WindowSize + $win.Width = [Math]::Min(220, $ui.MaxPhysicalWindowSize.Width) + $win.Height = [Math]::Min(50, $ui.MaxPhysicalWindowSize.Height) + $ui.WindowSize = $win +} catch { + Write-Verbose "Console resize skipped on non-interactive host: $_" } # $Global:pcHealthRoot is used by menus to resolve the tools/ path. # Set before dot-sourcing so menus can reference it at load time. $Global:pcHealthRoot = $PSScriptRoot -$versionFile = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath '..', 'VERSION' +# src/Windows/CLI -> src/Windows -> src -> repo root +$versionFile = Join-Path -Path $PSScriptRoot -ChildPath '..' -AdditionalChildPath '..', '..', 'VERSION' $Global:PcVersion = if (Test-Path $versionFile) { (Get-Content $versionFile -Raw).Trim() } else { 'unknown' } @@ -73,9 +53,6 @@ $Global:PcVersion = if (Test-Path $versionFile) { # Order matters: Helpers must load before Main/Tools/Programs. . (Join-Path -Path $PSScriptRoot -ChildPath 'menus' -AdditionalChildPath 'Helpers.ps1') -# Resolved once here: the Tools menu hides package- and boot-related tools on -# image-based systems, where they cannot work. -$Global:PcImageBased = Test-PcImageBasedSystem . (Join-Path -Path $PSScriptRoot -ChildPath 'menus' -AdditionalChildPath 'Main.ps1') . (Join-Path -Path $PSScriptRoot -ChildPath 'menus' -AdditionalChildPath 'Tools.ps1') . (Join-Path -Path $PSScriptRoot -ChildPath 'menus' -AdditionalChildPath 'Programs.ps1') diff --git a/src/Windows/CLI/menus/Helpers.ps1 b/src/Windows/CLI/menus/Helpers.ps1 index a21da8f..ce4d21c 100644 --- a/src/Windows/CLI/menus/Helpers.ps1 +++ b/src/Windows/CLI/menus/Helpers.ps1 @@ -1,166 +1,27 @@ # ============================================================================ -# pcHealth -- Shared -- UI Helpers +# pcHealth -- Windows -- UI Helpers # Display and navigation utilities used by all menu scripts. # ============================================================================ -# Runs a native command and returns its trimmed output, or $null when the -# command is missing, fails, or prints nothing. -# The bare `(& cmd ...).Trim()` idiom throws on $null and aborts the whole tool. -# On Linux that is the common case, not the edge case: containers and WSL have -# no systemd (timedatectl, systemctl), and mokutil, lspci or uptime may not be -# installed at all. -function Get-PcCommandOutput { - param( - [Parameter(Mandatory, Position = 0)][string]$Command, - [Parameter(Position = 1)][string[]]$Arguments = @() - ) - if (-not (Get-Command $Command -CommandType Application -ErrorAction SilentlyContinue)) { return $null } - $out = try { & $Command @Arguments 2>$null } catch { $null } - if (-not $out) { return $null } - $text = ($out -join "`n").Trim() - if ($text) { return $text } else { return $null } -} - -# Resolves the human user behind the session. Under `sudo pwsh` the process -# environment describes root, so tools that touch the desktop session -- audio, -# topgrade, the thumbnail cache, log off -- must not use $env:USER or $env:HOME. -# Returns $null on Windows and when no user can be determined. -function Get-PcDesktopUser { - if (-not $IsLinux) { return $null } - - $name = $env:SUDO_USER - if (-not $name) { $name = $env:USER } - # sudo -i clears both; ask the kernel who owns the login session instead. - if (-not $name) { $name = Get-PcCommandOutput 'id' @('-un') } - if (-not $name) { return $null } - - $uid = Get-PcCommandOutput 'id' @('-u', $name) - # Field 6 of the passwd entry is the home directory. - $passwd = Get-PcCommandOutput 'getent' @('passwd', $name) - $homeDir = if ($passwd) { ($passwd -split ':')[5] } else { $null } - if (-not $homeDir) { $homeDir = if ($name -eq 'root') { '/root' } else { "/home/$name" } } - - # Session bus of the user's login session; needed by `systemctl --user`. - # The inherited address is passed on to `env` as key=value, so reject anything - # that is not a D-Bus transport and derive the standard path instead. - $dbus = $env:DBUS_SESSION_BUS_ADDRESS - if ($dbus -notmatch '^(unix|tcp|nonce-tcp|autolaunch):') { $dbus = "unix:path=/run/user/$uid/bus" } - - [PSCustomObject]@{ - Name = $name - Uid = $uid - Home = $homeDir - Dbus = $dbus - } -} - -# True on image-based systems: Fedora Silverblue/Bazzite/Kinoite, openSUSE -# MicroOS and friends. /usr is read-only and the bootloader belongs to the -# deployment, so tools that manage packages or boot files are hidden there -# rather than taught a second dialect that would need chasing as bootc evolves. -function Test-PcImageBasedSystem { - if (-not $IsLinux) { return $false } - return (Test-Path '/run/ostree-booted') -or (Test-Path '/ostree') -} - -# Detects the distro's package manager and the verbs pcHealth needs from it. -# Returns $null when the distro has no manager pcHealth knows. -# Refresh is $null where the update verb already syncs the package index. -# Verify names its own command: rpm and debsums do the checking, not the manager. -# -# Chosen by distro family, NOT by which binary happens to be on PATH. A -# Distrobox export or Homebrew readily puts apt and pacman on a Fedora box, and -# picking the first one found would run Debian commands against an rpm system. -function Get-PcPackageManager { - $definitions = @{ - 'apt' = @{ Refresh = @('update'); List = @('list', '--upgradable'); Update = @('upgrade', '-y'); Install = @('install', '-y'); Verify = @('debsums', '-s') } - 'dnf' = @{ Refresh = $null; List = @('check-update'); Update = @('upgrade', '-y'); Install = @('install', '-y'); Verify = @('rpm', '-Va') } - 'pacman' = @{ Refresh = @('-Sy'); List = @('-Qu'); Update = @('-Syu', '--noconfirm'); Install = @('-S', '--noconfirm'); Verify = @('pacman', '-Qkk') } - 'zypper' = @{ Refresh = @('refresh'); List = @('list-updates'); Update = @('update', '-y'); Install = @('install', '-y'); Verify = @('rpm', '-Va') } - } - - $info = Get-LinuxDistroInfo - $family = "$($info['ID']) $($info['ID_LIKE'])" - - $name = switch -Regex ($family) { - 'debian|ubuntu|mint|pop|elementary|zorin|kali' { 'apt'; break } - 'fedora|rhel|centos|almalinux|rocky' { 'dnf'; break } - 'arch|cachyos|manjaro|endeavouros|artix' { 'pacman'; break } - 'suse|sles' { 'zypper'; break } - default { $null } - } - - # Unrecognised distro: fall back to whatever is actually installed. - if (-not $name) { - $name = $definitions.Keys | Sort-Object | - Where-Object { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue } | - Select-Object -First 1 - } - if (-not $name -or -not (Get-Command $name -CommandType Application -ErrorAction SilentlyContinue)) { - return $null - } - return [PSCustomObject]($definitions[$name] + @{ Cmd = $name }) -} - -# Windows counterpart of Get-PcPackageManager: reports whether winget is usable. -# Every supported build ships winget, but LTSC images, stripped deployment -# images and machines where App Installer was removed do not have it. A missing -# native command throws under $ErrorActionPreference = 'Stop', which would take -# the whole menu down instead of just the tool the user picked. -function Test-PcWinget { - if (Get-Command winget -CommandType Application -ErrorAction SilentlyContinue) { return $true } - - Write-Host "`n[!!] winget is not available on this system." -ForegroundColor Red - Write-Host " LTSC and stripped-down images ship without App Installer." -ForegroundColor Yellow - Write-Host " Try 'Repair Winget' in the Tools menu.`n" -ForegroundColor DarkGray - return $false -} - -# Opens a URL in the user's browser. -# Start-Process cannot launch a URL on Linux -- it tries to exec it as a file -- -# so hand the address to xdg-open, and drop privileges so the browser lands in -# the desktop session rather than root's. +# Opens a URL in the user's default browser. function Open-PcUrl { param([Parameter(Mandatory)][string]$Url) try { - if (-not $IsLinux) { - Start-Process $Url -ErrorAction Stop - return - } - $opener = @('xdg-open', 'gio', 'sensible-browser') | - Where-Object { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue } | - Select-Object -First 1 - if (-not $opener) { - Write-Host "`n Open this address manually: $Url" -ForegroundColor Yellow - return - } - $openArgs = if ($opener -eq 'gio') { @('open', $Url) } else { @($Url) } - - $user = Get-PcDesktopUser - if ($user -and $user.Name -ne 'root') { - & sudo -u $user.Name env "DBUS_SESSION_BUS_ADDRESS=$($user.Dbus)" $opener @openArgs 2>&1 | Out-Null - } else { - & $opener @openArgs 2>&1 | Out-Null - } + Start-Process $Url -ErrorAction Stop } catch { Write-Host "`n [!!] Could not open browser: $_" -ForegroundColor Red Write-Host " Open this address manually: $Url" -ForegroundColor Yellow } } -# Write to both the console and a persistent log file under C:\pcHealth\Logs\ (Windows) -# or ~/pcHealth/Logs/ (Linux). +# Write to both the console and a persistent log file under C:\pcHealth\Logs\. function Write-PcLog { param( [string]$Message, [switch]$IsError ) try { - $logDir = if ($IsLinux) { - Join-Path -Path $env:HOME -ChildPath 'pcHealth' -AdditionalChildPath 'Logs' - } else { - Join-Path -Path $env:SystemDrive -ChildPath 'pcHealth' -AdditionalChildPath 'Logs' - } + $logDir = Join-Path -Path $env:SystemDrive -ChildPath 'pcHealth' -AdditionalChildPath 'Logs' if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } $callerScript = (Get-PSCallStack | Where-Object { $_.ScriptName } | Select-Object -Last 1).ScriptName @@ -180,28 +41,10 @@ function Write-PcLog { } } -# Parses /etc/os-release and returns a hashtable. -# ID and ID_LIKE are lowercased; NAME and PRETTY_NAME keep original casing. -function Get-LinuxDistroInfo { - $info = @{} - if (Test-Path '/etc/os-release') { - Get-Content '/etc/os-release' | ForEach-Object { - if ($_ -match '^(\w+)=(.*)$') { - $info[$Matches[1]] = $Matches[2].Trim('"').Trim("'") - } - } - } - $info['ID'] = $info['ID']?.ToLower() ?? '' - $info['ID_LIKE'] = $info['ID_LIKE']?.ToLower() ?? '' - $info['NAME'] = $info['NAME'] ?? 'Linux' - $info['PRETTY_NAME'] = $info['PRETTY_NAME'] ?? $info['NAME'] - return $info -} - function Clear-PcHost { # [Console]::Clear() fills the entire buffer with spaces and resets the - # cursor — more reliable than Clear-Host's ANSI escape sequences on Linux, - # and avoids partial-render artifacts when colour state leaks from tools. + # cursor, which avoids partial-render artifacts when colour state leaks + # out of a tool. [Console]::ResetColor() [Console]::Clear() } @@ -237,13 +80,9 @@ function Write-PcHeader { Write-Host " pcHealth * $Global:PcPlatformLabel * $Title" -ForegroundColor $headerColor Write-Host $line -ForegroundColor $headerColor $fullName = try { - if (-not $IsLinux) { - (Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).FullName - } else { $null } + (Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).FullName } catch { $null } - if (-not $fullName) { - $fullName = if ($IsLinux) { (Get-PcDesktopUser)?.Name } else { $env:USERNAME } - } + if (-not $fullName) { $fullName = $env:USERNAME } if (-not $fullName) { $fullName = 'there' } $now = Get-Date -Format 'dddd, dd MMMM yyyy HH:mm' Write-Host " Hello, $fullName! * $now`n" -ForegroundColor DarkGray diff --git a/src/Windows/CLI/menus/Programs.ps1 b/src/Windows/CLI/menus/Programs.ps1 index af7f68b..151df6a 100644 --- a/src/Windows/CLI/menus/Programs.ps1 +++ b/src/Windows/CLI/menus/Programs.ps1 @@ -1,6 +1,6 @@ # ============================================================================ -# pcHealth -- Shared -- Programs Menu -# Windows: installs via winget. Linux: installs via the distro package manager. +# pcHealth -- Windows -- Programs Menu +# Installs the diagnostic programs a technician wants, via winget. # ============================================================================ # Translates a winget exit code into a human-readable message. @@ -59,14 +59,6 @@ function Get-WingetResult { return @{ Ok = $false; Message = "Unexpected exit code ($hex)."; SuggestRepair = $true } } -function Show-ProgramsMenu { - if ($Global:PcPlatform -eq 'Linux') { - Show-LinuxProgramsMenu - } else { - Show-WindowsProgramsMenu - } -} - function Get-InstalledApp { $regPaths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', @@ -91,7 +83,7 @@ function Resolve-AppExePath { return $null } -function Show-WindowsProgramsMenu { +function Show-ProgramsMenu { $packages = [ordered]@{ '1' = @{ Name = 'HWiNFO64'; Id = 'REALix.HWiNFO'; ExeName = 'HWiNFO64.exe'; RegistryName = 'HWiNFO' } '2' = @{ Name = 'HWMonitor'; Id = 'CPUID.HWMonitor'; ExeName = 'HWMonitor.exe'; RegistryName = 'HWMonitor' } @@ -232,90 +224,3 @@ function Show-WindowsProgramsMenu { } } } - -function Show-LinuxProgramsMenu { - $pm = Get-PcPackageManager - - # Bin is what the menu probes for the [installed] marker: one PATH lookup, - # instead of a different "is this package present" query per manager. - $packages = [ordered]@{ - '1' = @{ Name = 'htop'; Pkg = 'htop'; Bin = 'htop'; Note = '(process viewer)' } - '2' = @{ Name = 'iotop'; Pkg = 'iotop'; Bin = 'iotop'; Note = '(I/O monitor)' } - '3' = @{ Name = 'smartmontools'; Pkg = 'smartmontools'; Bin = 'smartctl'; Note = '(disk SMART data)' } - '4' = @{ Name = 'stress-ng'; Pkg = 'stress-ng'; Bin = 'stress-ng'; Note = '(stress test)' } - '5' = @{ Name = 'nmap'; Pkg = 'nmap'; Bin = 'nmap'; Note = '(network scanner)' } - } - - $navTools = $packages.Count + 1 - $navMain = $packages.Count + 2 - $navExit = $packages.Count + 3 - - while ($true) { - Set-PcTheme 'Programs' - Clear-PcHost - Write-PcHeader 'Programs' - - if ($Global:PcImageBased) { - Write-Host ' Image-based system: pcHealth does not install packages here.' -ForegroundColor DarkGray - Write-Host " Use Homebrew or a Distrobox container instead.`n" -ForegroundColor DarkGray - } elseif ($pm) { - Write-Host " Package manager: $($pm.Cmd)`n" -ForegroundColor DarkGray - } else { - Write-Host " [!] No supported package manager found (apt/dnf/pacman/zypper).`n" -ForegroundColor Yellow - } - - foreach ($key in $packages.Keys) { - $p = $packages[$key] - $note = if (Get-Command $p.Bin -CommandType Application -ErrorAction SilentlyContinue) { - "$($p.Note) [installed]" - } else { $p.Note } - Write-PcOption $key $p.Name $note - } - - Write-PcDivider - Write-PcOption "$navTools" 'Tools Menu' - Write-PcOption "$navMain" 'Back to Main Menu' - Write-PcOption "$navExit" 'Exit' - Write-PcDivider - - $choice = (Read-Host "`n Choice").Trim() - - switch ($choice) { - "$navTools" { return 'tools' } - "$navMain" { return 'main' } - "$navExit" { return 'exit' } - } - - if (-not $packages.Contains($choice)) { - Write-Host "`n Invalid choice." -ForegroundColor Red - Start-Sleep -Milliseconds 800 - continue - } - - $pkg = $packages[$choice] - Set-PcTheme 'Action' - Clear-PcHost - - if (Get-Command $pkg.Bin -CommandType Application -ErrorAction SilentlyContinue) { - Write-Host "[OK] $($pkg.Name) is already installed." -ForegroundColor Green - Write-Host " Update it via Tools > Update all packages.`n" -ForegroundColor DarkGray - } elseif ($Global:PcImageBased -or -not $pm) { - Write-Host '[!!] pcHealth does not install packages on an image-based system.' -ForegroundColor Red - Write-Host " Install $($pkg.Name) with Homebrew or inside a Distrobox container.`n" -ForegroundColor Yellow - } else { - Write-Host "[>>] Installing $($pkg.Name) via $($pm.Cmd)...`n" -ForegroundColor Yellow - & $pm.Cmd @($pm.Install) $pkg.Pkg - if ($LASTEXITCODE -eq 0) { - Write-Host "`n[OK] $($pkg.Name) installed." -ForegroundColor Green - } else { - Write-Host "`n[!!] Installation returned exit code $LASTEXITCODE." -ForegroundColor Red - } - } - - $nav = Read-PcNavChoice 'Back to Programs Menu' - switch ($nav) { - '2' { return 'main' } - '3' { return 'exit' } - } - } -} diff --git a/src/Windows/CLI/menus/Tools.ps1 b/src/Windows/CLI/menus/Tools.ps1 index 46acd8e..a002712 100644 --- a/src/Windows/CLI/menus/Tools.ps1 +++ b/src/Windows/CLI/menus/Tools.ps1 @@ -1,54 +1,37 @@ # ============================================================================ -# pcHealth -- Shared -- Tools Menu -# Data-driven: options are filtered per platform at runtime so option numbers -# are always sequential with no gaps. +# pcHealth -- Windows -- Tools Menu +# Data-driven: the catalogue below mirrors assets/tools.json, which the Linux +# app reads as well. Keep the two in step when adding a tool. # ============================================================================ function Show-ToolsMenu { - # Each entry: Label, Script (relative to tools/), Note, Platforms. - # Platforms controls which OS sees the option. NeedsMutableOS hides an entry - # on image-based systems (Silverblue, Bazzite, MicroOS), where /usr is - # read-only and the bootloader belongs to the deployment. + # Each entry: Label, Script (relative to tools/), Note. $toolDefs = @( - @{ Label = 'System Information'; Script = 'Get-SystemInfo.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Hardware Information'; Script = 'Get-HardwareInfo.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Scan + Repair'; Script = 'Invoke-ScanAndRepair.ps1'; Note = '(SFC + DISM combined)'; Platforms = @('Windows') } - @{ Label = 'Battery Report'; Script = 'Get-BatteryReport.ps1'; Note = '(laptop only)'; Platforms = @('Windows') } - @{ Label = 'Windows Update'; Script = 'Invoke-WindowsUpdate.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Disk Optimization'; Script = 'Invoke-DiskOptimize.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Disk Cleanup'; Script = 'Invoke-DiskCleanup.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Short Ping Test'; Script = 'Test-NetworkShort.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Continuous Ping Test'; Script = 'Test-NetworkContinuous.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Traceroute to Google'; Script = 'Test-Traceroute.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Reset Network Stack'; Script = 'Invoke-NetworkReset.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Update all packages'; Script = 'Invoke-SystemUpdate.ps1'; Note = '(winget)'; Platforms = @('Windows') } - @{ Label = 'Update HP Drivers'; Script = 'Invoke-HPUpdate.ps1'; Note = '(HP only)'; Platforms = @('Windows') } - @{ Label = 'Restart Audio Drivers'; Script = 'Invoke-AudioRestart.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Open Battery Report'; Script = 'Open-BatteryReport.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Open CBS Log'; Script = 'Open-CBSLog.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Get Ninite'; Script = 'Get-Ninite.ps1'; Note = '(Edge, Chrome, VLC, 7-Zip)'; Platforms = @('Windows') } - @{ Label = 'Windows License Key'; Script = 'Get-LicenseKey.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'BIOS Password Recovery'; Script = 'Open-BIOSPasswordTool.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Boot Repair'; Script = 'Invoke-BootRepair.ps1'; Note = '(UEFI - caution!)'; Platforms = @('Windows') } - @{ Label = 'Shutdown / Reboot / Log Off'; Script = 'Invoke-PowerOptions.ps1'; Note = ''; Platforms = @('Windows','Linux') } - @{ Label = 'Repair Winget'; Script = 'Invoke-WingetRepair.ps1'; Note = ''; Platforms = @('Windows') } - @{ Label = 'Update all packages'; Script = 'linux/Invoke-SystemUpdate.ps1'; Note = '(apt / dnf / pacman / zypper)'; Platforms = @('Linux'); NeedsMutableOS = $true } - @{ Label = 'Topgrade'; Script = 'linux/Invoke-Topgrade.ps1'; Note = '(full system upgrade)'; Platforms = @('Linux') } - @{ Label = 'Battery Report'; Script = 'linux/Get-BatteryReport.ps1'; Note = '(laptop only)'; Platforms = @('Linux') } - @{ Label = 'Scan + Repair'; Script = 'linux/Invoke-ScanAndRepair.ps1'; Note = '(package integrity)'; Platforms = @('Linux'); NeedsMutableOS = $true } - @{ Label = 'Disk Optimization'; Script = 'linux/Invoke-DiskOptimize.ps1'; Note = '(SSD trim)'; Platforms = @('Linux') } - @{ Label = 'Firmware Update'; Script = 'linux/Invoke-FirmwareUpdate.ps1'; Note = '(fwupd / LVFS)'; Platforms = @('Linux') } - @{ Label = 'Boot Repair'; Script = 'linux/Invoke-BootRepair.ps1'; Note = '(UEFI - caution!)'; Platforms = @('Linux'); NeedsMutableOS = $true } - @{ Label = 'Disk Cleanup'; Script = 'linux/Invoke-DiskCleanup.ps1'; Note = '(cache, journal, flatpak)'; Platforms = @('Linux'); NeedsMutableOS = $true } - @{ Label = 'Restart Audio'; Script = 'linux/Invoke-AudioRestart.ps1'; Note = '(PipeWire / PulseAudio)'; Platforms = @('Linux') } - @{ Label = 'Reset Network Stack'; Script = 'linux/Invoke-NetworkReset.ps1'; Note = ''; Platforms = @('Linux') } - @{ Label = 'View System Logs'; Script = 'linux/Get-SystemLogs.ps1'; Note = '(journalctl)'; Platforms = @('Linux') } + @{ Label = 'System Information'; Script = 'Get-SystemInfo.ps1'; Note = '' } + @{ Label = 'Hardware Information'; Script = 'Get-HardwareInfo.ps1'; Note = '' } + @{ Label = 'Scan + Repair'; Script = 'Invoke-ScanAndRepair.ps1'; Note = '(SFC + DISM combined)' } + @{ Label = 'Battery Report'; Script = 'Get-BatteryReport.ps1'; Note = '(laptop only)' } + @{ Label = 'Windows Update'; Script = 'Invoke-WindowsUpdate.ps1'; Note = '' } + @{ Label = 'Disk Optimization'; Script = 'Invoke-DiskOptimize.ps1'; Note = '' } + @{ Label = 'Disk Cleanup'; Script = 'Invoke-DiskCleanup.ps1'; Note = '' } + @{ Label = 'Short Ping Test'; Script = 'Test-NetworkShort.ps1'; Note = '' } + @{ Label = 'Continuous Ping Test'; Script = 'Test-NetworkContinuous.ps1'; Note = '' } + @{ Label = 'Traceroute to Google'; Script = 'Test-Traceroute.ps1'; Note = '' } + @{ Label = 'Reset Network Stack'; Script = 'Invoke-NetworkReset.ps1'; Note = '' } + @{ Label = 'Update all packages'; Script = 'Invoke-SystemUpdate.ps1'; Note = '(winget)' } + @{ Label = 'Update HP Drivers'; Script = 'Invoke-HPUpdate.ps1'; Note = '(HP only)' } + @{ Label = 'Restart Audio Drivers'; Script = 'Invoke-AudioRestart.ps1'; Note = '' } + @{ Label = 'Open Battery Report'; Script = 'Open-BatteryReport.ps1'; Note = '' } + @{ Label = 'Open CBS Log'; Script = 'Open-CBSLog.ps1'; Note = '' } + @{ Label = 'Get Ninite'; Script = 'Get-Ninite.ps1'; Note = '(Edge, Chrome, VLC, 7-Zip)' } + @{ Label = 'Windows License Key'; Script = 'Get-LicenseKey.ps1'; Note = '' } + @{ Label = 'BIOS Password Recovery'; Script = 'Open-BIOSPasswordTool.ps1'; Note = '' } + @{ Label = 'Boot Repair'; Script = 'Invoke-BootRepair.ps1'; Note = '(UEFI - caution!)' } + @{ Label = 'Shutdown / Reboot / Log Off'; Script = 'Invoke-PowerOptions.ps1'; Note = '' } + @{ Label = 'Repair Winget'; Script = 'Invoke-WingetRepair.ps1'; Note = '' } ) - $active = @($toolDefs | Where-Object { - $_.Platforms -contains $Global:PcPlatform -and - -not ($_.NeedsMutableOS -and $Global:PcImageBased) - }) + $active = @($toolDefs) $t = Join-Path $Global:pcHealthRoot 'tools' while ($true) { diff --git a/src/Windows/CLI/tools/Get-HardwareInfo.ps1 b/src/Windows/CLI/tools/Get-HardwareInfo.ps1 index 00c806a..417bd27 100644 --- a/src/Windows/CLI/tools/Get-HardwareInfo.ps1 +++ b/src/Windows/CLI/tools/Get-HardwareInfo.ps1 @@ -14,10 +14,8 @@ function Write-SectionHeader { function Find-Smartctl { $inPath = Get-Command smartctl -ErrorAction SilentlyContinue if ($inPath) { return $inPath.Source } - if (-not $IsLinux) { - $prog = "$env:ProgramFiles\smartmontools\bin\smartctl.exe" - if (Test-Path $prog) { return $prog } - } + $prog = "$env:ProgramFiles\smartmontools\bin\smartctl.exe" + if (Test-Path $prog) { return $prog } return $null } @@ -26,24 +24,15 @@ if (-not $smartctl) { Write-Host "`n[pcHealth] smartmontools is recommended for full SMART disk health data (life %, temperature, power-on hours)." -ForegroundColor Yellow Write-Host ' Without it, life %, temperature and power-on hours are unavailable.' -ForegroundColor DarkGray - $prompt = if ($IsLinux) { ' Install now? [Y/N]' } else { ' Install now via winget? [Y/N]' } - $answer = (Read-Host $prompt).Trim() + $answer = (Read-Host ' Install now via winget? [Y/N]').Trim() if ($answer -match '^[Yy]') { - if ($IsLinux) { - $pm = Get-PcPackageManager - if ($Global:PcImageBased) { - Write-Host '[!!] Image-based system -- install smartmontools with Homebrew or Distrobox.' -ForegroundColor Yellow - } elseif ($pm) { & $pm.Cmd @($pm.Install) smartmontools } - else { Write-Host '[!!] No supported package manager found. Install smartmontools manually.' -ForegroundColor Yellow } + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Host '[!!] winget not available. Install from: https://www.smartmontools.org/' -ForegroundColor Yellow } else { - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Host '[!!] winget not available. Install from: https://www.smartmontools.org/' -ForegroundColor Yellow - } else { - winget install --source winget --id smartmontools.smartmontools -e --silent ` - --accept-package-agreements --accept-source-agreements - $env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + - [System.Environment]::GetEnvironmentVariable('Path','User') - } + winget install --source winget --id smartmontools.smartmontools -e --silent ` + --accept-package-agreements --accept-source-agreements + $env:Path = [System.Environment]::GetEnvironmentVariable('Path','Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path','User') } $smartctl = Find-Smartctl if ($smartctl) { @@ -56,289 +45,146 @@ if (-not $smartctl) { } } -if ($IsLinux) { - # -- CPU ------------------------------------------------------------------ - Write-SectionHeader 'CPU' - if (Get-Command lscpu -ErrorAction SilentlyContinue) { - $lscpuData = @{} - & lscpu 2>$null | ForEach-Object { - if ($_ -match '^(.+?):\s+(.+)$') { $lscpuData[$Matches[1].Trim()] = $Matches[2].Trim() } - } - $maxMHz = $lscpuData['CPU max MHz'] - $maxMHzStr = if ($maxMHz) { "$([Math]::Round([double]($maxMHz -replace ',', '.'), 0)) MHz" } else { 'N/A' } - [PSCustomObject]@{ - 'CPU Name' = $lscpuData['Model name'] ?? 'N/A' - 'Architecture' = $lscpuData['Architecture'] ?? 'N/A' - 'Cores' = $lscpuData['Core(s) per socket'] ?? 'N/A' - 'Threads' = $lscpuData['CPU(s)'] ?? 'N/A' - 'Max Speed' = $maxMHzStr - 'L1d Cache' = $lscpuData['L1d cache'] ?? 'N/A' - 'L1i Cache' = $lscpuData['L1i cache'] ?? 'N/A' - 'L2 Cache' = $lscpuData['L2 cache'] ?? 'N/A' - 'L3 Cache' = $lscpuData['L3 cache'] ?? 'N/A' - 'Virtualization' = $lscpuData['Virtualization'] ?? 'N/A' - } | Format-List | Out-Host - } elseif (Test-Path '/proc/cpuinfo') { - Get-Content '/proc/cpuinfo' | Where-Object { $_ -match '^(model name|cpu MHz|cpu cores|siblings)' } | Out-Host - } else { - Write-Warning "CPU information not available." - } - - # -- GPU ------------------------------------------------------------------ - Write-SectionHeader 'GPU' - if (Get-Command lspci -ErrorAction SilentlyContinue) { - $gpuLines = & lspci 2>$null | Where-Object { $_ -match 'VGA|3D|Display' } - if ($gpuLines) { - $gpuObjects = @($gpuLines | ForEach-Object { - if ($_ -match '^[\w:\.]+\s+(?:VGA compatible controller|Display controller|3D controller):\s*(.+)$') { - [PSCustomObject]@{ GPU = $Matches[1].Trim() } - } - }) - if ($gpuObjects) { $gpuObjects | Format-Table -AutoSize -HideTableHeaders | Out-Host } else { $gpuLines | Out-Host } - } else { Write-Warning "No GPU found via lspci." } - } else { - Write-Warning "lspci not available. Install pciutils." - } +# -- CPU (Windows) -------------------------------------------------------- +$cpuData = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue +if ($cpuData) { + Write-SectionHeader 'CPU Information' + $cpuData | Select-Object @{N='CPU Name';E={$_.Name}}, + @{N='Cores';E={$_.NumberOfCores}}, + @{N='Threads';E={$_.NumberOfLogicalProcessors}}, + @{N='Base Speed (MHz)';E={$_.MaxClockSpeed}} | + Format-Table -AutoSize | Out-Host +} else { Write-Warning "CPU information not available." } + +# -- GPU (Windows) -------------------------------------------------------- +function ConvertTo-VramGB { + param($raw) + if ($null -eq $raw) { return $null } + $bytes = if ($raw -is [byte[]] -and $raw.Length -ge 8) { + [BitConverter]::ToInt64($raw, 0) + } elseif ($raw -isnot [byte[]]) { [long]$raw } else { 0L } + if ($bytes -le 0) { return $null } + return [Math]::Round($bytes / 1GB, 2) +} - # -- Storage -------------------------------------------------------------- - Write-SectionHeader 'Storage' - if ($smartctl) { - $scanJson = (& $smartctl --scan --json 2>$null) -join "`n" - $scanData = $scanJson | ConvertFrom-Json -ErrorAction SilentlyContinue - $devices = $scanData.devices - if ($devices) { - $rows = @(foreach ($dev in $devices) { - $devArgs = @('-a', $dev.name, '--json') - if ($dev.type -and $dev.type -ne 'auto') { $devArgs += @('-d', $dev.type) } - $devJson = (& $smartctl @devArgs 2>$null) -join "`n" - $data = $devJson | ConvertFrom-Json -ErrorAction SilentlyContinue - if (-not $data -or -not $data.model_name) { continue } - $mediaType = if ($dev.type -eq 'nvme') { 'SSD' } elseif ($data.rotation_rate -gt 0) { 'HDD' } else { 'SSD' } - $lifeLeft = 'N/A' - if ($dev.type -eq 'nvme') { - $pct = $data.nvme_smart_health_information_log.percentage_used - if ($null -ne $pct) { $lifeLeft = "$([Math]::Max(0, 100 - [int]$pct))%" } - } elseif ($mediaType -eq 'SSD') { - $attr = $data.ata_smart_attributes.table | Where-Object { $_.id -in @(231, 202, 177) } | Select-Object -First 1 - if ($attr) { $lifeLeft = "$($attr.value)%" } - } - [PSCustomObject]@{ - Model = $data.model_name - Type = $mediaType - 'Size (GB)' = if ($data.capacity.bytes) { [Math]::Round($data.capacity.bytes / 1GB, 0) } else { 'N/A' } - 'Temp (C)' = if ($null -ne $data.temperature.current) { $data.temperature.current } else { 'N/A' } - Hours = if ($data.power_on_time.hours) { $data.power_on_time.hours } else { 'N/A' } - 'Life Left' = $lifeLeft - Health = if ($data.smart_status.passed -eq $true) { 'Healthy' } elseif ($data.smart_status.passed -eq $false) { 'FAILING' } else { 'Unknown' } - } - }) - if ($rows) { $rows | Format-Table -AutoSize | Out-Host } else { Write-Warning "No usable SMART data." } - } else { Write-Warning "smartctl scan found no devices." } - } else { - if (Get-Command lsblk -ErrorAction SilentlyContinue) { - & lsblk -d -o NAME,SIZE,TYPE,MODEL 2>$null | Out-Host - } else { - Write-Warning "Storage section skipped -- smartmontools not available." +$classKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}' +$regAdapters = @() +try { + $regAdapters = @( + Get-ChildItem $classKey -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match '^\d' } | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | + Where-Object { $null -ne (ConvertTo-VramGB $_.'HardwareInformation.qwMemorySize') } + ) +} catch { Write-Warning "Registry adapter key unreadable -- falling back to AdapterRAM: $_" } + +$gpuData = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue +if ($gpuData) { + Write-SectionHeader 'GPU Information' + $gpuData | ForEach-Object { + $gpu = $_ + $regEntry = $regAdapters | Where-Object { $_.'HardwareInformation.AdapterString' -eq $gpu.Name } | Select-Object -First 1 + if (-not $regEntry) { + $regEntry = $regAdapters | Where-Object { + $a = $_.'HardwareInformation.AdapterString' + $a -and ($gpu.Name -like "*$a*" -or $a -like "*$($gpu.Name)*") + } | Select-Object -First 1 } - } + if (-not $regEntry -and $regAdapters.Count -eq 1) { $regEntry = $regAdapters[0] } - # -- RAM ------------------------------------------------------------------ - Write-SectionHeader 'Memory (RAM)' - $mi = @{} - if (Test-Path '/proc/meminfo') { - Get-Content '/proc/meminfo' | ForEach-Object { - if ($_ -match '^(\w+):\s+(\d+)') { $mi[$Matches[1]] = [long]$Matches[2] } - } - } - if ($mi['MemTotal']) { - $buffCache = ($mi['Buffers'] ?? 0) + ($mi['Cached'] ?? 0) + ($mi['SReclaimable'] ?? 0) - [PSCustomObject]@{ - 'Total (GB)' = [Math]::Round($mi['MemTotal'] / 1MB, 2) - 'Used (GB)' = [Math]::Round(($mi['MemTotal'] - $mi['MemAvailable']) / 1MB, 2) - 'Available (GB)' = [Math]::Round($mi['MemAvailable'] / 1MB, 2) - 'Buff/Cache (GB)' = [Math]::Round($buffCache / 1MB, 2) - 'Swap Total (GB)' = [Math]::Round($mi['SwapTotal'] / 1MB, 2) - 'Swap Used (GB)' = [Math]::Round(($mi['SwapTotal'] - $mi['SwapFree']) / 1MB, 2) - } | Format-List | Out-Host - } elseif (Get-Command free -ErrorAction SilentlyContinue) { - & free -h 2>$null | Out-Host - } else { - Write-Warning "RAM information not available." - } + $vramGB = if ($regEntry) { ConvertTo-VramGB $regEntry.'HardwareInformation.qwMemorySize' } + elseif ($gpu.AdapterRAM -ge 1GB) { [Math]::Round($gpu.AdapterRAM / 1GB, 2) } + else { 'Shared' } - # -- Sensors -------------------------------------------------------------- - # Straight from the kernel's hwmon class -- the same source lm-sensors reads, - # so no package needs to be installed. - Write-SectionHeader 'Sensors (Temperatures)' - $hwmonRoot = '/sys/class/hwmon' - $readings = @( - if (Test-Path $hwmonRoot) { - foreach ($chip in Get-ChildItem $hwmonRoot -ErrorAction SilentlyContinue) { - $chipName = try { (Get-Content (Join-Path $chip.FullName 'name') -Raw -ErrorAction Stop).Trim() } - catch { $chip.Name } - foreach ($sensor in Get-ChildItem $chip.FullName -Filter 'temp*_input' -ErrorAction SilentlyContinue) { - $milli = try { [double](Get-Content $sensor.FullName -Raw -ErrorAction Stop) } catch { continue } - $labelFile = $sensor.FullName -replace '_input$', '_label' - $label = if (Test-Path $labelFile) { - (Get-Content $labelFile -Raw -ErrorAction SilentlyContinue).Trim() - } else { $sensor.Name -replace '_input$', '' } - [PSCustomObject]@{ - Chip = $chipName - Sensor = $label - 'Temp (C)' = [Math]::Round($milli / 1000, 1) - } - } - } + [PSCustomObject]@{ + Name = $gpu.Name + 'Video Proc.' = $gpu.VideoProcessor + 'Driver Ver.' = $gpu.DriverVersion + 'Driver Date' = if ($gpu.DriverDate) { $gpu.DriverDate.ToString('yyyy-MM-dd') } else { 'N/A' } + 'VRAM (GB)' = $vramGB } - ) - if ($readings) { - $readings | Sort-Object Chip, Sensor | Format-Table -AutoSize | Out-Host - } else { - Write-Warning "No temperature sensors exposed under $hwmonRoot." - } - -} else { - # -- CPU (Windows) -------------------------------------------------------- - $cpuData = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue - if ($cpuData) { - Write-SectionHeader 'CPU Information' - $cpuData | Select-Object @{N='CPU Name';E={$_.Name}}, - @{N='Cores';E={$_.NumberOfCores}}, - @{N='Threads';E={$_.NumberOfLogicalProcessors}}, - @{N='Base Speed (MHz)';E={$_.MaxClockSpeed}} | - Format-Table -AutoSize | Out-Host - } else { Write-Warning "CPU information not available." } - - # -- GPU (Windows) -------------------------------------------------------- - function ConvertTo-VramGB { - param($raw) - if ($null -eq $raw) { return $null } - $bytes = if ($raw -is [byte[]] -and $raw.Length -ge 8) { - [BitConverter]::ToInt64($raw, 0) - } elseif ($raw -isnot [byte[]]) { [long]$raw } else { 0L } - if ($bytes -le 0) { return $null } - return [Math]::Round($bytes / 1GB, 2) - } - - $classKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}' - $regAdapters = @() - try { - $regAdapters = @( - Get-ChildItem $classKey -ErrorAction SilentlyContinue | - Where-Object { $_.PSChildName -match '^\d' } | - ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | - Where-Object { $null -ne (ConvertTo-VramGB $_.'HardwareInformation.qwMemorySize') } - ) - } catch { Write-Warning "Registry adapter key unreadable -- falling back to AdapterRAM: $_" } - - $gpuData = Get-CimInstance -ClassName Win32_VideoController -ErrorAction SilentlyContinue - if ($gpuData) { - Write-SectionHeader 'GPU Information' - $gpuData | ForEach-Object { - $gpu = $_ - $regEntry = $regAdapters | Where-Object { $_.'HardwareInformation.AdapterString' -eq $gpu.Name } | Select-Object -First 1 - if (-not $regEntry) { - $regEntry = $regAdapters | Where-Object { - $a = $_.'HardwareInformation.AdapterString' - $a -and ($gpu.Name -like "*$a*" -or $a -like "*$($gpu.Name)*") - } | Select-Object -First 1 + } | Format-Table -AutoSize | Out-Host +} else { Write-Warning "GPU information not available." } + +# -- Storage (Windows) ---------------------------------------------------- +Write-SectionHeader 'Storage' +if ($smartctl) { + $scanData = (& $smartctl --scan --json 2>$null) | ConvertFrom-Json -ErrorAction SilentlyContinue + $devices = $scanData.devices + if ($devices) { + $storageRows = @(foreach ($dev in $devices) { + $data = (& $smartctl -a $dev.name --json 2>$null) | ConvertFrom-Json -ErrorAction SilentlyContinue + if (-not $data -or -not $data.model_name) { continue } + $busType = switch ($dev.type) { 'nvme' { 'NVMe' } 'sat' { 'SATA' } default { $dev.type.ToUpper() } } + $mediaType = if ($dev.type -eq 'nvme') { 'SSD' } elseif ($data.rotation_rate -gt 0) { 'HDD' } else { 'SSD' } + $lifeLeft = 'N/A' + if ($dev.type -eq 'nvme') { + $pct = $data.nvme_smart_health_information_log.percentage_used + if ($null -ne $pct) { $lifeLeft = "$([Math]::Max(0,100-[int]$pct))%" } + } elseif ($mediaType -eq 'SSD') { + $attr = $data.ata_smart_attributes.table | Where-Object { $_.id -in @(231,202,177) } | Select-Object -First 1 + if ($attr) { $lifeLeft = "$($attr.value)%" } } - if (-not $regEntry -and $regAdapters.Count -eq 1) { $regEntry = $regAdapters[0] } - - $vramGB = if ($regEntry) { ConvertTo-VramGB $regEntry.'HardwareInformation.qwMemorySize' } - elseif ($gpu.AdapterRAM -ge 1GB) { [Math]::Round($gpu.AdapterRAM / 1GB, 2) } - else { 'Shared' } - [PSCustomObject]@{ - Name = $gpu.Name - 'Video Proc.' = $gpu.VideoProcessor - 'Driver Ver.' = $gpu.DriverVersion - 'Driver Date' = if ($gpu.DriverDate) { $gpu.DriverDate.ToString('yyyy-MM-dd') } else { 'N/A' } - 'VRAM (GB)' = $vramGB + Model = $data.model_name + Bus = $busType + Type = $mediaType + 'Size (GB)' = if ($data.capacity.bytes) { [Math]::Round($data.capacity.bytes/1GB,0) } else { 'N/A' } + 'Temp (degC)' = if ($null -ne $data.temperature.current) { $data.temperature.current } else { 'N/A' } + Hours = if ($data.power_on_time.hours) { $data.power_on_time.hours } else { 'N/A' } + 'Life Left' = $lifeLeft + Health = if ($data.smart_status.passed -eq $true) { 'Healthy' } elseif ($data.smart_status.passed -eq $false) { 'FAILING' } else { 'Unknown' } } - } | Format-Table -AutoSize | Out-Host - } else { Write-Warning "GPU information not available." } - - # -- Storage (Windows) ---------------------------------------------------- - Write-SectionHeader 'Storage' - if ($smartctl) { - $scanData = (& $smartctl --scan --json 2>$null) | ConvertFrom-Json -ErrorAction SilentlyContinue - $devices = $scanData.devices - if ($devices) { - $storageRows = @(foreach ($dev in $devices) { - $data = (& $smartctl -a $dev.name --json 2>$null) | ConvertFrom-Json -ErrorAction SilentlyContinue - if (-not $data -or -not $data.model_name) { continue } - $busType = switch ($dev.type) { 'nvme' { 'NVMe' } 'sat' { 'SATA' } default { $dev.type.ToUpper() } } - $mediaType = if ($dev.type -eq 'nvme') { 'SSD' } elseif ($data.rotation_rate -gt 0) { 'HDD' } else { 'SSD' } - $lifeLeft = 'N/A' - if ($dev.type -eq 'nvme') { - $pct = $data.nvme_smart_health_information_log.percentage_used - if ($null -ne $pct) { $lifeLeft = "$([Math]::Max(0,100-[int]$pct))%" } - } elseif ($mediaType -eq 'SSD') { - $attr = $data.ata_smart_attributes.table | Where-Object { $_.id -in @(231,202,177) } | Select-Object -First 1 - if ($attr) { $lifeLeft = "$($attr.value)%" } - } - [PSCustomObject]@{ - Model = $data.model_name - Bus = $busType - Type = $mediaType - 'Size (GB)' = if ($data.capacity.bytes) { [Math]::Round($data.capacity.bytes/1GB,0) } else { 'N/A' } - 'Temp (degC)' = if ($null -ne $data.temperature.current) { $data.temperature.current } else { 'N/A' } - Hours = if ($data.power_on_time.hours) { $data.power_on_time.hours } else { 'N/A' } - 'Life Left' = $lifeLeft - Health = if ($data.smart_status.passed -eq $true) { 'Healthy' } elseif ($data.smart_status.passed -eq $false) { 'FAILING' } else { 'Unknown' } - } - }) - if ($storageRows) { $storageRows | Format-Table -AutoSize | Out-Host } else { Write-Warning "smartctl returned no usable device data." } - } else { Write-Warning "smartctl scan found no devices." } - } else { Write-Warning "Storage section skipped -- smartmontools not available." } - - # -- RAM (Windows) -------------------------------------------------------- - function Resolve-RamManufacturer { - param([string]$Manufacturer, [string]$PartNumber) - $m = $Manufacturer.Trim() - if ($m -and $m -ne 'Unknown') { return $m } - switch -Wildcard ($PartNumber.Trim()) { - 'CM*' { return 'Corsair' } 'CT*' { return 'Crucial' } - 'BL*' { return 'Crucial' } 'KVR*' { return 'Kingston' } - 'HX*' { return 'HyperX / Kingston' } - 'F4-*' { return 'G.Skill' } 'F5-*' { return 'G.Skill' } - 'TED*' { return 'TeamGroup' } 'TEAMGROUP*' { return 'TeamGroup' } - 'MTA*' { return 'Micron' } 'MT*' { return 'Micron' } - 'M378*'{ return 'Samsung' } 'M471*'{ return 'Samsung' } - 'AD4*' { return 'ADATA' } 'AX4*' { return 'ADATA (XPG)' } - default { return 'Unknown' } - } + }) + if ($storageRows) { $storageRows | Format-Table -AutoSize | Out-Host } else { Write-Warning "smartctl returned no usable device data." } + } else { Write-Warning "smartctl scan found no devices." } +} else { Write-Warning "Storage section skipped -- smartmontools not available." } + +# -- RAM (Windows) -------------------------------------------------------- +function Resolve-RamManufacturer { + param([string]$Manufacturer, [string]$PartNumber) + $m = $Manufacturer.Trim() + if ($m -and $m -ne 'Unknown') { return $m } + switch -Wildcard ($PartNumber.Trim()) { + 'CM*' { return 'Corsair' } 'CT*' { return 'Crucial' } + 'BL*' { return 'Crucial' } 'KVR*' { return 'Kingston' } + 'HX*' { return 'HyperX / Kingston' } + 'F4-*' { return 'G.Skill' } 'F5-*' { return 'G.Skill' } + 'TED*' { return 'TeamGroup' } 'TEAMGROUP*' { return 'TeamGroup' } + 'MTA*' { return 'Micron' } 'MT*' { return 'Micron' } + 'M378*'{ return 'Samsung' } 'M471*'{ return 'Samsung' } + 'AD4*' { return 'ADATA' } 'AX4*' { return 'ADATA (XPG)' } + default { return 'Unknown' } } - - $ramData = Get-CimInstance -ClassName Win32_PhysicalMemory -ErrorAction SilentlyContinue - if ($ramData) { - Write-SectionHeader 'Memory (RAM) Modules' - $ramData | Select-Object @{N='Slot';E={$_.BankLabel}}, - @{N='Capacity(GB)';E={[Math]::Round($_.Capacity/1GB,2)}}, - @{N='Speed(MT/s)';E={$_.Speed}}, - @{N='Part Number';E={$_.PartNumber.Trim()}}, - @{N='Manufacturer';E={Resolve-RamManufacturer $_.Manufacturer $_.PartNumber}} | - Format-Table -AutoSize | Out-Host - $totalGB = [Math]::Round(($ramData | Measure-Object -Property Capacity -Sum).Sum / 1GB, 2) - Write-Host "Total Installed RAM: $totalGB GB`n" -ForegroundColor Green - } else { Write-Warning "RAM information not available." } - - # -- Chipset (Windows) ---------------------------------------------------- - Write-SectionHeader 'Chipset' - $smbus = Get-PnpDevice -Class System -ErrorAction SilentlyContinue | - Where-Object { $_.FriendlyName -like '*SMBus*' -and $_.Status -eq 'OK' } | - Select-Object -First 1 - - if ($smbus) { - $chipsetVer = (Get-PnpDeviceProperty -InstanceId $smbus.InstanceId ` - -KeyName 'DEVPKEY_Device_DriverVersion' -ErrorAction SilentlyContinue).Data - $chipsetDate = (Get-PnpDeviceProperty -InstanceId $smbus.InstanceId ` - -KeyName 'DEVPKEY_Device_DriverDate' -ErrorAction SilentlyContinue).Data - [PSCustomObject]@{ - Device = $smbus.FriendlyName - 'Driver Version' = if ($chipsetVer) { $chipsetVer } else { 'N/A' } - 'Driver Date' = if ($chipsetDate) { ([datetime]$chipsetDate).ToString('yyyy-MM-dd') } else { 'N/A' } - } | Format-List | Out-Host - } else { Write-Warning "Chipset SMBus controller not found." } } + +$ramData = Get-CimInstance -ClassName Win32_PhysicalMemory -ErrorAction SilentlyContinue +if ($ramData) { + Write-SectionHeader 'Memory (RAM) Modules' + $ramData | Select-Object @{N='Slot';E={$_.BankLabel}}, + @{N='Capacity(GB)';E={[Math]::Round($_.Capacity/1GB,2)}}, + @{N='Speed(MT/s)';E={$_.Speed}}, + @{N='Part Number';E={$_.PartNumber.Trim()}}, + @{N='Manufacturer';E={Resolve-RamManufacturer $_.Manufacturer $_.PartNumber}} | + Format-Table -AutoSize | Out-Host + $totalGB = [Math]::Round(($ramData | Measure-Object -Property Capacity -Sum).Sum / 1GB, 2) + Write-Host "Total Installed RAM: $totalGB GB`n" -ForegroundColor Green +} else { Write-Warning "RAM information not available." } + +# -- Chipset (Windows) ---------------------------------------------------- +Write-SectionHeader 'Chipset' +$smbus = Get-PnpDevice -Class System -ErrorAction SilentlyContinue | + Where-Object { $_.FriendlyName -like '*SMBus*' -and $_.Status -eq 'OK' } | + Select-Object -First 1 + +if ($smbus) { + $chipsetVer = (Get-PnpDeviceProperty -InstanceId $smbus.InstanceId ` + -KeyName 'DEVPKEY_Device_DriverVersion' -ErrorAction SilentlyContinue).Data + $chipsetDate = (Get-PnpDeviceProperty -InstanceId $smbus.InstanceId ` + -KeyName 'DEVPKEY_Device_DriverDate' -ErrorAction SilentlyContinue).Data + [PSCustomObject]@{ + Device = $smbus.FriendlyName + 'Driver Version' = if ($chipsetVer) { $chipsetVer } else { 'N/A' } + 'Driver Date' = if ($chipsetDate) { ([datetime]$chipsetDate).ToString('yyyy-MM-dd') } else { 'N/A' } + } | Format-List | Out-Host +} else { Write-Warning "Chipset SMBus controller not found." } diff --git a/src/Windows/CLI/tools/Get-SystemInfo.ps1 b/src/Windows/CLI/tools/Get-SystemInfo.ps1 index 8d1bfce..a4785da 100644 --- a/src/Windows/CLI/tools/Get-SystemInfo.ps1 +++ b/src/Windows/CLI/tools/Get-SystemInfo.ps1 @@ -3,168 +3,62 @@ # pcHealth -- System Information # ============================================================================ -if ($IsLinux) { - $hostname = [System.Net.Dns]::GetHostName() - $kernel = (Get-PcCommandOutput 'uname' @('-r')) ?? 'N/A' - $arch = (Get-PcCommandOutput 'uname' @('-m')) ?? 'N/A' - $uptime = (Get-PcCommandOutput 'uptime' @('-p')) ?? 'N/A' - $user = (Get-PcDesktopUser)?.Name ?? 'N/A' - - $osName = (Get-LinuxDistroInfo)['PRETTY_NAME'] - - # RAM - $memInfo = @{} - if (Test-Path '/proc/meminfo') { - Get-Content '/proc/meminfo' | ForEach-Object { - if ($_ -match '^(\w+):\s+(\d+)') { - $memInfo[$Matches[1]] = [long]$Matches[2] - } - } - } - $totalRamGB = if ($memInfo['MemTotal']) { [Math]::Round($memInfo['MemTotal'] / 1MB, 2) } else { 'N/A' } - $usedRamGB = if ($memInfo['MemTotal'] -and $memInfo['MemAvailable']) { - [Math]::Round(($memInfo['MemTotal'] - $memInfo['MemAvailable']) / 1MB, 2) - } else { 'N/A' } - - # CPU model (brief — Hardware Info has the full lscpu dump) - $cpu = 'N/A' - if (Test-Path '/proc/cpuinfo') { - $cpuLine = Get-Content '/proc/cpuinfo' | Where-Object { $_ -match '^model name' } | Select-Object -First 1 - if ($cpuLine -match '^model name\s*:\s*(.+)') { $cpu = $Matches[1].Trim() } - } - - # Machine model from DMI - $vendor = try { (Get-Content '/sys/class/dmi/id/sys_vendor' -ErrorAction Stop).Trim() } catch { $null } - $model = try { (Get-Content '/sys/class/dmi/id/product_name' -ErrorAction Stop).Trim() } catch { $null } - $machine = if ($vendor -and $model) { "$vendor $model" } elseif ($model) { $model } else { 'N/A' } - - # Firmware type - $firmwareType = if (Test-Path '/sys/firmware/efi') { 'UEFI' } else { 'Legacy BIOS' } - - # Secure Boot - $secureBoot = 'N/A' - $mokOut = Get-PcCommandOutput 'mokutil' @('--sb-state') - if ($mokOut) { - $secureBoot = if ($mokOut -match 'enabled') { 'Enabled' } elseif ($mokOut -match 'disabled') { 'Disabled' } else { $mokOut } - } elseif (Test-Path '/sys/firmware/efi/efivars') { - $sbVar = Get-ChildItem '/sys/firmware/efi/efivars' -Filter 'SecureBoot-*' -ErrorAction SilentlyContinue | Select-Object -First 1 - if ($sbVar) { - try { - $bytes = [System.IO.File]::ReadAllBytes($sbVar.FullName) - $secureBoot = if ($bytes.Length -ge 5 -and $bytes[4] -eq 1) { 'Enabled' } else { 'Disabled' } - } catch { - # If reading the efivar fails, mark secure boot state as unknown - $secureBoot = 'Unknown' - } - } - } - - # Last boot timestamp - $lastBoot = (Get-PcCommandOutput 'uptime' @('-s')) ?? 'N/A' - - # Desktop environment / Wayland or X11 - $de = $env:XDG_CURRENT_DESKTOP ?? $env:DESKTOP_SESSION ?? 'Unknown' - $session = $env:WAYLAND_DISPLAY ? 'Wayland' : ($env:DISPLAY ? 'X11' : 'Unknown') - - # Shell (basename only) - $shell = $env:SHELL ?? 'Unknown' - if ($shell -match '/([^/]+)$') { $shell = $Matches[1] } - - # Installed package count (distro-aware) - $pkgCount = 'N/A' - if (Get-Command pacman -ErrorAction SilentlyContinue) { - $pkgCount = "$(( & pacman -Q 2>$null).Count) (pacman)" - } elseif (Get-Command dpkg -ErrorAction SilentlyContinue) { - $pkgCount = "$(( & dpkg -l 2>$null | Where-Object { $_ -match '^ii' }).Count) (dpkg)" - } elseif (Get-Command rpm -ErrorAction SilentlyContinue) { - $pkgCount = "$(( & rpm -qa 2>$null).Count) (rpm)" - } - - # Timezone - # timedatectl is unavailable without systemd (containers, WSL, OpenRC distros). - $timezone = Get-PcCommandOutput 'timedatectl' @('show', '--property=Timezone', '--value') - if (-not $timezone) { $timezone = $env:TZ ?? (Get-PcCommandOutput 'date' @('+%Z')) ?? 'N/A' } - - [PSCustomObject]@{ - 'Computer Name' = $hostname - 'Machine' = $machine - 'OS Name' = $osName - 'Kernel' = $kernel - 'Architecture' = $arch - 'CPU' = $cpu - 'RAM Used (GB)' = $usedRamGB - 'RAM Total (GB)' = $totalRamGB - 'Firmware' = $firmwareType - 'Secure Boot' = "$secureBoot [*]" - 'Uptime' = $uptime - 'Last Boot' = $lastBoot - 'Desktop' = $de - 'Session' = $session - 'Shell' = $shell - 'Packages' = $pkgCount - 'Timezone' = $timezone - 'User' = $user - } | Format-List | Out-Host - Write-Host ' [*] Secure Boot shows the UEFI firmware state only. On Linux, actual enforcement depends on shim/MOK setup and varies per distro.' -ForegroundColor DarkGray - -} else { - $os = Get-CimInstance -ClassName Win32_OperatingSystem - $cs = Get-CimInstance -ClassName Win32_ComputerSystem - $cpu = Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 - $ntCv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue - - $winVer = $ntCv.DisplayVersion - $ubr = $ntCv.UBR - $fullBuild = if ($ubr) { "$($os.BuildNumber).$ubr" } else { $os.BuildNumber } - - $fw = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue - # $env:firmware_type is only set in WinPE/MDT; in a normal session it is always empty. - # Read PEFirmwareType from the registry instead: 1 = BIOS, 2 = UEFI. - # Use -Name so only this one value is retrieved; accessing a missing property on the - # whole key would return $null in PowerShell, but -Name throws a clean error instead. - $fwTypeRaw = try { - (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control' ` - -Name PEFirmwareType -ErrorAction Stop).PEFirmwareType - } catch { - # PEFirmwareType is absent on some OEM or pre-UEFI systems; log and fall through. - Write-Debug "PEFirmwareType registry property not found: $_" - $null - } - $fwType = switch ($fwTypeRaw) { 2 { 'UEFI' } 1 { 'Legacy BIOS' } default { 'Unknown' } } - $fwVersion = if ($fw.SMBIOSBIOSVersion) { $fw.SMBIOSBIOSVersion } else { 'Unknown' } - $fwDate = if ($fw.ReleaseDate) { $fw.ReleaseDate.ToString('yyyy-MM-dd') } else { 'Unknown' } - - $secureBoot = try { - if (Confirm-SecureBootUEFI) { 'Enabled' } else { 'Disabled' } - } catch { 'N/A' } - - $tpmState = Get-Tpm -ErrorAction SilentlyContinue - $tpmWmi = Get-CimInstance -Namespace 'root\cimv2\security\microsofttpm' ` - -ClassName Win32_Tpm -ErrorAction SilentlyContinue - $tpmVersion = if ($tpmWmi.SpecVersion) { ($tpmWmi.SpecVersion -split ',')[0].Trim() } else { 'N/A' } - $tpmStatus = if ($tpmState.TpmReady) { 'Ready' } - elseif ($tpmState.TpmPresent) { 'Present (not ready)' } - else { 'Not present' } - - [PSCustomObject]@{ - 'Computer Name' = $env:COMPUTERNAME - 'OS Name' = $os.Caption - 'Windows Version' = $winVer - 'OS Build' = $fullBuild - 'Architecture' = $os.OSArchitecture - 'Manufacturer' = $cs.Manufacturer - 'Model' = $cs.Model - 'Firmware Type' = $fwType - 'Firmware Version' = $fwVersion - 'Firmware Date' = $fwDate - 'Secure Boot' = $secureBoot - 'TPM Version' = $tpmVersion - 'TPM Status' = $tpmStatus - 'Processor' = $cpu.Name - 'Total RAM (GB)' = [Math]::Round($cs.TotalPhysicalMemory / 1GB, 2) - 'Install Date' = $os.InstallDate.ToString('yyyy-MM-dd') - 'Last Boot' = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss') - 'System Directory' = $os.SystemDirectory - 'Windows Directory'= $os.WindowsDirectory - } | Format-List | Out-Host +$os = Get-CimInstance -ClassName Win32_OperatingSystem +$cs = Get-CimInstance -ClassName Win32_ComputerSystem +$cpu = Get-CimInstance -ClassName Win32_Processor | Select-Object -First 1 +$ntCv = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue + +$winVer = $ntCv.DisplayVersion +$ubr = $ntCv.UBR +$fullBuild = if ($ubr) { "$($os.BuildNumber).$ubr" } else { $os.BuildNumber } + +$fw = Get-CimInstance -ClassName Win32_BIOS -ErrorAction SilentlyContinue +# $env:firmware_type is only set in WinPE/MDT; in a normal session it is always empty. +# Read PEFirmwareType from the registry instead: 1 = BIOS, 2 = UEFI. +# Use -Name so only this one value is retrieved; accessing a missing property on the +# whole key would return $null in PowerShell, but -Name throws a clean error instead. +$fwTypeRaw = try { + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control' ` + -Name PEFirmwareType -ErrorAction Stop).PEFirmwareType +} catch { + # PEFirmwareType is absent on some OEM or pre-UEFI systems; log and fall through. + Write-Debug "PEFirmwareType registry property not found: $_" + $null } +$fwType = switch ($fwTypeRaw) { 2 { 'UEFI' } 1 { 'Legacy BIOS' } default { 'Unknown' } } +$fwVersion = if ($fw.SMBIOSBIOSVersion) { $fw.SMBIOSBIOSVersion } else { 'Unknown' } +$fwDate = if ($fw.ReleaseDate) { $fw.ReleaseDate.ToString('yyyy-MM-dd') } else { 'Unknown' } + +$secureBoot = try { + if (Confirm-SecureBootUEFI) { 'Enabled' } else { 'Disabled' } +} catch { 'N/A' } + +$tpmState = Get-Tpm -ErrorAction SilentlyContinue +$tpmWmi = Get-CimInstance -Namespace 'root\cimv2\security\microsofttpm' ` + -ClassName Win32_Tpm -ErrorAction SilentlyContinue +$tpmVersion = if ($tpmWmi.SpecVersion) { ($tpmWmi.SpecVersion -split ',')[0].Trim() } else { 'N/A' } +$tpmStatus = if ($tpmState.TpmReady) { 'Ready' } + elseif ($tpmState.TpmPresent) { 'Present (not ready)' } + else { 'Not present' } + +[PSCustomObject]@{ + 'Computer Name' = $env:COMPUTERNAME + 'OS Name' = $os.Caption + 'Windows Version' = $winVer + 'OS Build' = $fullBuild + 'Architecture' = $os.OSArchitecture + 'Manufacturer' = $cs.Manufacturer + 'Model' = $cs.Model + 'Firmware Type' = $fwType + 'Firmware Version' = $fwVersion + 'Firmware Date' = $fwDate + 'Secure Boot' = $secureBoot + 'TPM Version' = $tpmVersion + 'TPM Status' = $tpmStatus + 'Processor' = $cpu.Name + 'Total RAM (GB)' = [Math]::Round($cs.TotalPhysicalMemory / 1GB, 2) + 'Install Date' = $os.InstallDate.ToString('yyyy-MM-dd') + 'Last Boot' = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss') + 'System Directory' = $os.SystemDirectory + 'Windows Directory'= $os.WindowsDirectory +} | Format-List | Out-Host diff --git a/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 b/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 index b6c4e7b..153cf76 100644 --- a/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 +++ b/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 @@ -14,56 +14,26 @@ Write-Host " [B] Cancel`n" $choice = (Read-Host " Choice").Trim().ToUpper() -if ($IsLinux) { - switch ($choice) { - '1' { - # Under sudo $env:USER is root; log off the human behind it instead. - $desktopUser = (Get-PcDesktopUser)?.Name - if (-not $desktopUser) { - Write-Host "`n [!!] Could not determine the desktop user.`n" -ForegroundColor Red - return - } - $ok = (Read-Host "`n Log off $desktopUser? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { - # loginctl terminates the user's session cleanly. - & loginctl terminate-user $desktopUser - } else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - '2' { - $ok = (Read-Host "`n Restart the system? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { & shutdown -r now } - else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - '3' { - $ok = (Read-Host "`n Shut down the system? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { & shutdown -h now } - else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - 'B' { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - default { Write-Host "`n Invalid choice.`n" -ForegroundColor Red } +switch ($choice) { + '1' { + $ok = (Read-Host "`n Log off $env:USERNAME? (y/n)").Trim().ToLower() + if ($ok -eq 'y') { + # Win32Shutdown flag 0 = Log off. Uses CIM to trigger the normal + # Windows sign-out flow (respects running apps), unlike logoff.exe. + $os = Get-CimInstance -ClassName Win32_OperatingSystem + Invoke-CimMethod -InputObject $os -MethodName Win32Shutdown -Arguments @{ Flags = 0 } | Out-Null + } else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } } -} else { - switch ($choice) { - '1' { - $ok = (Read-Host "`n Log off $env:USERNAME? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { - # Win32Shutdown flag 0 = Log off. Uses CIM to trigger the normal - # Windows sign-out flow (respects running apps), unlike logoff.exe. - $os = Get-CimInstance -ClassName Win32_OperatingSystem - Invoke-CimMethod -InputObject $os -MethodName Win32Shutdown -Arguments @{ Flags = 0 } | Out-Null - } else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - '2' { - $ok = (Read-Host "`n Restart the PC? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { Restart-Computer -Force } - else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - '3' { - $ok = (Read-Host "`n Shut down the PC? (y/n)").Trim().ToLower() - if ($ok -eq 'y') { Stop-Computer -Force } - else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - } - 'B' { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } - default { Write-Host "`n Invalid choice.`n" -ForegroundColor Red } + '2' { + $ok = (Read-Host "`n Restart the PC? (y/n)").Trim().ToLower() + if ($ok -eq 'y') { Restart-Computer -Force } + else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } } + '3' { + $ok = (Read-Host "`n Shut down the PC? (y/n)").Trim().ToLower() + if ($ok -eq 'y') { Stop-Computer -Force } + else { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } + } + 'B' { Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray } + default { Write-Host "`n Invalid choice.`n" -ForegroundColor Red } } diff --git a/src/Windows/CLI/tools/Test-Traceroute.ps1 b/src/Windows/CLI/tools/Test-Traceroute.ps1 index 2781654..b110235 100644 --- a/src/Windows/CLI/tools/Test-Traceroute.ps1 +++ b/src/Windows/CLI/tools/Test-Traceroute.ps1 @@ -8,30 +8,15 @@ param( Write-Host "`nTraceroute to $Target (max 30 hops)...`n" -ForegroundColor Cyan -if ($IsLinux) { - # Test-NetConnection -TraceRoute is not available on Linux. - # Prefer traceroute; fall back to tracepath if not installed. - $cmd = if (Get-Command traceroute -ErrorAction SilentlyContinue) { 'traceroute' } - elseif (Get-Command tracepath -ErrorAction SilentlyContinue) { 'tracepath' } - else { $null } +$result = Test-NetConnection -ComputerName $Target -TraceRoute -ErrorAction SilentlyContinue - if ($cmd) { - & $cmd $Target - } else { - Write-Host " Neither traceroute nor tracepath found." -ForegroundColor Yellow - Write-Host " Install via: sudo apt-get install traceroute (or dnf/pacman)`n" -ForegroundColor DarkGray +if ($result) { + $hop = 1 + foreach ($node in $result.TraceRoute) { + Write-Host (" {0,2} {1}" -f $hop, $node) + $hop++ } + Write-Host "`n Destination: $($result.RemoteAddress) -- TCP: $($result.TcpTestSucceeded)`n" -ForegroundColor Cyan } else { - $result = Test-NetConnection -ComputerName $Target -TraceRoute -ErrorAction SilentlyContinue - - if ($result) { - $hop = 1 - foreach ($node in $result.TraceRoute) { - Write-Host (" {0,2} {1}" -f $hop, $node) - $hop++ - } - Write-Host "`n Destination: $($result.RemoteAddress) -- TCP: $($result.TcpTestSucceeded)`n" -ForegroundColor Cyan - } else { - Write-Host " Traceroute failed. Check your network connection.`n" -ForegroundColor Red - } + Write-Host " Traceroute failed. Check your network connection.`n" -ForegroundColor Red } diff --git a/src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 b/src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 deleted file mode 100644 index e689ee5..0000000 --- a/src/Windows/CLI/tools/linux/Get-BatteryReport.ps1 +++ /dev/null @@ -1,87 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Battery Report (Linux) -# Reads the kernel's power_supply class directly. No external tool needed: -# upower and acpi both read the same sysfs files. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Battery Report' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -# Attribute names vary by driver and any of them may be absent. -function Get-SysAttribute { - param([string]$Dir, [string[]]$Names) - foreach ($name in $Names) { - $path = Join-Path $Dir $name - if (Test-Path $path) { - $value = try { (Get-Content $path -Raw -ErrorAction Stop).Trim() } catch { $null } - if ($value) { return $value } - } - } - return $null -} - -$supplyRoot = '/sys/class/power_supply' -if (-not (Test-Path $supplyRoot)) { - Write-Host "[!!] $supplyRoot not found -- this kernel exposes no power supplies.`n" -ForegroundColor Red - return -} - -$batteries = @(Get-ChildItem $supplyRoot -ErrorAction SilentlyContinue | - Where-Object { (Get-SysAttribute $_.FullName @('type')) -eq 'Battery' }) - -if (-not $batteries) { - Write-Host "[!] No battery detected -- this looks like a desktop system.`n" -ForegroundColor Yellow - return -} - -foreach ($bat in $batteries) { - $dir = $bat.FullName - - # Drivers report either energy (uWh) or charge (uAh); the health ratio holds - # for both as long as full and design come from the same pair. - $full = Get-SysAttribute $dir @('energy_full', 'charge_full') - $design = Get-SysAttribute $dir @('energy_full_design', 'charge_full_design') - $unit = if (Test-Path (Join-Path $dir 'energy_full')) { 'Wh' } else { 'Ah' } - $healthPct = if ($full -and $design -and [double]$design -gt 0) { - [Math]::Round(([double]$full / [double]$design) * 100, 1) - } else { $null } - - $cycles = Get-SysAttribute $dir @('cycle_count') - $now = Get-SysAttribute $dir @('energy_now', 'charge_now') - $power = Get-SysAttribute $dir @('power_now', 'current_now') - $voltage = Get-SysAttribute $dir @('voltage_now') - - # sysfs reports micro-units throughout. - $toUnit = { param($raw) if ($raw) { [Math]::Round([double]$raw / 1e6, 2) } else { 'N/A' } } - - [PSCustomObject]@{ - 'Battery' = $bat.Name - 'Manufacturer' = (Get-SysAttribute $dir @('manufacturer')) ?? 'N/A' - 'Model' = (Get-SysAttribute $dir @('model_name')) ?? 'N/A' - 'Technology' = (Get-SysAttribute $dir @('technology')) ?? 'N/A' - 'Status' = (Get-SysAttribute $dir @('status')) ?? 'N/A' - 'Charge' = ((Get-SysAttribute $dir @('capacity')) ?? 'N/A') + '%' - "Full ($unit)" = & $toUnit $full - "Design ($unit)" = & $toUnit $design - "Now ($unit)" = & $toUnit $now - 'Voltage (V)' = & $toUnit $voltage - 'Draw' = if ($power) { "$(& $toUnit $power) $(if ($unit -eq 'Wh') { 'W' } else { 'A' })" } else { 'N/A' } - 'Cycle Count' = $cycles ?? 'Not reported by driver' - 'Health' = if ($null -ne $healthPct) { "$healthPct%" } else { 'N/A' } - } | Format-List | Out-Host - - if ($null -ne $healthPct) { - $verdict, $colour = switch ($healthPct) { - { $_ -ge 80 } { 'Good -- the battery holds most of its design capacity.', 'Green'; break } - { $_ -ge 60 } { 'Worn -- noticeably reduced runtime.', 'Yellow'; break } - default { 'Poor -- consider replacing the battery.', 'Red' } - } - Write-Host " $verdict" -ForegroundColor $colour - } - if (-not $cycles) { - Write-Host ' [*] Many laptop batteries do not expose a cycle count to the kernel.' -ForegroundColor DarkGray - } - Write-Host '' -} diff --git a/src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 b/src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 deleted file mode 100644 index 8c380a0..0000000 --- a/src/Windows/CLI/tools/linux/Get-SystemLogs.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- View System Logs (Linux) -# Shows recent error/warning entries from the systemd journal. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host " System Logs (journalctl)" -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -if (-not (Get-Command journalctl -ErrorAction SilentlyContinue)) { - Write-Host "[!!] journalctl not found. This system may not use systemd.`n" -ForegroundColor Red - return -} - -Write-Host " [1] Errors from today" -Write-Host " [2] Last 100 error/warning entries" -Write-Host " [3] Boot messages (current boot)" -Write-Host " [4] Kernel messages (dmesg)" -Write-Host " [5] Failed services" -Write-Host " [B] Back`n" - -$choice = (Read-Host " Choice").Trim().ToUpper() - -switch ($choice) { - '1' { - Write-Host "`n[>>] Errors from today...`n" -ForegroundColor Yellow - & journalctl --priority=err --since=today --no-pager - } - '2' { - Write-Host "`n[>>] Last 100 error/warning entries...`n" -ForegroundColor Yellow - & journalctl --priority=warning -n 100 --no-pager - } - '3' { - Write-Host "`n[>>] Boot messages (current boot)...`n" -ForegroundColor Yellow - & journalctl -b --no-pager | tail -n 100 - } - '4' { - Write-Host "`n[>>] Kernel messages...`n" -ForegroundColor Yellow - & dmesg --level=err,warn 2>$null | tail -n 50 - } - '5' { - Write-Host "`n[>>] Failed systemd units...`n" -ForegroundColor Yellow - $failed = Get-PcCommandOutput 'systemctl' @('--failed', '--no-legend', '--no-pager') - if ($failed) { - Write-Host $failed - Write-Host "`n Inspect one with: journalctl -u -b" -ForegroundColor DarkGray - } else { - Write-Host ' No failed units.' -ForegroundColor Green - } - } - 'B' { return } - default { Write-Host "`n Invalid choice.`n" -ForegroundColor Red } -} - -Write-Host '' diff --git a/src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 b/src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 deleted file mode 100644 index 829d1b4..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-AudioRestart.ps1 +++ /dev/null @@ -1,63 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Restart Audio (Linux) -# Detects PipeWire or PulseAudio and restarts the relevant user services. -# The audio server lives in the user's session, not root's, so every call is -# dropped to the desktop user with their session bus forwarded. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Restart Audio' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -$user = Get-PcDesktopUser -if (-not $user) { - Write-Host "[!!] Could not determine the desktop user.`n" -ForegroundColor Red - return -} - -# systemctl and its arguments are passed as individual tokens -- no shell involved. -function Invoke-UserCommand { - param([string[]]$CommandLine) - return & sudo -u $user.Name env "DBUS_SESSION_BUS_ADDRESS=$($user.Dbus)" @CommandLine 2>&1 -} - -function Restart-UserUnit { - param([string]$Unit) - Write-Host "[>>] Restarting $Unit..." -ForegroundColor Yellow - $out = Invoke-UserCommand @('systemctl', '--user', 'restart', $Unit) - if ($LASTEXITCODE -eq 0) { - Write-Host "[OK] Done.`n" -ForegroundColor Green - } else { - Write-Host "[!!] Exit code $LASTEXITCODE.`n" -ForegroundColor Red - if ($out) { Write-Host " $out" -ForegroundColor DarkGray } - } -} - -# Exact match: `is-active` answers "inactive" too, which -match 'active' would accept. -$pipeWireState = "$(Invoke-UserCommand @('systemctl', '--user', 'is-active', 'pipewire'))".Trim() -$isPipeWire = $pipeWireState -eq 'active' -$hasPulse = [bool](Get-Command pulseaudio -ErrorAction SilentlyContinue) - -if ($isPipeWire) { - Write-Host " Detected: PipeWire`n" -ForegroundColor DarkGray - 'pipewire', 'pipewire-pulse', 'wireplumber' | ForEach-Object { Restart-UserUnit $_ } -} elseif ($hasPulse) { - Write-Host " Detected: PulseAudio`n" -ForegroundColor DarkGray - Write-Host '[>>] Restarting PulseAudio...' -ForegroundColor Yellow - # Kill then start as two invocations to avoid a shell compound command. - Invoke-UserCommand @('pulseaudio', '--kill') | Out-Null - Start-Sleep -Milliseconds 500 - $out = Invoke-UserCommand @('pulseaudio', '--start') - if ($LASTEXITCODE -eq 0) { - Write-Host "[OK] Done.`n" -ForegroundColor Green - } else { - Write-Host "[!!] Exit code $LASTEXITCODE.`n" -ForegroundColor Red - if ($out) { Write-Host " $out" -ForegroundColor DarkGray } - } -} else { - Write-Host "[!!] No supported audio server found (PipeWire or PulseAudio).`n" -ForegroundColor Red - return -} - -Write-Host " Audio services restarted.`n" -ForegroundColor Green diff --git a/src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 b/src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 deleted file mode 100644 index 6726eac..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-BootRepair.ps1 +++ /dev/null @@ -1,209 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Boot Repair (Linux, UEFI) -# Reinstalls the bootloader's EFI files. Supports systemd-boot, GRUB and Limine. -# -# UEFI only, deliberately. pcHealth's minimum is kernel 7.0, and repairing a -# legacy BIOS/MBR setup means writing raw boot code to the disk -- a different -# and far riskier operation than reinstalling an EFI binary onto the ESP. -# -# Every repair below runs the bootloader's own official command. pcHealth never -# writes boot sectors itself and never guesses which loader you use. -# ============================================================================ - -if (Get-Command Set-PcTheme -ErrorAction SilentlyContinue) { - Set-PcTheme 'Danger' - Clear-PcHost -} - -Write-Host "`n$('=' * 60)" -ForegroundColor Red -Write-Host ' Boot Repair (UEFI)' -ForegroundColor Red -Write-Host "$('=' * 60)`n" -ForegroundColor Red -Write-Host ' WARNING: This operation modifies boot-critical files.' -ForegroundColor Yellow -Write-Host ' Incorrect use can render the system unbootable.' -ForegroundColor Yellow -Write-Host " Only proceed if you understand what you are doing.`n" -ForegroundColor Yellow - -# -- Image-based guard --------------------------------------------------------- -# On ostree systems the bootloader entries are generated from the deployments -# (/boot/loader/entries). Reinstalling GRUB or systemd-boot by hand here fights -# whatever produced those entries; `bootc`/`rpm-ostree` own this, not pcHealth. -if (Test-PcImageBasedSystem) { - Write-Host '[!!] This is an image-based system (ostree).' -ForegroundColor Red - Write-Host ' Its bootloader is managed by the deployment, not by hand.' -ForegroundColor Yellow - Write-Host ' Roll back to a working deployment instead:' -ForegroundColor Yellow - Write-Host ' rpm-ostree status # list deployments' -ForegroundColor DarkGray - Write-Host ' rpm-ostree rollback # boot the previous one' -ForegroundColor DarkGray - Write-Host '' - return -} - -# -- Firmware guard ------------------------------------------------------------ -if (-not (Test-Path '/sys/firmware/efi')) { - Write-Host '[!!] This system booted in legacy BIOS mode (no /sys/firmware/efi).' -ForegroundColor Red - Write-Host ' pcHealth only repairs UEFI bootloaders.' -ForegroundColor Yellow - Write-Host '' - return -} - -# EFI binary name and GRUB target follow the firmware's bitness, not the CPU's: -# a 64-bit CPU can ship 32-bit UEFI firmware, and BOOTX64 will not boot there. -$machine = (Get-PcCommandOutput 'uname' @('-m')) ?? 'x86_64' -$fwBits = try { (Get-Content '/sys/firmware/efi/fw_platform_size' -Raw -ErrorAction Stop).Trim() } catch { '64' } -$efiName, $grubTarget = switch -Regex ($machine) { - '^aarch64|^arm64' { 'BOOTAA64.EFI', 'arm64-efi'; break } - default { if ($fwBits -eq '32') { 'BOOTIA32.EFI', 'i386-efi' } else { 'BOOTX64.EFI', 'x86_64-efi' } } -} - -# -- Locate the EFI System Partition ------------------------------------------- -# Only trust a mounted vfat partition. Mounting one ourselves would mean picking -# a candidate by guesswork, on the one filesystem where a wrong guess is fatal. -$esp = $null -foreach ($candidate in @('/efi', '/boot/efi', '/boot')) { - $fsType = Get-PcCommandOutput 'findmnt' @('-rno', 'FSTYPE', '--target', $candidate) - if ($fsType -eq 'vfat') { $esp = $candidate; break } -} - -if (-not $esp) { - Write-Host '[!!] No mounted EFI System Partition found at /efi, /boot/efi or /boot.' -ForegroundColor Red - Write-Host ' Mount it first, then run this tool again. Candidates:' -ForegroundColor Yellow - & lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,MOUNTPOINT 2>$null | - Where-Object { $_ -match 'EFI System|vfat|NAME' } | - ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - Write-Host '' - return -} - -Write-Host " Firmware: UEFI ($fwBits-bit, $machine)" -ForegroundColor DarkGray -Write-Host " ESP: $esp" -ForegroundColor DarkGray -Write-Host " EFI binary: $efiName`n" -ForegroundColor DarkGray - -# -- Detect which bootloaders are installed ------------------------------------ -$loaders = @() - -if ((Test-Path (Join-Path $esp 'EFI/systemd')) -or (Get-Command bootctl -ErrorAction SilentlyContinue)) { - $installed = Test-Path (Join-Path $esp 'EFI/systemd') - $loaders += [PSCustomObject]@{ - Name = 'systemd-boot' - Present = $installed - Commands = [string[][]]@(, @('bootctl', 'install', "--esp-path=$esp")) - } -} - -$grubCmd = @('grub-install', 'grub2-install') | - Where-Object { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue } | - Select-Object -First 1 -if ($grubCmd) { - # Fedora/RHEL name everything grub2-* and keep the config under /boot/grub2. - $mkconfig = if ($grubCmd -eq 'grub2-install') { 'grub2-mkconfig' } else { 'grub-mkconfig' } - $grubDir = if ($grubCmd -eq 'grub2-install') { '/boot/grub2' } else { '/boot/grub' } - $distroId = (Get-LinuxDistroInfo)['ID'] - $bootId = if ($distroId) { $distroId } else { 'linux' } - $loaders += [PSCustomObject]@{ - Name = 'GRUB' - Present = (Test-Path $grubDir) -or (Test-Path (Join-Path $esp 'EFI/grub')) - # Commas matter: newline-separated elements collapse into one flat list, - # which would make $cmd[0] a single character instead of the command. - Commands = [string[][]]@( - @($grubCmd, "--target=$grubTarget", "--efi-directory=$esp", "--bootloader-id=$bootId"), - @($mkconfig, '-o', (Join-Path $grubDir 'grub.cfg')) - ) - } -} - -# Limine has no upstream UEFI installer -- the documented procedure is to copy -# the EFI binary onto the ESP. Distros ship their own helper, so prefer that. -$limineHelper = @('limine-update', 'limine-install') | - Where-Object { Get-Command $_ -CommandType Application -ErrorAction SilentlyContinue } | - Select-Object -First 1 -$limineSource = Join-Path '/usr/share/limine' $efiName -if ($limineHelper -or (Test-Path $limineSource)) { - $limineCommands = if ($limineHelper) { - [string[][]]@(, @($limineHelper)) - } else { - # Never `limine bios-install` here: that writes an MBR stage and is - # documented as BIOS-only. - [string[][]]@( - @('mkdir', '-p', (Join-Path $esp 'EFI/BOOT')), - @('cp', $limineSource, (Join-Path $esp "EFI/BOOT/$efiName")) - ) - } - $loaders += [PSCustomObject]@{ - Name = 'Limine' - Present = (Test-Path (Join-Path $esp "EFI/BOOT/$efiName")) -or - (@('limine.conf', 'limine/limine.conf', 'boot/limine/limine.conf') | - Where-Object { Test-Path (Join-Path $esp $_) }).Count -gt 0 - Commands = $limineCommands - } -} - -if (-not $loaders) { - Write-Host '[!!] No supported bootloader found (systemd-boot, GRUB or Limine).' -ForegroundColor Red - Write-Host ' Install your bootloader''s package first, then run this tool again.' -ForegroundColor Yellow - Write-Host '' - return -} - -# -- Choose ------------------------------------------------------------------- -Write-Host ' Detected bootloaders:' -ForegroundColor Cyan -for ($i = 0; $i -lt $loaders.Count; $i++) { - $state = if ($loaders[$i].Present) { 'installed on this ESP' } else { 'tooling present, not installed here' } - Write-PcOption "$($i + 1)" $loaders[$i].Name "($state)" -} -Write-PcOption 'B' 'Cancel' -Write-Host '' - -$choice = (Read-Host ' Which bootloader should be repaired?').Trim() -if ($choice -eq 'B' -or $choice -eq 'b') { - Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray - return -} -$index = 0 -if (-not [int]::TryParse($choice, [ref]$index) -or $index -lt 1 -or $index -gt $loaders.Count) { - Write-Host "`n Invalid choice.`n" -ForegroundColor Red - return -} -$loader = $loaders[$index - 1] - -# -- Confirm, showing exactly what will run ------------------------------------ -Write-Host "`n These commands will run as root:`n" -ForegroundColor Yellow -foreach ($cmd in $loader.Commands) { Write-Host " $($cmd -join ' ')" -ForegroundColor White } -Write-Host '' - -$confirm1 = (Read-Host " Type 'yes' to continue or anything else to cancel").Trim().ToLower() -if ($confirm1 -ne 'yes') { - Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray - return -} -$confirm2 = (Read-Host " Last chance -- type 'CONFIRM' in capitals to proceed").Trim() -if ($confirm2 -ne 'CONFIRM') { - Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray - return -} - -# -- Repair -------------------------------------------------------------------- -Write-Host '' -foreach ($cmd in $loader.Commands) { - Write-Host "[>>] $($cmd -join ' ')" -ForegroundColor Yellow - & $cmd[0] @($cmd[1..($cmd.Count - 1)]) 2>&1 | - ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - if ($LASTEXITCODE -ne 0) { - Write-Host "`n[!!] Failed with exit code $LASTEXITCODE -- stopping here." -ForegroundColor Red - Write-Host ' The system may still boot from its existing entry. Do not reboot' -ForegroundColor Yellow - Write-Host ' until you have resolved this, and keep a live USB to hand.' -ForegroundColor Yellow - Write-Host '' - return - } -} - -Write-Host "[OK] $($loader.Name) reinstalled on $esp.`n" -ForegroundColor Green - -# efibootmgr lets the user confirm the firmware entry exists before rebooting -- -# a copied EFI binary with no boot entry still leaves an unbootable machine. -if (Get-Command efibootmgr -ErrorAction SilentlyContinue) { - Write-Host ' Current firmware boot entries:' -ForegroundColor Cyan - & efibootmgr 2>$null | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - Write-Host '' -} - -Write-Host ' Verify the entry above before rebooting.' -ForegroundColor Yellow -Write-Host '' diff --git a/src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 b/src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 deleted file mode 100644 index 4c69e83..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-DiskCleanup.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Disk Cleanup (Linux) -# Cleans package caches, trims old journal logs, removes unused Flatpak -# runtimes, and clears the thumbnail cache. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Disk Cleanup' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -$osRelease = Get-LinuxDistroInfo -$distroId = $osRelease['ID'] -$distroLike = $osRelease['ID_LIKE'] - -Write-Host " Distro: $($osRelease['PRETTY_NAME'])`n" -ForegroundColor DarkGray - -function Invoke-Cleanup { - param([string]$Label, [scriptblock]$Action) - Write-Host "[>>] $Label" -ForegroundColor Yellow - # Reset first: a step that runs no native command would otherwise be judged - # by whichever exit code was left behind by the previous one. - $global:LASTEXITCODE = 0 - & $Action 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - if ($LASTEXITCODE -eq 0) { - Write-Host "[OK] Done.`n" -ForegroundColor Green - } else { - Write-Host "[--] Exit code $LASTEXITCODE (may be non-fatal).`n" -ForegroundColor DarkGray - } -} - -# ── Package cache ───────────────────────────────────────────────────────────── - -$archIds = @('arch', 'cachyos', 'garuda', 'manjaro', 'endeavouros', 'artix') - -if ($distroId -in $archIds -or $distroLike -match 'arch') { - if (Get-Command paccache -ErrorAction SilentlyContinue) { - Invoke-Cleanup 'Clearing pacman cache (keeping last 2 versions)...' { paccache -rk2 } - } - # Only remove orphans when there is actually something to remove; - # passing an empty list to pacman -Rns causes a non-zero exit and confuses users. - $orphans = @(& pacman -Qdtq 2>$null) - if ($orphans.Count -gt 0) { - Invoke-Cleanup 'Removing unneeded pacman dependencies...' { pacman -Rns $orphans --noconfirm } - } else { - Write-Host "[--] No unneeded pacman dependencies found, skipping.`n" -ForegroundColor DarkGray - } -} elseif ($distroId -in @('ubuntu', 'debian', 'linuxmint', 'pop', 'elementary', 'zorin', 'kali') -or $distroLike -match 'debian|ubuntu') { - Invoke-Cleanup 'Removing unneeded apt packages...' { apt autoremove -y } - Invoke-Cleanup 'Cleaning apt cache...' { apt autoclean } -} elseif ($distroId -in @('fedora', 'rhel', 'centos', 'almalinux', 'rocky') -or $distroLike -match 'fedora|rhel') { - Invoke-Cleanup 'Removing unneeded dnf packages...' { dnf autoremove -y } - Invoke-Cleanup 'Cleaning dnf cache...' { dnf clean all } -} elseif ($distroId -in @('opensuse-leap', 'opensuse-tumbleweed', 'sles') -or $distroLike -match 'suse') { - Invoke-Cleanup 'Cleaning zypper cache...' { zypper clean --all } -} else { - Write-Host "[--] Package cache: distro not recognised, skipping.`n" -ForegroundColor DarkGray -} - -# ── Journal logs ────────────────────────────────────────────────────────────── - -if (Get-Command journalctl -ErrorAction SilentlyContinue) { - Invoke-Cleanup 'Vacuuming journal logs (keeping last 7 days)...' { - journalctl --vacuum-time=7d - } -} - -# ── Flatpak unused runtimes ─────────────────────────────────────────────────── - -if (Get-Command flatpak -ErrorAction SilentlyContinue) { - Invoke-Cleanup 'Removing unused Flatpak runtimes...' { flatpak uninstall --unused -y } -} - -# ── Thumbnail cache ─────────────────────────────────────────────────────────── - -# Under $env:HOME is root's, so resolve the desktop user's cache instead. -$userHome = (Get-PcDesktopUser)?.Home -$thumbDir = if ($userHome) { Join-Path $userHome '.cache/thumbnails' } else { $null } -if ($thumbDir -and (Test-Path $thumbDir)) { - $sizeMB = [math]::Round( - (Get-ChildItem $thumbDir -Recurse -File -ErrorAction SilentlyContinue | - Measure-Object -Property Length -Sum).Sum / 1MB, 1) - Invoke-Cleanup "Clearing thumbnail cache ($sizeMB MB)..." { - Get-ChildItem $thumbDir -Recurse -File -ErrorAction SilentlyContinue | - Remove-Item -Force -ErrorAction SilentlyContinue - } -} - -Write-Host " Disk cleanup complete.`n" -ForegroundColor Green diff --git a/src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 b/src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 deleted file mode 100644 index 9aa209c..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-DiskOptimize.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Disk Optimization (Linux) -# Counterpart to dfrgui.exe on Windows. Linux filesystems do not need -# defragmenting, so the useful half of that job is discarding unused blocks -# on SSDs, which is what fstrim does. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Disk Optimization' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -# rotational = 1 means spinning rust: nothing to trim, and ext4/btrfs/xfs do not -# fragment the way NTFS does, so there is nothing to defragment either. -$disks = @(Get-ChildItem '/sys/block' -ErrorAction SilentlyContinue | - Where-Object { $_.Name -notmatch '^(loop|ram|zram|sr)' } | - ForEach-Object { - $rotational = try { (Get-Content (Join-Path $_.FullName 'queue/rotational') -Raw -ErrorAction Stop).Trim() } catch { $null } - [PSCustomObject]@{ - Disk = $_.Name - Type = switch ($rotational) { '0' { 'SSD / NVMe' } '1' { 'HDD' } default { 'Unknown' } } - } - }) - -if ($disks) { - $disks | Format-Table -AutoSize | Out-Host - if ($disks.Type -notcontains 'SSD / NVMe') { - Write-Host " No solid-state device detected -- there is nothing to trim." -ForegroundColor Yellow - Write-Host " Linux filesystems do not need defragmenting.`n" -ForegroundColor DarkGray - return - } -} - -if (-not (Get-Command fstrim -ErrorAction SilentlyContinue)) { - Write-Host "[!!] fstrim not found. Install util-linux.`n" -ForegroundColor Red - return -} - -# Many distros already run fstrim.timer weekly; say so rather than implying -# the manual run was necessary. -$timer = Get-PcCommandOutput 'systemctl' @('is-enabled', 'fstrim.timer') -if ($timer -eq 'enabled') { - Write-Host " Note: fstrim.timer is enabled, so this already runs weekly.`n" -ForegroundColor DarkGray -} - -Write-Host "[>>] Trimming all mounted filesystems that support it..." -ForegroundColor Yellow -Write-Host " This can take a minute on a large or nearly full disk.`n" -ForegroundColor DarkGray - -# --all walks every mounted filesystem; --verbose reports bytes freed per mount. -& fstrim --all --verbose 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - -if ($LASTEXITCODE -eq 0) { - Write-Host "`n[OK] Trim complete.`n" -ForegroundColor Green -} else { - Write-Host "`n[!!] fstrim exited with code $LASTEXITCODE.`n" -ForegroundColor Red -} diff --git a/src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 b/src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 deleted file mode 100644 index e8f9606..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 +++ /dev/null @@ -1,78 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Firmware Update (Linux) -# Counterpart to HP Image Assistant on Windows, but vendor-neutral: fwupd -# ships BIOS, dock, SSD and peripheral firmware from LVFS for most vendors. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Firmware Update (fwupd / LVFS)' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -if (-not (Get-Command fwupdmgr -ErrorAction SilentlyContinue)) { - Write-Host '[!!] fwupdmgr is not installed.' -ForegroundColor Red - Write-Host '' - Write-Host ' Install it with your package manager:' -ForegroundColor DarkGray - Write-Host ' Debian / Ubuntu: apt install fwupd' -ForegroundColor DarkGray - Write-Host ' Fedora / RHEL: dnf install fwupd' -ForegroundColor DarkGray - Write-Host ' Arch / CachyOS: pacman -S fwupd' -ForegroundColor DarkGray - Write-Host ' openSUSE: zypper install fwupd' -ForegroundColor DarkGray - Write-Host '' - return -} - -Write-Host '[>>] Refreshing firmware metadata from LVFS...' -ForegroundColor Yellow -# --force refreshes even when the cached metadata is still considered fresh. -$refresh = & fwupdmgr refresh --force 2>&1 | ForEach-Object { "$_" } -$refresh | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -# Without fresh metadata the verdict below reflects whatever was cached, which -# may be months old -- say so rather than reporting a confident "up to date". -$staleMetadata = [bool]($refresh -match 'Failed to download|transient failure|Failed to connect') - -Write-Host "`n[>>] Checking for firmware updates...`n" -ForegroundColor Yellow -$updates = & fwupdmgr get-updates 2>&1 | ForEach-Object { "$_" } - -# fwupd is a daemon; the CLI still exits having printed nothing useful when it -# is masked or not running. Never fall through to the install prompt on that -- -# an empty update list must not become an invitation to flash firmware. -if ($updates -match 'Failed to connect to daemon|Failed to load daemon|could not be activated') { - Write-Host '[!!] Could not reach the fwupd daemon.' -ForegroundColor Red - Write-Host ' Start it with: systemctl start fwupd' -ForegroundColor DarkGray - Write-Host '' - return -} - -# fwupdmgr exits non-zero when there is simply nothing to do, so read the text. -if (-not $updates -or - $updates -match 'No updatable devices|No updates available|Devices with no available firmware updates') { - if ($staleMetadata) { - Write-Host '[!] No updates found, but the LVFS metadata could not be refreshed.' -ForegroundColor Yellow - Write-Host " This answer is based on cached data -- check again once you are online.`n" -ForegroundColor DarkGray - } else { - Write-Host "[OK] All firmware is up to date.`n" -ForegroundColor Green - } - return -} - -$updates | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - -Write-Host '' -Write-Host ' [!] Firmware updates carry real risk. Do not power the machine off' -ForegroundColor Yellow -Write-Host ' while one is running, and plug in the charger on a laptop.' -ForegroundColor Yellow -Write-Host '' - -$confirm = (Read-Host ' Install these firmware updates? (y/n)').Trim().ToLower() -if ($confirm -ne 'y') { - Write-Host "`n Cancelled.`n" -ForegroundColor DarkGray - return -} - -Write-Host "`n[>>] Installing firmware updates...`n" -ForegroundColor Yellow -& fwupdmgr update - -if ($LASTEXITCODE -eq 0) { - Write-Host "`n[OK] Firmware update complete." -ForegroundColor Green - Write-Host " Some devices only apply the update on the next reboot.`n" -ForegroundColor DarkGray -} else { - Write-Host "`n[!!] fwupdmgr exited with code $LASTEXITCODE.`n" -ForegroundColor Red -} diff --git a/src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 b/src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 deleted file mode 100644 index 1b065bd..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-NetworkReset.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Network Reset (Linux) -# Restarts NetworkManager (or systemd-networkd) and flushes the DNS cache. -# Note: this briefly drops the network connection. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Network Reset' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -if (-not (Get-Command systemctl -ErrorAction SilentlyContinue)) { - Write-Host "[!!] systemctl not found. This system may not use systemd.`n" -ForegroundColor Red - return -} - -function Invoke-Step { - param([string]$Label, [scriptblock]$Action) - Write-Host "[>>] $Label" -ForegroundColor Yellow - $out = & $Action 2>&1 - if ($LASTEXITCODE -eq 0) { - Write-Host "[OK] Done.`n" -ForegroundColor Green - } else { - Write-Host "[!!] Exit code $LASTEXITCODE.`n" -ForegroundColor Red - if ($out) { $out | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } } - } -} - -Write-Host " Note: the network connection will drop briefly.`n" -ForegroundColor DarkGray - -$nmActive = (Get-PcCommandOutput 'systemctl' @('is-active', 'NetworkManager')) -eq 'active' - -if ($nmActive -or (Get-Command nmcli -ErrorAction SilentlyContinue)) { - Invoke-Step 'Restarting NetworkManager...' { systemctl restart NetworkManager } -} else { - Invoke-Step 'Restarting systemd-networkd...' { systemctl restart systemd-networkd } -} - -if (Get-Command resolvectl -ErrorAction SilentlyContinue) { - Invoke-Step 'Flushing DNS cache...' { resolvectl flush-caches } -} elseif (Get-Command systemd-resolve -ErrorAction SilentlyContinue) { - Invoke-Step 'Flushing DNS cache...' { systemd-resolve --flush-caches } -} - -Write-Host " Network reset complete.`n" -ForegroundColor Green diff --git a/src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 b/src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 deleted file mode 100644 index bba8f31..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-ScanAndRepair.ps1 +++ /dev/null @@ -1,73 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Scan + Repair (Linux) -# Counterpart to SFC + DISM. SFC compares system files against the component -# store; the package database is the same idea, so verify against that and -# reinstall whatever no longer matches. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Scan + Repair (package integrity)' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -$pm = Get-PcPackageManager -if (-not $pm) { - Write-Host "[!!] No supported package manager found (apt/dnf/pacman/zypper).`n" -ForegroundColor Red - return -} - -$verifyCmd = $pm.Verify[0] -if (-not (Get-Command $verifyCmd -ErrorAction SilentlyContinue)) { - Write-Host "[!!] $verifyCmd is not installed -- it does the checking, not $($pm.Cmd) itself." -ForegroundColor Red - Write-Host " Install it with: $($pm.Cmd) $($pm.Install -join ' ') $verifyCmd`n" -ForegroundColor DarkGray - return -} - -# -- Filesystem errors --------------------------------------------------------- -# Read-only: fsck cannot safely touch a mounted root, so report what the kernel -# has already seen and let the user schedule a repair from a live image. -Write-Host '[>>] Step 1/2 -- Checking the kernel log for filesystem errors...' -ForegroundColor Yellow -$fsErrors = @(& dmesg --level=err,warn 2>$null | - Where-Object { $_ -match 'EXT4-fs error|XFS.*Corruption|BTRFS error|I/O error|filesystem.*read-only' }) - -if ($fsErrors) { - Write-Host "[!!] The kernel has logged filesystem errors:`n" -ForegroundColor Red - $fsErrors | Select-Object -Last 10 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } - Write-Host "`n Run fsck from a live image -- it cannot repair a mounted root.`n" -ForegroundColor Yellow -} else { - Write-Host "[OK] No filesystem errors in the kernel log.`n" -ForegroundColor Green -} - -# -- Package integrity --------------------------------------------------------- -Write-Host "[>>] Step 2/2 -- Verifying installed packages with $verifyCmd..." -ForegroundColor Yellow -Write-Host ' This reads every packaged file and takes several minutes.' -ForegroundColor DarkGray - -$confirm = (Read-Host "`n Start the verification? (y/n)").Trim().ToLower() -if ($confirm -ne 'y') { - Write-Host "`n Skipped.`n" -ForegroundColor DarkGray - return -} - -Write-Host '' -$verifyArgs = @($pm.Verify[1..($pm.Verify.Count - 1)]) -# Merge stderr: debsums reports every changed file there, so discarding it would -# turn a corrupted system into a clean bill of health. Empty stderr lines -# stringify to the ErrorRecord type name, so drop those. -$findings = @(& $verifyCmd @verifyArgs 2>&1 | - ForEach-Object { "$_".Trim() } | - Where-Object { $_ -and $_ -ne 'System.Management.Automation.RemoteException' }) - -if (-not $findings) { - Write-Host "[OK] Every packaged file matches the package database.`n" -ForegroundColor Green - return -} - -Write-Host "[!!] $($findings.Count) file(s) no longer match their package:`n" -ForegroundColor Red -$findings | Select-Object -First 20 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -if ($findings.Count -gt 20) { - Write-Host " ... and $($findings.Count - 20) more" -ForegroundColor DarkGray -} - -Write-Host '' -Write-Host ' Config files you edited yourself show up here too -- that is expected.' -ForegroundColor DarkGray -Write-Host " Repair a package with: $($pm.Cmd) $($pm.Install -join ' ') --reinstall `n" -ForegroundColor DarkGray diff --git a/src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 b/src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 deleted file mode 100644 index 7551828..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-SystemUpdate.ps1 +++ /dev/null @@ -1,71 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Update all packages (Linux) -# Distro-native counterpart to the winget update on Windows. Unlike Topgrade -# this needs nothing installed beyond the package manager the distro ships. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Update all packages' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -$pm = Get-PcPackageManager -if (-not $pm) { - Write-Host "[!!] No supported package manager found (apt/dnf/pacman/zypper).`n" -ForegroundColor Red - return -} - -Write-Host " Package manager: $($pm.Cmd)`n" -ForegroundColor DarkGray - -if ($pm.Refresh) { - Write-Host '[>>] Refreshing package index...' -ForegroundColor Yellow - & $pm.Cmd @($pm.Refresh) 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { - Write-Host "[!!] Refresh failed (exit code $LASTEXITCODE). Check your network connection.`n" -ForegroundColor Red - return - } -} - -Write-Host "[>>] Checking for available updates...`n" -ForegroundColor Yellow -# dnf check-update exits 100 when updates exist and 0 when there are none; -# pacman -Qu exits 1 on an empty list. Judge by output, not exit code. -# Discard stderr: every manager writes banners and progress there, and merged -# ErrorRecords stringify to their type name rather than their text. -$lines = @(& $pm.Cmd @($pm.List) 2>$null | - ForEach-Object { "$_".Trim() } | - Where-Object { $_ -and $_ -notmatch '^(Listing|Last metadata)' }) - -if (-not $lines) { - Write-Host "[OK] Everything is already up to date.`n" -ForegroundColor Green - return -} - -# A full-distro upgrade can list hundreds of packages; show enough to judge by. -$preview = 15 -$lines | Select-Object -First $preview | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray } -if ($lines.Count -gt $preview) { - Write-Host " ... and $($lines.Count - $preview) more" -ForegroundColor DarkGray -} -Write-Host "`n $($lines.Count) update(s) available.`n" -ForegroundColor Cyan - -$confirm = (Read-Host ' Proceed with updating all packages? (y/n)').Trim().ToLower() -if ($confirm -ne 'y') { - Write-Host "`n Update cancelled.`n" -ForegroundColor DarkGray - return -} - -Write-Host "`n[>>] Updating all packages...`n" -ForegroundColor Yellow -& $pm.Cmd @($pm.Update) - -if ($LASTEXITCODE -eq 0) { - Write-Host "`n[OK] Update complete.`n" -ForegroundColor Green - # Kernel and glibc updates only take effect after a restart. - if (Get-Command needs-restarting -ErrorAction SilentlyContinue) { - & needs-restarting -r 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { Write-Host " [!] A reboot is required to finish this update.`n" -ForegroundColor Yellow } - } elseif (Test-Path '/var/run/reboot-required') { - Write-Host " [!] A reboot is required to finish this update.`n" -ForegroundColor Yellow - } -} else { - Write-Host "`n[!!] Update exited with code $LASTEXITCODE.`n" -ForegroundColor Red -} diff --git a/src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 b/src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 deleted file mode 100644 index 64a59c5..0000000 --- a/src/Windows/CLI/tools/linux/Invoke-Topgrade.ps1 +++ /dev/null @@ -1,66 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Topgrade (Linux) -# Runs topgrade to update all managed software in one pass. -# topgrade is interactive (pacnew prompts, etc.) so it opens in a new terminal. -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host ' Topgrade -- Full System Upgrade' -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -if (-not (Get-Command topgrade -ErrorAction SilentlyContinue)) { - Write-Host '[!!] topgrade is not installed.' -ForegroundColor Red - Write-Host '' - Write-Host ' Install it with your package manager:' -ForegroundColor DarkGray - Write-Host ' Arch / CachyOS / Manjaro: sudo pacman -S topgrade' -ForegroundColor DarkGray - Write-Host ' Debian / Ubuntu / Fedora: cargo install topgrade' -ForegroundColor DarkGray - Write-Host '' - return -} - -$user = Get-PcDesktopUser -if (-not $user) { - Write-Host "[!!] Could not determine the desktop user.`n" -ForegroundColor Red - return -} - -Write-Host ' topgrade will upgrade:' -ForegroundColor DarkGray -Write-Host ' packages, flatpak, VS Code extensions, uv tools,' -ForegroundColor DarkGray -Write-Host ' gcloud, helm, firmware, and more.' -ForegroundColor DarkGray -Write-Host '' - -# Reconstruct the session environment so GNOME Shell extensions and -# session-aware tools (gcloud, gdbus) work when topgrade is spawned from a sudo -# context that doesn't inherit the user's graphical session. -# Each value is a separate argv token for `env` rather than text spliced into a -# shell command, so a hostile DISPLAY cannot become an extra command. -$sessionEnv = @( - "DBUS_SESSION_BUS_ADDRESS=$($user.Dbus)" - "WAYLAND_DISPLAY=$($env:WAYLAND_DISPLAY ?? 'wayland-0')" - "DISPLAY=$($env:DISPLAY ?? ':0')" -) -# Fixed literal -- the shell is only here to hold the window open afterwards. -$runCmd = @('sudo', '-u', $user.Name, 'env') + $sessionEnv + - @('bash', '-c', 'topgrade; echo; read -r -p "Press Enter to close..."') - -$terminals = [ordered]@{ - 'gnome-terminal' = @('--wait', '--') - 'konsole' = @('--hold', '-e') - 'alacritty' = @('-e') - 'kitty' = @() - 'xfce4-terminal' = @('--hold', '-e') - 'xterm' = @('-hold', '-e') -} - -foreach ($term in $terminals.Keys) { - if (Get-Command $term -ErrorAction SilentlyContinue) { - Write-Host "[>>] Opening topgrade in $term..." -ForegroundColor Yellow - & $term @($terminals[$term] + $runCmd) - return - } -} - -Write-Host '[!!] No supported terminal emulator found.' -ForegroundColor Red -Write-Host ' Install one of: gnome-terminal, konsole, alacritty, kitty, xterm' -ForegroundColor DarkGray -Write-Host '' From 1095e6b7c55bdbb7a100d25b9e821d1bdf8e541f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:38:38 +0000 Subject: [PATCH 10/43] docs: document the windows/linux split Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- AGENTS.md | 54 ++++++++++++++++++++--------------- Documentation/changelog.md | 13 +++++++++ README.md | 58 +++++++++++++++++++++++++++++++++----- 3 files changed, 95 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a158d42..094ab8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,14 +8,22 @@ ## Project Structure -This project has **two separate codebases**. Know which one you're in: +This project has **three separate codebases**. Know which one you're in: | Part | Location | Language | Purpose | |---|---|---|---| -| CLI | `src/Windows/CLI/` | PowerShell 7 | Cross-platform terminal health tool | -| GUI | `src/Windows/GUI/pcHealth/` | C# / WinUI 3 (.NET) | Windows-only graphical frontend | +| Windows CLI | `src/Windows/CLI/` | PowerShell 7 | Windows terminal health tool | +| Windows GUI | `src/Windows/GUI/pcHealth/` | C# / WinUI 3 (.NET) | Windows-only graphical frontend | +| Linux app | `src/Linux/pchealth/` | Python 3.11+ / GTK4 | Linux terminal menu and desktop app | -Do not mix patterns between them. C# APIs do not belong in PowerShell scripts and vice versa. +Do not mix patterns between them. C# APIs do not belong in PowerShell scripts, and neither belongs in the Python package. + +The tool list is shared: `assets/tools.json` is read by both stacks. A new tool +is added there first, then implemented on each side that should have it. + +**Each side owns its platform completely.** There are no `$IsLinux` branches in +the PowerShell any more, and no Windows paths in the Python. A Linux tool +belongs in `src/Linux/pchealth/tools/`, never in `src/Windows/`. --- @@ -86,16 +94,22 @@ foreach (var instance in session.QueryInstances( | String concatenation for paths (`"$dir\$file"`) | `Join-Path $dir $file` | Handles both `\` and `/` correctly on Windows and Linux | | Bare `ls`, `cat`, `cp` aliases | `Get-ChildItem`, `Get-Content`, `Copy-Item` | Aliases are unreliable in strict or non-interactive environments | | `(& somecmd args).Trim()` | `Get-PcCommandOutput 'somecmd' @('args')` | A missing or silent command returns `$null`, and `.Trim()` on it throws — which aborts the whole tool, not just that field. On Linux this is routine: no systemd in containers and WSL, no `mokutil`/`lspci` on minimal installs | -| `sudo ` inside a tool | Call the command directly | pcHealth already exits unless it is running as root on Linux. Re-elevating is a no-op where sudo exists and a hard failure where it does not. `sudo -u ` to *drop* privileges is still correct | -| `$env:USER` / `$env:HOME` on Linux | `Get-PcDesktopUser` | Under `sudo pwsh` both describe root, not the person at the keyboard | +| `$IsLinux` branches | Nothing -- the Windows CLI is Windows-only | Linux is `src/Linux/`, in Python. A platform branch here means the tool is in the wrong stack | -### Bash (CLI Linux — `src/CLI/start.sh`) +### Python (Linux app — `src/Linux/pchealth/`) -| Avoid | Prefer | Why | +| Deprecated / Avoid | Preferred | Why | |---|---|---| -| Unquoted variables (`$VAR`) | Quoted (`"$VAR"`) | Breaks on paths with spaces | -| `ls` in scripts | `find` or explicit glob | `ls` output is not reliably parseable | -| `[ ]` (single bracket) | `[[ ]]` (double bracket) | Double bracket is safer and supports regex | +| `subprocess.run(..., shell=True)` | An argv list, no shell | A shell turns any interpolated value into possible code. Every call in `system.py` passes a list | +| `os.system`, backticks, `shell=True` pipelines | `system.run` / `system.stream` | They centralise the missing-command, timeout and encoding handling | +| Bare `subprocess` calls in a tool | `system.run`, `system.output`, `system.stream` | A missing binary is the normal case on Linux, not an edge case; these return instead of raising | +| `os.geteuid() == 0` checks scattered in tools | `system.run_root` / `system.elevated` | Privilege is raised per action via pkexec so the GUI never runs as root | +| `print()` inside a tool | `ctx.line(...)` / `ctx.emit(...)` | A tool must not know whether it is in a terminal or in GTK | +| `$HOME` / `os.environ["USER"]` | `system.desktop_user()` | Under sudo or pkexec both describe root, not the person at the keyboard | +| Touching GTK from a worker thread | `GLib.idle_add` | GTK may only be called from the main loop | + +Run `python3 -m ruff check .`, `python3 -m ruff format --check .` and +`python3 -m mypy pchealth` from `src/Linux/` before committing. CI runs all three. --- @@ -103,19 +117,13 @@ foreach (var instance in session.QueryInstances( Both CLI and C# code must guard platform-specific calls: -**PowerShell:** -```powershell -if ($IsWindows) { Get-CimInstance Win32_Processor } -if ($IsLinux) { & lscpu } -``` - -**C#:** -```csharp -if (OperatingSystem.IsWindows()) { /* registry, CIM, WinUI */ } -``` +The Windows CLI and the WinUI GUI are Windows-only, so CIM, the registry and +`Get-PnpDevice` need no platform guard there -- but they still need error +handling, because a key or a class can be missing on any given machine. -Never call `Get-CimInstance`, registry reads, `Get-PnpDevice`, or WinUI APIs -without a platform guard. The CLI runs on Linux too. +The Python side guards differently: a missing command is the normal case, so +everything goes through `system.run`, `system.output` or `system.stream`, which +return instead of raising. --- diff --git a/Documentation/changelog.md b/Documentation/changelog.md index d90a7c8..f44f61f 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -1,5 +1,18 @@ # Changelog.md - pcHealth +## 17-09-2026 (2) - @Stensel8 + +Split the codebase into a Windows stack and a Linux stack. + +- `src/CLI` and `src/GUI` moved to `src/Windows/CLI` and `src/Windows/GUI`. Windows keeps PowerShell 7 plus WinUI 3. +- **New: `src/Linux/`** -- the Linux app in Python 3.11+, with a terminal menu and a GTK4 / libadwaita desktop app. All 18 Linux tools ported: system and hardware info, battery, journal logs, ping, traceroute, network reset, audio restart, disk cleanup and trim, scan + repair, package updates, topgrade, firmware and boot repair. +- Reason for Python: PowerShell 7 is not installed on a Linux machine until someone installs it, which is a poor first step for a tool you reach for because something is already broken. Python 3 ships with every distro pcHealth targets. +- **New: `assets/tools.json`** -- one tool catalogue that both stacks read, so the menus cannot drift apart. CI fails if the catalogue lists a tool the Python registry cannot run. +- Neither Linux front-end runs as root. Privilege is raised per action through `pkexec` (falling back to `sudo`), because a root process cannot reach the user's Wayland session and a root-owned toolkit is a bad idea regardless. +- The PowerShell CLI is now Windows-only: `tools/linux/` and every `$IsLinux` branch are gone, along with the helpers that only served them (`Get-PcDesktopUser`, `Get-PcPackageManager`, `Get-LinuxDistroInfo`, `Test-PcImageBasedSystem`, `Get-PcCommandOutput`). The originals stay in git history. +- Fixed: the `VERSION` lookup in `app.ps1` still pointed two directories up after the move, which resolved to `src/` instead of the repo root. +- CI gained a `python-lint` job (ruff, ruff format, mypy) and a catalogue/registry consistency check. + ## 17-09-2026 - @Stensel8 Support floors lowered so older devices are usable again — Windows 10 22H2 and Linux kernel 6.0. diff --git a/README.md b/README.md index 5deba62..0f56381 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,32 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. --- +## Project layout + +The two platforms have separate stacks, because neither one can cross over: +WinUI 3 does not run on Linux, and PowerShell 7 is not installed on a Linux +machine until someone installs it -- a poor first step for a tool you reach for +*because* something is broken. + +| Path | Stack | Covers | +|------|-------|--------| +| `src/Windows/CLI/` | PowerShell 7 | Windows terminal tools | +| `src/Windows/GUI/` | C# / WinUI 3 | Windows desktop app | +| `src/Linux/` | Python 3.11+ / GTK4 + libadwaita | Linux terminal menu and desktop app | +| `assets/tools.json` | -- | Shared tool catalogue both stacks read, so the menus cannot drift apart | + +Each side owns its platform completely: no `$IsLinux` branches in the +PowerShell, no Windows paths in the Python. Every tool the PowerShell CLI used +to run on Linux is now a Python tool -- all 18 of them, same names, same +behaviour, and the originals remain in this repository's git history. + +Adding a tool to Linux means three things: an entry in `assets/tools.json`, a +function in `src/Linux/pchealth/tools/`, and a line in that package's registry. +CI fails if the catalogue lists a tool the registry cannot run. See +[src/Linux/README.md](src/Linux/README.md). + +--- + ## Getting Started **Requirements:** PowerShell 7+, run as Administrator (Windows) or root/sudo (Linux). Minimum: Windows build 19045 (10 22H2) or Linux kernel 6.0. Build 26200 (11 25H2) is what releases are tested on. @@ -57,15 +83,19 @@ See [SECURITY.md](SECURITY.md) for version and end-of-life details. ### Linux -**Requirements:** PowerShell 7 must be installed first (the launcher is a `.ps1` file — there is no bash wrapper). Install it via your package manager, e.g. `sudo pacman -S powershell` on Arch/CachyOS or see [aka.ms/powershell](https://aka.ms/powershell) for other distros. +**Requirements:** Python 3.11+, which every supported distro already ships. Nothing else for the terminal app; the desktop app additionally needs PyGObject, GTK 4 and libadwaita. 1. Download or clone this repository. -2. Run `Start.ps1` elevated: +2. Run it from `src/Linux`: ```bash -sudo pwsh src/Windows/CLI/Start.ps1 +cd src/Linux +python3 -m pchealth # terminal menu +python3 -m pchealth.gui.app # desktop app ``` +Tools elevate one at a time through `pkexec`, so neither front-end needs to run as root. See [src/Linux/README.md](src/Linux/README.md). + ### GUI On Windows, pcHealth includes a native desktop application built with **WinUI 3** (.NET 10). It provides the same functionality as the CLI in a graphical interface. Minimum: build 19045 (Windows 10 22H2) — the build where WinUI 3 stops rendering. Recommended: build 26200 (Windows 11 25H2). @@ -73,7 +103,7 @@ On Windows, pcHealth includes a native desktop application built with **WinUI 3* ![Tools tab](Tools-tab.avif) ![Programs tab](Programs-tab.avif) -A Linux GUI is not yet available - WinUI 3 is Windows-only. A cross-platform alternative is in the works. +A Linux GUI is available separately -- WinUI 3 is Windows-only, so the Linux desktop app is built with GTK4 and libadwaita. See [Project layout](#project-layout). **Build dependencies:** @@ -185,10 +215,11 @@ Installed packages are marked `[installed]` in the menu. ## Contributing -Contributions are welcome. Follow the existing naming conventions: `Verb-Noun.ps1` for tools, consistent `Write-PcOption` / `Set-PcTheme` calls for UI. +Contributions are welcome. Follow the conventions of the stack you are in. -- New tool scripts go in `src/Windows/CLI/tools/` and must be registered in `src/Windows/CLI/menus/Tools.ps1` with appropriate `Platforms` tags. -- Linux-only tools go in `src/Windows/CLI/tools/linux/`. +- A new tool starts as an entry in `assets/tools.json`, the catalogue both sides read. +- **Windows:** `Verb-Noun.ps1` in `src/Windows/CLI/tools/`, registered in `src/Windows/CLI/menus/Tools.ps1`, using `Write-PcOption` / `Set-PcTheme` for UI. +- **Linux:** a function in `src/Linux/pchealth/tools/`, registered in that package's `REGISTRY`. Emit through the `ToolContext` so the tool works in both the terminal and the GTK app. - Open an issue before starting larger changes to avoid duplicate work. See [SECURITY.md](SECURITY.md) for responsible disclosure of vulnerabilities. @@ -216,3 +247,16 @@ This repository consolidates and replaces several earlier pcHealth-related proje - [pcHealthPlus-VS](https://github.com/REALSDEALS/pcHealthPlus-VS) - Visual Studio variant (deprecated; migrated) - [pcHealth-GUI](https://github.com/iRepairzone-NL/pcHealth_GUI) - Python GUI variant (deprecated; migrated) - [Win_Scan](https://github.com/REALSDEALS/Win_Scan) - standalone Windows scanning utility (deprecated; migrated) + +Where that functionality lives today: + +| Predecessor | Now in | +|-------------|--------| +| pcHealth (batch) | `src/Windows/CLI/` -- the menu-driven toolkit, rewritten in PowerShell 7 | +| pcHealthPlus, pcHealthPlus-VS | `src/Windows/CLI/tools/` -- the individual repair and reporting tools | +| pcHealth-GUI (Python) | `src/Linux/pchealth/gui/` -- the Python GUI lineage continues on Linux with GTK4 | +| Win_Scan | `src/Windows/CLI/tools/Invoke-ScanAndRepair.ps1` -- SFC and DISM in one pass | + +Nothing from those projects has been dropped on the way in. Where a tool was +replaced by a better one, the replacement covers the same job -- and the +history of every migration is in this repository's git log. From e45ff256a6a224daca4196941d998872ebb918ff Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:45:06 +0000 Subject: [PATCH 11/43] build: publish self-contained and ship an MSI installer A release ZIP used to be useless until the technician installed the .NET Desktop Runtime and the Windows App SDK runtime on the machine they were standing in front of to repair. Publishing self-contained puts both inside the app, and the MSI wraps that into one file to hand someone. WiX v6 authoring in installer/pcHealth.wxs: per-machine install, Start menu shortcut, and a fixed UpgradeCode so a new version replaces the old one. msiexec /qn works for unattended deployment. The portable ZIP keeps its name so the existing WinGet manifest is unaffected. Trimming stays off: WinUI 3 resolves XAML types by reflection, so a trimmed build fails at runtime rather than at build time. Single-file is opt-in, because the Windows App SDK's native binaries cannot all be merged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .github/workflows/ci-cd.yml | 44 ++++++++++ .github/workflows/release.yml | 17 +++- Documentation/changelog.md | 11 +++ README.md | 19 ++++- development/tools/Build-Release.ps1 | 120 +++++++++++++++++++++------- installer/pcHealth.wxs | 77 ++++++++++++++++++ 6 files changed, 254 insertions(+), 34 deletions(-) create mode 100644 installer/pcHealth.wxs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index f47fd6e..e7617a1 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -22,6 +22,7 @@ concurrency: # dotnet-build → dotnet build (GUI/ WinUI 3 app) # dotnet-format → dotnet format --verify-no-changes (code style) # python-lint → ruff + mypy (Linux app, src/Linux) +# installer-build → wix build (MSI authoring compiles) # pr-title → conventional commits (PR title format) # commit-lint → conventional commits (commit message format) # markdown-lint → markdownlint (README, SECURITY, .github docs) @@ -118,6 +119,49 @@ jobs: - name: Catalogue matches registry run: python3 -c "from pchealth import catalog; from pchealth.tools import REGISTRY; missing = [t.id for t in catalog.load() if t.id not in REGISTRY]; assert not missing, f'no implementation for {missing}'; print(f'{len(REGISTRY)} tools wired')" + # ---------------------------------------------------------- + # INSTALLER AUTHORING + # Compiles installer/pcHealth.wxs against a stub payload so a + # broken installer is caught on push instead of at release + # time, when a failed build means a release without its MSI. + # WiX v4+ builds MSIs on Linux, so this needs no Windows runner. + # ---------------------------------------------------------- + installer-build: + name: Installer authoring (WiX) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup .NET 10 + uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0 + with: + dotnet-version: '10.0.x' + + - name: Install WiX + run: dotnet tool install --global wix + + # A stub stands in for the publish output: this job checks the + # authoring, not the app. The real payload is built by + # development/tools/Build-Release.ps1 on a Windows runner. + - name: Create stub payload + run: | + mkdir -p stub-publish + printf 'stub' > stub-publish/pcHealth.exe + printf 'stub' > stub-publish/Microsoft.WindowsAppRuntime.Bootstrap.dll + + - name: Build MSI from authoring + run: | + wix build installer/pcHealth.wxs \ + -arch x64 \ + -d "Version=$(cat VERSION)" \ + -d "PublishDir=$(pwd)/stub-publish" \ + -out "$(pwd)/pcHealth-authoring-check.msi" + + - name: Confirm the MSI was produced + run: test -s pcHealth-authoring-check.msi && echo "MSI authoring builds" + # ---------------------------------------------------------- # PR TITLE CHECK # Enforces conventional commit format in pull request titles. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df35542..e0b4cfe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,8 +11,9 @@ jobs: # ------------------------------------------------------------ # BUILD - # Compiles the WinUI 3 app and packages GUI + CLI into ZIPs, - # then uploads the artifacts to the GitHub Release. + # Publishes the WinUI 3 app self-contained, packages GUI + CLI + # into ZIPs, builds the MSI installers, and uploads everything + # to the GitHub Release. # ------------------------------------------------------------ build: name: Build & upload release artifacts @@ -37,13 +38,19 @@ jobs: with: dotnet-version: '10.0.x' + # The MSI is built by WiX; -RequireMsi below turns a missing toolset + # into a failed release rather than a release without its installer. + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix + - name: Build release (x64) shell: pwsh - run: pwsh -File development/tools/Build-Release.ps1 -Architecture x64 + run: pwsh -File development/tools/Build-Release.ps1 -Architecture x64 -RequireMsi - name: Build release (arm64) shell: pwsh - run: pwsh -File development/tools/Build-Release.ps1 -Architecture arm64 -Output dist-arm64 + run: pwsh -File development/tools/Build-Release.ps1 -Architecture arm64 -Output dist-arm64 -RequireMsi - name: Upload release assets shell: pwsh @@ -53,6 +60,8 @@ jobs: $v = "${{ steps.version.outputs.version }}" $tag = "${{ github.ref_name }}" gh release upload $tag ` + "dist/pcHealth-${v}-win-x64.msi" ` + "dist-arm64/pcHealth-${v}-win-arm64.msi" ` "dist/pcHealth-${v}-win-x64.zip" ` "dist-arm64/pcHealth-${v}-win-arm64.zip" ` "dist/pcHealth-CLI-${v}.zip" ` diff --git a/Documentation/changelog.md b/Documentation/changelog.md index f44f61f..44fd1f5 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -1,5 +1,16 @@ # Changelog.md - pcHealth +## 17-09-2026 (3) - @Stensel8 + +Self-contained builds and an MSI installer. + +- The GUI is now published **self-contained**: the .NET runtime and the Windows App SDK travel inside the app, so the target machine needs neither installed first. Previously a release ZIP was useless until the technician installed two runtimes on the machine they were there to repair. +- **New: an MSI installer** (`pcHealth--win-x64.msi` and `-win-arm64.msi`), built with WiX v6 from `installer/pcHealth.wxs`. Per-machine install to Program Files, Start menu shortcut, and a fixed UpgradeCode so a new version replaces the old one instead of installing beside it. `msiexec /qn` works for unattended deployment. +- The portable ZIP stays, with the same name, so the existing WinGet manifest keeps working. +- `Build-Release.ps1` switched from `dotnet build --no-self-contained` to `dotnet publish --self-contained`, and gained `-SingleFile` (opt-in; the Windows App SDK's native binaries cannot all be merged into the exe) and `-RequireMsi` (used by CI so a release can never silently ship without its installer). +- Trimming is explicitly disabled: WinUI 3 resolves XAML types by reflection, so a trimmed build fails at runtime rather than at build time. +- CI gained an `installer-build` job that compiles the WiX authoring against a stub payload on every push, so a broken installer surfaces then rather than during a release. + ## 17-09-2026 (2) - @Stensel8 Split the codebase into a Windows stack and a Linux stack. diff --git a/README.md b/README.md index 0f56381..0bfda01 100644 --- a/README.md +++ b/README.md @@ -74,8 +74,16 @@ CI fails if the catalogue lists a tool the registry cannot run. See ### Windows -1. Download or clone this repository. -2. Run `Start.ps1` from an elevated PowerShell 7 terminal: +**Install the desktop app** — download `pcHealth--win-x64.msi` (or `-win-arm64`) from [Releases](https://github.com/REALSDEALS/pcHealth/releases) and run it. The build is self-contained: the .NET runtime and the Windows App SDK travel inside it, so nothing has to be installed on the machine first. That matters on a PC you are there to repair. + +```powershell +# Unattended, for deployment +msiexec /i pcHealth-2.0.0-win-x64.msi /qn +``` + +A portable ZIP is published alongside the MSI for running straight off a USB stick — same binaries, no installation. + +**Run the CLI from source** — from an elevated PowerShell 7 terminal: ```powershell .\src\Windows\CLI\Start.ps1 @@ -112,6 +120,13 @@ A Linux GUI is available separately -- WinUI 3 is Windows-only, so the Linux des | .NET 10 SDK | `winget install Microsoft.DotNet.SDK.10` | | Visual Studio 2026 | `winget install Microsoft.VisualStudio.Community` | | Windows App SDK | Included via NuGet on build | +| WiX v6 | `dotnet tool install --global wix` (only needed to build the MSI) | + +**Building the release artifacts** (self-contained app, ZIPs and MSI): + +```powershell +pwsh -File development/tools/Build-Release.ps1 -Architecture x64 +``` ```powershell dotnet build "src/Windows/GUI/pcHealth/pcHealth.csproj" -c Release diff --git a/development/tools/Build-Release.ps1 b/development/tools/Build-Release.ps1 index c9abc20..5ca32dd 100644 --- a/development/tools/Build-Release.ps1 +++ b/development/tools/Build-Release.ps1 @@ -1,20 +1,23 @@ #Requires -Version 7.0 # ============================================================================ # pcHealth — Release builder -# Builds the GUI (framework-dependent, no MSIX) and packages both GUI and -# CLI into distributable ZIP archives, ready for a GitHub Release or a -# WinGet manifest submission. +# Publishes the GUI self-contained, packages GUI and CLI as ZIPs, and builds +# an MSI installer. # -# Prerequisites on the TARGET machine: -# - Windows App SDK 1.8 runtime -# winget install Microsoft.WindowsAppRuntime.1.8 -# - .NET 10 Desktop Runtime -# winget install Microsoft.DotNet.DesktopRuntime.10 +# Self-contained means the .NET runtime and the Windows App SDK travel with +# the app, so the TARGET MACHINE NEEDS NOTHING PRE-INSTALLED. That is the +# whole point: a technician's USB stick should work on a machine that is +# already broken, without first installing two runtimes on it. +# +# Build prerequisites on THIS machine: +# - .NET 10 SDK winget install Microsoft.DotNet.SDK.10 +# - WiX v6 (for the MSI) dotnet tool install --global wix # # Usage: # pwsh -File development/tools/Build-Release.ps1 # pwsh -File development/tools/Build-Release.ps1 -Architecture arm64 -# pwsh -File development/tools/Build-Release.ps1 -Output C:\my\dist +# pwsh -File development/tools/Build-Release.ps1 -SingleFile +# pwsh -File development/tools/Build-Release.ps1 -RequireMsi # CI: fail if no MSI # ============================================================================ [CmdletBinding()] @@ -22,7 +25,18 @@ param( [ValidateSet('x64', 'arm64')] [string] $Architecture = 'x64', - [string] $Output = (Join-Path $PSScriptRoot '..\..\dist') + [string] $Output = (Join-Path $PSScriptRoot '..\..\dist'), + + # Packs the managed assemblies into pcHealth.exe. The Windows App SDK's + # native binaries cannot all be merged, so this shrinks the file count + # rather than producing a literal single file. Off by default because the + # MSI is the one-file answer and this path is the less-travelled one. + [switch] $SingleFile, + + # Turns a missing WiX toolset from a warning into an error. The release + # workflow passes this so a release can never silently ship without its + # installer. + [switch] $RequireMsi ) $ErrorActionPreference = 'Stop' @@ -33,48 +47,73 @@ $rid = "win-$Architecture" $distDir = $Output $stageDir = Join-Path $distDir '_stage' -$guiStage = Join-Path $stageDir "pcHealth-$version" +$publishDir = Join-Path $distDir "_publish\$rid" $cliStage = Join-Path $stageDir "pcHealth-CLI-$version" +$guiStage = Join-Path $stageDir "pcHealth-$version" $guiZipPath = Join-Path $distDir "pcHealth-$version-$rid.zip" $cliZipPath = Join-Path $distDir "pcHealth-CLI-$version.zip" +$msiPath = Join-Path $distDir "pcHealth-$version-$rid.msi" # ── Banner ──────────────────────────────────────────────────────────────────── Write-Host '' -Write-Host "[Build-Release] pcHealth v$version | $rid" -ForegroundColor Cyan +Write-Host "[Build-Release] pcHealth v$version | $rid | self-contained" -ForegroundColor Cyan Write-Host '' # ── Clean ───────────────────────────────────────────────────────────────────── -Write-Host '[1/4] Cleaning dist/...' -ForegroundColor Yellow +Write-Host '[1/5] Cleaning dist/...' -ForegroundColor Yellow if (Test-Path $distDir) { Remove-Item $distDir -Recurse -Force } -$null = New-Item $guiStage -ItemType Directory -Force -$null = New-Item $cliStage -ItemType Directory -Force +$null = New-Item $guiStage -ItemType Directory -Force +$null = New-Item $cliStage -ItemType Directory -Force +$null = New-Item $publishDir -ItemType Directory -Force -# ── Build GUI ───────────────────────────────────────────────────────────────── +# ── Publish GUI ─────────────────────────────────────────────────────────────── -Write-Host '[2/4] Building GUI...' -ForegroundColor Yellow +Write-Host '[2/5] Publishing GUI (self-contained)...' -ForegroundColor Yellow $csproj = Join-Path $repoRoot 'src\Windows\GUI\pcHealth\pcHealth.csproj' -dotnet build $csproj --configuration Release --runtime $rid --no-self-contained --nologo +# WindowsAppSDKSelfContained and WindowsPackageType live in the csproj; the +# .NET side is set here so an ordinary `dotnet build` during development stays +# fast and framework-dependent. +$publishArgs = @( + 'publish', $csproj + '--configuration', 'Release' + '--runtime', $rid + '--self-contained', 'true' + '--output', $publishDir + '--nologo' +) +if ($SingleFile) { + # IncludeNativeLibrariesForSelfExtract pulls what native binaries it can + # into the exe; they are extracted to a temp directory on first launch. + $publishArgs += @( + '-p:PublishSingleFile=true' + '-p:IncludeNativeLibrariesForSelfExtract=true' + ) +} +# Never trim: WinUI 3 resolves XAML types by reflection, and a trimmed build +# fails at runtime rather than at build time. +$publishArgs += '-p:PublishTrimmed=false' + +dotnet @publishArgs if ($LASTEXITCODE -ne 0) { - Write-Error "dotnet build failed (exit $LASTEXITCODE)." + Write-Error "dotnet publish failed (exit $LASTEXITCODE)." } -# Read TargetFramework from csproj so the bin path never drifts. -$tfm = ([xml](Get-Content $csproj)).Project.PropertyGroup.TargetFramework | - Where-Object { $_ } | Select-Object -First 1 -$binOut = Join-Path $repoRoot "src\Windows\GUI\pcHealth\bin\Release\$tfm\$rid" +if (-not (Test-Path (Join-Path $publishDir 'pcHealth.exe'))) { + Write-Error "Publish succeeded but pcHealth.exe is missing from $publishDir." +} -Copy-Item "$binOut\*" $guiStage -Recurse +Copy-Item "$publishDir\*" $guiStage -Recurse # ── Package ZIPs ────────────────────────────────────────────────────────────── -Write-Host '[3/4] Packaging ZIPs...' -ForegroundColor Yellow +Write-Host '[3/5] Packaging ZIPs...' -ForegroundColor Yellow # GUI — folder-nested so WinGet NestedInstallerFiles can target the EXE Compress-Archive -Path $guiStage -DestinationPath $guiZipPath -CompressionLevel Optimal @@ -83,11 +122,35 @@ Compress-Archive -Path $guiStage -DestinationPath $guiZipPath -CompressionLevel Copy-Item (Join-Path $repoRoot 'src\Windows\CLI\*') $cliStage -Recurse Compress-Archive -Path $cliStage -DestinationPath $cliZipPath -CompressionLevel Optimal +# ── Build MSI ───────────────────────────────────────────────────────────────── + +Write-Host '[4/5] Building MSI...' -ForegroundColor Yellow + +$wix = Get-Command wix -CommandType Application -ErrorAction SilentlyContinue +if (-not $wix) { + $message = 'WiX toolset not found. Install it with: dotnet tool install --global wix' + if ($RequireMsi) { Write-Error $message } + Write-Host " [--] $message" -ForegroundColor Yellow + Write-Host ' [--] Skipping the MSI; the ZIP above is complete on its own.' -ForegroundColor DarkGray +} else { + $wxs = Join-Path $repoRoot 'installer\pcHealth.wxs' + + & $wix.Source build $wxs ` + -arch $Architecture ` + -d "Version=$version" ` + -d "PublishDir=$((Resolve-Path $publishDir).Path)" ` + -out $msiPath + + if ($LASTEXITCODE -ne 0) { + Write-Error "wix build failed (exit $LASTEXITCODE)." + } +} + # ── SHA256 hashes ───────────────────────────────────────────────────────────── -Write-Host '[4/4] Computing SHA256 hashes...' -ForegroundColor Yellow +Write-Host '[5/5] Computing SHA256 hashes...' -ForegroundColor Yellow -$artifacts = @($guiZipPath, $cliZipPath) +$artifacts = @($guiZipPath, $cliZipPath) + @(if (Test-Path $msiPath) { $msiPath }) $hashes = $artifacts | ForEach-Object { [PSCustomObject]@{ @@ -99,9 +162,10 @@ $hashes = $artifacts | ForEach-Object { $hashes | ForEach-Object { "$($_.SHA256) $($_.File)" } | Set-Content (Join-Path $distDir 'SHA256SUMS.txt') -# ── Cleanup staging dir ─────────────────────────────────────────────────────── +# ── Cleanup staging dirs ────────────────────────────────────────────────────── Remove-Item $stageDir -Recurse -Force +Remove-Item (Join-Path $distDir '_publish') -Recurse -Force # ── Summary ─────────────────────────────────────────────────────────────────── diff --git a/installer/pcHealth.wxs b/installer/pcHealth.wxs new file mode 100644 index 0000000..defb2d2 --- /dev/null +++ b/installer/pcHealth.wxs @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From bdaf556e3fa4c1be996bb9074602e608a677a9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 17 Sep 2026 21:46:52 +0000 Subject: [PATCH 12/43] fix(ci): pin wix to v5 and build the installer on windows Two findings from the first run of the installer-build job, which is what that job exists for: WiX only supports Windows. On Linux it prints "all behavior after this point is undefined" and carries on, so the job moves to a Windows runner. WiX v6 and v7 refuse to build until the Open Source Maintenance Fee EULA is accepted (error WIX7015). Accepting a licence on the project's behalf is not a CI default, so the toolset is pinned to v5.0.2 -- the last release under the plain open-source licence, building the same MSI from the same authoring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DxhvVJmPAgyVJ8QJSp5N5X --- .github/workflows/ci-cd.yml | 40 +++++++++++++++++++---------- .github/workflows/release.yml | 4 ++- Documentation/changelog.md | 1 + README.md | 2 +- development/tools/Build-Release.ps1 | 4 ++- installer/pcHealth.wxs | 2 +- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index e7617a1..5db8b22 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -124,11 +124,12 @@ jobs: # Compiles installer/pcHealth.wxs against a stub payload so a # broken installer is caught on push instead of at release # time, when a failed build means a release without its MSI. - # WiX v4+ builds MSIs on Linux, so this needs no Windows runner. + # Windows runner: WiX warns that it supports Windows only and + # that everything after that point is undefined. # ---------------------------------------------------------- installer-build: name: Installer authoring (WiX) - runs-on: ubuntu-latest + runs-on: windows-latest permissions: contents: read steps: @@ -139,28 +140,41 @@ jobs: with: dotnet-version: '10.0.x' + # Pinned to v5 deliberately. WiX v6 and v7 require accepting the Open + # Source Maintenance Fee EULA before they will build anything, which is + # a licensing decision for the project owner, not a CI default. v5 is + # the last release under the plain open-source licence and builds the + # same MSI from the same authoring. - name: Install WiX - run: dotnet tool install --global wix + shell: pwsh + run: dotnet tool install --global wix --version 5.0.2 # A stub stands in for the publish output: this job checks the # authoring, not the app. The real payload is built by - # development/tools/Build-Release.ps1 on a Windows runner. + # development/tools/Build-Release.ps1. - name: Create stub payload + shell: pwsh run: | - mkdir -p stub-publish - printf 'stub' > stub-publish/pcHealth.exe - printf 'stub' > stub-publish/Microsoft.WindowsAppRuntime.Bootstrap.dll + $null = New-Item stub-publish -ItemType Directory -Force + Set-Content stub-publish/pcHealth.exe 'stub' + Set-Content stub-publish/Microsoft.WindowsAppRuntime.Bootstrap.dll 'stub' - name: Build MSI from authoring + shell: pwsh run: | - wix build installer/pcHealth.wxs \ - -arch x64 \ - -d "Version=$(cat VERSION)" \ - -d "PublishDir=$(pwd)/stub-publish" \ - -out "$(pwd)/pcHealth-authoring-check.msi" + $version = (Get-Content VERSION -Raw).Trim() + wix build installer/pcHealth.wxs ` + -arch x64 ` + -d "Version=$version" ` + -d "PublishDir=$((Resolve-Path stub-publish).Path)" ` + -out (Join-Path $PWD 'pcHealth-authoring-check.msi') - name: Confirm the MSI was produced - run: test -s pcHealth-authoring-check.msi && echo "MSI authoring builds" + shell: pwsh + run: | + $msi = Get-Item pcHealth-authoring-check.msi -ErrorAction SilentlyContinue + if (-not $msi -or $msi.Length -eq 0) { throw 'wix produced no MSI' } + "MSI authoring builds ($([Math]::Round($msi.Length / 1KB)) KB)" # ---------------------------------------------------------- # PR TITLE CHECK diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e0b4cfe..7e76c4a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,9 +40,11 @@ jobs: # The MSI is built by WiX; -RequireMsi below turns a missing toolset # into a failed release rather than a release without its installer. + # Pinned to v5: v6 and v7 gate every build behind the Open Source + # Maintenance Fee EULA. See the installer-build job in ci-cd.yml. - name: Install WiX shell: pwsh - run: dotnet tool install --global wix + run: dotnet tool install --global wix --version 5.0.2 - name: Build release (x64) shell: pwsh diff --git a/Documentation/changelog.md b/Documentation/changelog.md index 44fd1f5..d025ee7 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -10,6 +10,7 @@ Self-contained builds and an MSI installer. - `Build-Release.ps1` switched from `dotnet build --no-self-contained` to `dotnet publish --self-contained`, and gained `-SingleFile` (opt-in; the Windows App SDK's native binaries cannot all be merged into the exe) and `-RequireMsi` (used by CI so a release can never silently ship without its installer). - Trimming is explicitly disabled: WinUI 3 resolves XAML types by reflection, so a trimmed build fails at runtime rather than at build time. - CI gained an `installer-build` job that compiles the WiX authoring against a stub payload on every push, so a broken installer surfaces then rather than during a release. +- **WiX is pinned to v5.0.2.** v6 and v7 refuse to build anything until you accept the [Open Source Maintenance Fee](https://wixtoolset.org/osmf/) EULA, which is a licensing decision for the project owner rather than a CI default. v5 is the last release under the plain open-source licence and builds the same MSI from the same authoring. ## 17-09-2026 (2) - @Stensel8 diff --git a/README.md b/README.md index 0bfda01..2b58abe 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ A Linux GUI is available separately -- WinUI 3 is Windows-only, so the Linux des | .NET 10 SDK | `winget install Microsoft.DotNet.SDK.10` | | Visual Studio 2026 | `winget install Microsoft.VisualStudio.Community` | | Windows App SDK | Included via NuGet on build | -| WiX v6 | `dotnet tool install --global wix` (only needed to build the MSI) | +| WiX v5 | `dotnet tool install --global wix --version 5.0.2` (only for the MSI) | **Building the release artifacts** (self-contained app, ZIPs and MSI): diff --git a/development/tools/Build-Release.ps1 b/development/tools/Build-Release.ps1 index 5ca32dd..34e0c88 100644 --- a/development/tools/Build-Release.ps1 +++ b/development/tools/Build-Release.ps1 @@ -11,7 +11,9 @@ # # Build prerequisites on THIS machine: # - .NET 10 SDK winget install Microsoft.DotNet.SDK.10 -# - WiX v6 (for the MSI) dotnet tool install --global wix +# - WiX v5 (for the MSI) dotnet tool install --global wix --version 5.0.2 +# v6 and v7 refuse to build until you accept the Open Source Maintenance +# Fee EULA (https://wixtoolset.org/osmf/); v5 is the last plain one. # # Usage: # pwsh -File development/tools/Build-Release.ps1 diff --git a/installer/pcHealth.wxs b/installer/pcHealth.wxs index defb2d2..4fc9672 100644 --- a/installer/pcHealth.wxs +++ b/installer/pcHealth.wxs @@ -1,6 +1,6 @@ + @@ -72,15 +72,27 @@ Style="{StaticResource CaptionTextBlockStyle}" Foreground="{ThemeResource TextFillColorSecondaryBrush}" Visibility="{x:Bind NoteVisibility}"/> + - -