diff --git a/.github/labeler.yml b/.github/labeler.yml index 9a2660f..75b2561 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -75,13 +75,12 @@ "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/Linux/**" # ── Language labels ─────────────────────────────────────────────────────────── diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 8872038..4544182 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -21,6 +21,8 @@ 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) @@ -53,10 +55,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 +80,105 @@ 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 + + # ---------------------------------------------------------- + # 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')" + + # ---------------------------------------------------------- + # 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. + # 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: windows-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' + + # WiX v7 refuses to build until the Open Source Maintenance Fee EULA is + # accepted (error WIX7015), which -acceptEula below does. + # + # Accepted by the pcHealth maintainers (@Stensel8, REALSDEALS). The fee + # itself does not apply here: it is owed by organisations above $10,000 + # annual revenue that use WiX to generate revenue, and pcHealth is a + # FOSS project well under that line. Only the acceptance is required. + # https://docs.firegiant.com/wix/osmf/ + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 7.0.0 + + # 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. + - name: Create stub payload + shell: pwsh + run: | + $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: | + $version = (Get-Content VERSION -Raw).Trim() + wix build installer/pcHealth.wxs ` + -acceptEula wix7 ` + -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 + 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/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/release.yml b/.github/workflows/release.yml index df35542..415d84f 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,20 @@ 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. + # The OSMF EULA acceptance is explained in ci-cd.yml's installer-build job. + - name: Install WiX + shell: pwsh + run: dotnet tool install --global wix --version 7.0.0 + - 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 +61,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/.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/.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/AGENTS.md b/AGENTS.md index 725fdf3..133a411 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/CLI/` | PowerShell 7 + Bash | Cross-platform terminal health tool | -| GUI | `src/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/`. --- @@ -42,7 +50,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 +82,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 | |---|---|---| @@ -86,16 +94,23 @@ 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, or any formatted text | `ui.section` / `ui.fields` / `ui.note` / `ui.run` | A tool describes results; the front-end decides whether they become text or widgets. A tool that emits `"[>>] ..."` has decided it lives in a terminal | +| Running a command by hand and printing its output | `ui.run(argv, label=...)` / `ui.run_all(...)` | Handles the step, its raw output, the exit code, and a single elevation prompt for a batch | +| `$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 +118,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 763ccbc..59089c5 100644 --- a/Documentation/changelog.md +++ b/Documentation/changelog.md @@ -1,5 +1,85 @@ # Changelog.md - pcHealth +## 18-09-2026 (4) - @Stensel8 + +Both GUIs stop being terminals in a window. + +- **The tool contract is structural instead of line-based.** `ctx.line(text, style)` gave a front-end nothing to render but text, which is why the GTK window looked like a console. A tool now calls `ui.section()`, `ui.fields()`, `ui.note()` and `ui.run()`; the terminal turns those into text and the GTK window into groups, rows and an expander. Raw command output belongs to the step that produced it and stays folded away. +- `ui.run()` / `ui.run_all()` absorb the six lines every tool repeated around each command -- label, stream with an indent, check the exit code, report OK or the failure -- and keep the single elevation prompt. +- **Windows: `ICliRunner.RunScript` is gone** (it was dead code) and so is `RunWinget`, which opened a real pwsh window that waited for a keypress. Installing a program now reports its progress on its own card. +- **Windows: command output moved behind a `Details` expander** on the seven pages that opened onto a wall of monospace log text -- Scan + Repair, Boot Repair, Network Reset, Continuous Ping, System Update, Winget Repair and HP Update. The redundant card border inside it went with it. The CBS log and licence key pages keep their text: there the text *is* the result. + +## 18-09-2026 (3) - @Stensel8 + +One password prompt per tool, instead of one per command. + +- **Fixed: pkexec asked for the admin password once per privileged command.** Disk Cleanup ran six of them, so it asked six times; Hardware Information asked once per disk; the Health page asked for nearly every section. Each of those is one prompt now. +- `privileged.py` is elevated once and runs the whole batch, streaming each line back as it arrives. It holds no logic and takes no decisions: it runs exactly the argv lists it is handed on stdin as JSON, with no shell involved, and exits when the batch is done -- there is no long-lived root process listening on a pipe. +- Boot Repair passes `stop_on_error`, so `grub-mkconfig` can never run after `grub-install` failed. +- The Health report no longer elevates at all for the firewall state. A report that asks for the root password to tell you whether ufw is running is not worth the interruption; it says the state needs root instead. +- Reading SMART is now one batch for every disk, so Hardware Information and Health ask once rather than once per drive. + +## 18-09-2026 (2) - @Stensel8 + +Health report for Linux, and two fixes the screencast turned up. + +- **New: a Health page**, the Linux counterpart of the WinUI 3 Health tab. Seven sections -- Overview, Processor, Graphics, Memory, Storage, Battery and Security/Services -- each check carrying a status so both front-ends can colour it. It reads the same `assets/hardware-db.json` the Windows app uses, for CPU and GPU release years. +- What it checks differs from Windows because the systems differ: no Defender or BitLocker, but CPU mitigations, the active LSM (SELinux/AppArmor), the firewall, failed systemd units and boot time. +- **Fixed: a firmware refresh flooded the output with 300+ lines** of `Downloading…: 41.4%`. fwupd, apt and dnf redraw a progress line with carriage returns; through a pipe those become separate lines. `ProgressFilter` now keeps one progress line per second plus the last one, so movement is still visible but the four lines that say something are not buried. +- **Fixed: output colours were read from the theme once at startup**, so switching light/dark mid-session left them wrong. The tags now repaint on `notify::dark`. +- `smart.py` extracted: Hardware Information and the Health report read SMART through one module instead of each parsing smartctl's JSON their own way. + +Note: the `Adwaita-WARNING` about `gtk-application-prefer-dark-theme` comes from the desktop's own GTK configuration, not from pcHealth, and is harmless. + +## 18-09-2026 - @Stensel8 + +Linux GUI rebuilt to match the WinUI 3 app. + +- **Tools no longer render menus of their own.** They used to print `[1] [2] [B]` and ask for a line of text, which the GUI could only present as a text box -- a terminal pretending to be an app. A tool now *declares* its options (`ctx.choose`) and each front-end renders them its own way: a numbered list in the terminal, real buttons in the GUI. Affects Power Options, System Logs, BIOS Password Recovery and Boot Repair. +- `ctx.ask` (free text) is gone with it. Boot Repair's "type CONFIRM" is now two explicit confirmations, which is what the Windows tool does anyway. +- **Fixed: categories repeated in the sidebar.** The tool list started a new heading on every change of category, and since the catalogue is in menu order, categories interleave -- UPDATES, HARDWARE and INFORMATION each appeared several times. They are grouped properly now. +- **Fixed: output from the previous tool stayed on screen** after selecting another one. Each tool now has its own page and its own output. +- Laid out like the Windows app: a navigation sidebar, a Tools page of grouped cards with icons, and one page per tool with its title, description and Run button. +- The "Actions ask for elevation" note moved out of the window controls into the sidebar footer. + + +## 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. +- **WiX is pinned to v7.0.0** and the builds pass `-acceptEula`. v6 and up refuse to build until the [Open Source Maintenance Fee](https://docs.firegiant.com/wix/osmf/) EULA is accepted; the maintainers accepted it. The fee itself is owed by organisations above $10,000 annual revenue that use WiX to generate revenue, which does not include this project -- only the acceptance was needed. + +## 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. + +- **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: 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 Linux — Topgrade integration replaces distro-specific package update script. diff --git a/README.md b/README.md index 962b959..1de0d1e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Check the health of your Windows or Linux installation, drivers, updates, batter ## Overview -pcHealth is a cross-platform toolkit for IT technicians and power users. It runs on **Windows and Linux** using a single PowerShell 7 codebase. The goal is to offer the same functionality everywhere: tools are shown or hidden based on the detected OS, and platform-specific actions (like updating packages) automatically use the right method for the current system. +pcHealth is a cross-platform toolkit for IT technicians and power users. It runs on **Windows and Linux**, each with a terminal tool and a native desktop app. The goal is the same functionality everywhere: tools are shown or hidden based on what the machine actually has, and platform-specific actions use the right method for the current system. --- @@ -19,10 +19,10 @@ pcHealth is a cross-platform toolkit for IT technicians and power users. It runs | Platform | CLI | GUI | Minimum | |----------|-----|-----|-------------------------------| -| Windows | ✅ | ✅ | Build 26200 (Windows 11 25H2) | -| Linux | ✅ | ❌ | Kernel 7.0 | +| Windows | ✅ | ✅ | Build 19045 (Windows 10 22H2) | +| Linux | ✅ | ✅ | Kernel 6.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. +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. @@ -33,38 +33,76 @@ 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 26200 (11 25H2) or Linux kernel 7.0. +**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 -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\CLI\Start.ps1 +.\src\Windows\CLI\Start.ps1 ``` ### 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/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 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) -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:** @@ -73,12 +111,26 @@ A Linux GUI is not yet available - WinUI 3 is Windows-only. A cross-platform alt | .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 v7 | `dotnet tool install --global wix --version 7.0.0` (only for the MSI) | + +Two scripts, one job each: + +```powershell +pwsh -File src/Windows/GUI/Run-Debug.ps1 # develop: Debug build, live log in the terminal +pwsh -File src/Windows/GUI/Make-Release.ps1 # Release build and launch, as a user gets it +``` + +`Run-Debug.ps1` exists because a WinUI 3 app is a GUI subsystem binary: it has no console of its own and prints nothing to the terminal you started it from. The script builds Debug, runs the app, and streams the NLog output into the terminal as it happens, colouring warnings and errors. When the app stops it decodes the exit code, so a native crash (`0xC0000005`) reads as one instead of as a window that silently vanished. + +`Make-Release.ps1` also installs any missing build dependency, so it is the one to run first on a fresh machine. + +**Building the release artifacts** (self-contained app, ZIPs and MSI): ```powershell -dotnet build "src/GUI/pcHealth/pcHealth.csproj" -c Release +pwsh -File development/tools/Build-Release.ps1 -Architecture x64 ``` -Or open `src/GUI/pcHealth/pcHealth.csproj` in Visual Studio 2026. +Or open `src/Windows/GUI/pcHealth/pcHealth.csproj` in Visual Studio 2026. --- @@ -176,10 +228,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/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/`. +- 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. @@ -207,3 +260,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. diff --git a/SECURITY.md b/SECURITY.md index 17dcc5d..be8958d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,12 +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 | Status | -|----------|-------------------------------|------------------------| -| Windows | 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 | -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. +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 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/assets/tools.json b/assets/tools.json new file mode 100644 index 0000000..c3d2f6c --- /dev/null +++ b/assets/tools.json @@ -0,0 +1,51 @@ +{ + "_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", + "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"], "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, "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/development/tools/Build-Release.ps1 b/development/tools/Build-Release.ps1 index ceff0a7..85f5d9d 100644 --- a/development/tools/Build-Release.ps1 +++ b/development/tools/Build-Release.ps1 @@ -1,20 +1,27 @@ #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 v7 (for the MSI) dotnet tool install --global wix --version 7.0.0 +# v7 refuses to build until the Open Source Maintenance Fee EULA is +# accepted; -acceptEula below does that. The fee is owed by organisations +# above $10,000 annual revenue that use WiX to generate revenue, which +# pcHealth is not. See https://docs.firegiant.com/wix/osmf/ # # 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 +29,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,61 +51,113 @@ $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 - -# ── Build GUI ───────────────────────────────────────────────────────────────── - -Write-Host '[2/4] Building GUI...' -ForegroundColor Yellow - -$csproj = Join-Path $repoRoot 'src\GUI\pcHealth\pcHealth.csproj' +$null = New-Item $guiStage -ItemType Directory -Force +$null = New-Item $cliStage -ItemType Directory -Force +$null = New-Item $publishDir -ItemType Directory -Force + +# ── Publish GUI ─────────────────────────────────────────────────────────────── + +Write-Host '[2/5] Publishing GUI (self-contained)...' -ForegroundColor Yellow + +$csproj = Join-Path $repoRoot 'src\Windows\GUI\pcHealth\pcHealth.csproj' + +# 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 build $csproj --configuration Release --runtime $rid --no-self-contained --nologo +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\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 # 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 +# ── 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' + + # -acceptEula names the EULA being accepted, not a bare switch: see the + # note at the top of this file for who accepted it and why. + & $wix.Source build $wxs ` + -acceptEula wix7 ` + -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 +169,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/development/tools/Find-RepairTrigger.ps1 b/development/tools/Find-RepairTrigger.ps1 new file mode 100644 index 0000000..0613c59 --- /dev/null +++ b/development/tools/Find-RepairTrigger.ps1 @@ -0,0 +1,554 @@ +#Requires -RunAsAdministrator +<# +.SYNOPSIS + Finds what the Settings "Reinstall now" button calls under the hood. + +.DESCRIPTION + Windows publishes no API for the "Fix problems using Windows Update" repair, + so pcHealth presses the real button through UI Automation. This script looks + for something better: the binary, setting id or registry flag the button + actually uses, so the repair could be started directly instead. + + Everything here is read-only. Nothing in this script starts a repair -- Watch + asks you to press the button yourself and reports what moved. + + A warning about false leads: ResetEngine.dll is full of promising words like + CloudDownloadConnection and GenerateReinstallList, but every one of them + belongs to PushButtonReset -- "Reset this PC". That wipes the machine and is + not the repair. Judge a hit by the feature it belongs to, not by its name. + +.PARAMETER Mode + Settings scans for the Settings page's own setting ids. + Strings scans the likely binaries for revealing text. + Protocols lists the ms- URI schemes this machine registers. + Watch records what happens while you press the button. + All runs everything. + +.PARAMETER WatchSeconds + How long Watch records. The repair takes a while to get going, so this is + generous by default. + +.EXAMPLE + .\Find-RepairTrigger.ps1 -Mode Settings + +.EXAMPLE + .\Find-RepairTrigger.ps1 -Mode Watch -WatchSeconds 420 +#> +[CmdletBinding()] +param( + [ValidateSet('Settings', 'Strings', 'Protocols', 'Watch', 'Deep', 'Locate', 'Hunt', 'All')] + [string]$Mode = 'All', + + [ValidateRange(30, 1800)] + [int]$WatchSeconds = 300, + + # Deep mode: the binary to read. Locate mode: the file name to find. + [string]$File, + [string]$Pattern = 'Ipu|Uso|Orchestrator|Reinstall|Repair|Recovery|IUpdate' +) + +Set-StrictMode -Version Latest + +# Processes worth shouting about: these are the ones the button reaches for. +$NotableProcess = 'SystemSettingsAdminFlows|MoUsoCoreWorker|usoclient|UsoCoreWorker|TiWorker|TrustedInstaller|SetupHost|WaaSMedic' + +# Words worth finding in a binary that knows about this repair. +# Matched case-sensitively, which is what keeps Ipu from hitting "manIPULation". +$InterestingPattern = 'Reinstall|CloudDownload|RepairVersion|SelfHeal|RecoveryReinstall|FixProblem|' + + 'ms-settings:recovery|StartRepair|RemediationRequired|ms-cxh|' + + 'Ipu[A-Z]|IpuInitiated|AdminFlow|Orchestrator|UpdateSessionOrchestrator|UsoSvc' + +# Settings names every control it owns as SystemSettings__, so +# the repair button has an id of its own and that id is the real lead. +$SettingIdPattern = 'SystemSettings_[A-Za-z0-9_]*(Recovery|Reinstall|Repair|Reset|Update|Ipu)[A-Za-z0-9_]*' + +function Get-BinaryString { + <# + .SYNOPSIS + Pulls printable runs out of a file and returns the ones that match. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Pattern, + [int]$MinimumLength = 6 + ) + + try { + $bytes = [System.IO.File]::ReadAllBytes($Path) + } + catch [System.IO.IOException] { + Write-Warning "Could not read $Path : $($_.Exception.Message)" + return + } + catch [System.UnauthorizedAccessException] { + Write-Warning "Access denied reading $Path" + return + } + + # A PE carries literals both as plain bytes and as UTF-16, so both decodings + # are searched rather than guessing which one a given string used. UTF-16 is + # decoded from byte 0 and byte 1, because a string starting at an odd offset + # is garbled by the other alignment and would be missed entirely. + $runPattern = "[\x20-\x7E]{$MinimumLength,}" + foreach ($text in @( + [System.Text.Encoding]::ASCII.GetString($bytes), + [System.Text.Encoding]::Unicode.GetString($bytes), + [System.Text.Encoding]::Unicode.GetString($bytes, 1, $bytes.Length - 1) + )) { + foreach ($match in [regex]::Matches($text, $runPattern)) { + if ($match.Value -cnotmatch $Pattern) { continue } + + # One blob of concatenated error names can be tens of kilobytes and + # tells us nothing, so long runs are clipped rather than dumped. + if ($match.Value.Length -gt 160) { + Write-Output ($match.Value.Substring(0, 160) + ' [clipped]') + } + else { + Write-Output $match.Value + } + } + } +} + +function Get-ScanCandidate { + <# + .SYNOPSIS + The binaries worth reading, as full paths, deduplicated. + #> + [CmdletBinding()] + param([switch]$SettingsOnly) + + $system32 = Join-Path $env:SystemRoot 'System32' + + $named = if ($SettingsOnly) { + @('SystemSettings.Handlers.dll', 'SystemSettings.DataModel.dll', 'SystemSettings.dll') + } + else { + @( + 'SystemSettings.Handlers.dll', 'SystemSettings.DataModel.dll', 'SystemSettings.dll', + 'SystemSettingsAdminFlows.exe', + 'usoclient.exe', 'UsoCore.dll', 'MoUsoCoreWorker.exe', 'usocoreworker.exe', + 'SystemReset.exe', 'ResetEngine.dll', 'ResetEngOnline.dll', 'wuaueng.dll' + ) + } + + $paths = [System.Collections.Generic.List[string]]::new() + foreach ($name in $named) { $paths.Add((Join-Path $system32 $name)) } + + # SystemSettings.Handlers.dll only launches the admin flow host; the real + # per-area handlers are separate DLLs sitting next to it. + foreach ($glob in @('SettingsHandlers*.dll', 'SystemSettings*.dll', '*Uso*.dll', '*Uso*.exe')) { + Get-ChildItem -LiteralPath $system32 -Filter $glob -File -ErrorAction SilentlyContinue | + ForEach-Object { $paths.Add($_.FullName) } + } + + $folders = @((Join-Path $env:SystemRoot 'ImmersiveControlPanel')) + if (-not $SettingsOnly) { + $folders += (Join-Path $env:SystemRoot 'SystemApps') + $folders += (Join-Path $env:SystemRoot 'UUS') + } + + foreach ($folder in $folders) { + if (-not (Test-Path -LiteralPath $folder)) { continue } + Get-ChildItem -LiteralPath $folder -Filter '*.dll' -Recurse -ErrorAction SilentlyContinue | + Where-Object { $_.Directory.Name -notlike '*Edge*' } | + ForEach-Object { $paths.Add($_.FullName) } + } + + Write-Output ($paths | Sort-Object -Unique) +} + +function Invoke-Scan { + <# + .SYNOPSIS + Scans a candidate set and reports missing files as well as hits, so + "no output" cannot be mistaken for "no matches". + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyCollection()][string[]]$Candidate, + [Parameter(Mandatory)][string]$Pattern + ) + + $scanned = 0 + $missing = [System.Collections.Generic.List[string]]::new() + $hitFiles = 0 + + foreach ($path in $Candidate) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + $found = @(Join-Path $env:SystemRoot 'System32'), (Join-Path $env:SystemRoot 'UUS') | + Where-Object { Test-Path -LiteralPath $_ } | + ForEach-Object { + Get-ChildItem -LiteralPath $_ -Filter (Split-Path -Leaf $path) -Recurse -File -ErrorAction SilentlyContinue + } | + Select-Object -First 1 + if ($null -eq $found) { + $missing.Add($path) + continue + } + $path = $found.FullName + } + $scanned++ + + $hits = @(Get-BinaryString -Path $path -Pattern $Pattern | Sort-Object -Unique) + if ($hits.Count -eq 0) { continue } + + $hitFiles++ + Write-Host '' + Write-Host ("--- {0} ({1} hits)" -f $path, $hits.Count) -ForegroundColor Yellow + $hits | ForEach-Object { Write-Host " $_" } + } + + Write-Host '' + Write-Host ("Read {0} files, {1} had hits." -f $scanned, $hitFiles) + if ($missing.Count -gt 0) { + Write-Host ("{0} candidate(s) were not on this machine:" -f $missing.Count) -ForegroundColor DarkYellow + $missing | ForEach-Object { Write-Host " $_" } + } +} + +function Invoke-SettingIdScan { + [CmdletBinding()] + param() + + Write-Host '' + Write-Host '== Settings page ids around recovery and repair ==' -ForegroundColor Cyan + Write-Host 'The button has an id of its own; that id is the lead worth chasing.' + + Invoke-Scan -Candidate @(Get-ScanCandidate -SettingsOnly) -Pattern $SettingIdPattern +} + +function Invoke-StringScan { + [CmdletBinding()] + param() + + Write-Host '' + Write-Host '== Binaries mentioning repair-ish things ==' -ForegroundColor Cyan + Write-Host 'Remember: PushButtonReset hits belong to "Reset this PC", not to the repair.' -ForegroundColor DarkYellow + + Invoke-Scan -Candidate @(Get-ScanCandidate) -Pattern $InterestingPattern +} + +function Invoke-ProtocolScan { + <# + .SYNOPSIS + Lists the ms- URI schemes this machine registers, with the command + each one really runs. + + .DESCRIPTION + Read-only. Nothing is launched: some of these verbs start + destructive flows, so they are only printed. + #> + [CmdletBinding()] + param() + + Write-Host '' + Write-Host '== Registered ms- URI schemes ==' -ForegroundColor Cyan + Write-Host 'Read-only. Do not fire these blindly: some of them reset the PC.' -ForegroundColor DarkYellow + + Get-ChildItem -LiteralPath 'Registry::HKEY_CLASSES_ROOT' -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -like 'ms-*' } | + ForEach-Object { + $scheme = $_.PSChildName + $values = Get-ItemProperty -LiteralPath $_.PSPath -ErrorAction SilentlyContinue + if ($null -eq $values) { return } + if ($values.PSObject.Properties.Name -notcontains 'URL Protocol') { return } + + # Each scheme gets its own lookup; a shared variable would carry the + # previous scheme's command over whenever a key has none. + $command = '(no open command)' + $commandKey = Join-Path $_.PSPath 'shell\open\command' + if (Test-Path -LiteralPath $commandKey) { + $item = Get-ItemProperty -LiteralPath $commandKey -ErrorAction SilentlyContinue + if ($null -ne $item -and $item.PSObject.Properties.Name -contains '(default)') { + $command = $item.'(default)' + } + } + + Write-Host (' {0,-34} {1}' -f $scheme, $command) + } +} + +function Invoke-DeepScan { + <# + .SYNOPSIS + Dumps one binary's strings against a pattern you choose. + + .DESCRIPTION + The broad scan answers "which file knows about this". This answers + "what exactly does that file say", which is the next question every + time the broad scan points somewhere. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Match + ) + + Write-Host '' + Write-Host ("== Deep read of {0} ==" -f $Path) -ForegroundColor Cyan + Write-Host ("Pattern (case-sensitive): {0}" -f $Match) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-Host ' File not found.' -ForegroundColor Red + return + } + + $hits = @(Get-BinaryString -Path $Path -Pattern $Match -MinimumLength 4 | Sort-Object -Unique) + Write-Host ("{0} distinct matches." -f $hits.Count) -ForegroundColor Yellow + $hits | ForEach-Object { Write-Host " $_" } +} + +function Invoke-Locate { + <# + .SYNOPSIS + Finds a file anywhere under the Windows directory. + + .DESCRIPTION + Guessing a path wastes a round trip. MoUsoCoreWorker plainly exists, + since it runs, so the machine can simply be asked where it is. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Name) + + Write-Host '' + Write-Host ("== Looking for {0} under {1} ==" -f $Name, $env:SystemRoot) -ForegroundColor Cyan + + $found = @( + Get-ChildItem -LiteralPath $env:SystemRoot -Filter $Name -Recurse -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName + ) + + if ($found.Count -eq 0) { + Write-Host ' Not found.' -ForegroundColor Red + Write-Host ' If it is running, this gives the path directly:' + Write-Host (" Get-Process {0} | Select-Object -Unique Path" -f [System.IO.Path]::GetFileNameWithoutExtension($Name)) + return + } + + $found | ForEach-Object { Write-Host " $_" -ForegroundColor Green } +} + +function Invoke-Hunt { + <# + .SYNOPSIS + Reads every binary under System32 looking for one pattern. + + .DESCRIPTION + The slow, last-resort sweep, for when a distinctive string is known + but not which file writes it. Opt-in because it reads gigabytes. + #> + [CmdletBinding()] + param([Parameter(Mandatory)][string]$Match) + + Write-Host '' + Write-Host ("== Sweeping System32 for {0} ==" -f $Match) -ForegroundColor Cyan + Write-Host 'This reads a lot of files and takes a few minutes.' -ForegroundColor DarkYellow + + $files = @( + Get-ChildItem -LiteralPath (Join-Path $env:SystemRoot 'System32') -Include '*.dll', '*.exe' ` + -Recurse -File -ErrorAction SilentlyContinue | + Where-Object { $_.Length -lt 40MB } + ) + Write-Host ("{0} files to read." -f $files.Count) + + $index = 0 + $hitCount = 0 + foreach ($file in $files) { + $index++ + if ($index % 250 -eq 0) { + Write-Host (" ... {0}/{1}, {2} file(s) with hits" -f $index, $files.Count, $hitCount) -ForegroundColor DarkGray + } + + $hits = @(Get-BinaryString -Path $file.FullName -Pattern $Match -MinimumLength 4 | Sort-Object -Unique) + if ($hits.Count -eq 0) { continue } + + $hitCount++ + Write-Host '' + Write-Host ("--- {0}" -f $file.FullName) -ForegroundColor Yellow + $hits | ForEach-Object { Write-Host " $_" } + } + + Write-Host '' + Write-Host ("Swept {0} files, {1} had hits." -f $files.Count, $hitCount) +} + +function Get-UpdateRegistrySnapshot { + <# + .SYNOPSIS + Flattens the Windows Update keys into comparable "key|name=value" lines. + #> + [CmdletBinding()] + param() + + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate' + ) + + foreach ($root in $roots) { + if (-not (Test-Path -LiteralPath $root)) { continue } + + $keys = @($root) + @( + Get-ChildItem -LiteralPath $root -Recurse -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty PSPath + ) + + foreach ($key in $keys) { + $values = Get-ItemProperty -LiteralPath $key -ErrorAction SilentlyContinue + if ($null -eq $values) { continue } + + foreach ($property in $values.PSObject.Properties) { + if ($property.Name -like 'PS*') { continue } + Write-Output ('{0}|{1}={2}' -f $key, $property.Name, ($property.Value -join ',')) + } + } + } +} + +function Get-OrchestratorTask { + <# + .SYNOPSIS + Lists the Update Orchestrator task files, read off disk so no + Windows-only cmdlet is needed. + #> + [CmdletBinding()] + param() + + $tasks = Join-Path $env:SystemRoot 'System32\Tasks\Microsoft\Windows\UpdateOrchestrator' + if (-not (Test-Path -LiteralPath $tasks)) { return } + + Get-ChildItem -LiteralPath $tasks -Recurse -File -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty FullName +} + +function Invoke-ButtonWatch { + <# + .SYNOPSIS + Reports what starts and what changes while you press the button. + + .DESCRIPTION + Processes are polled rather than subscribed to. An indication + subscription printed nothing until it ended, which looks identical + to finding nothing; polling shows each new process the moment it + appears, so the screen proves the watch is alive. + #> + [CmdletBinding()] + param([int]$Seconds) + + Write-Host '' + Write-Host '== Recording what the button does ==' -ForegroundColor Cyan + + $registryBefore = @(Get-UpdateRegistrySnapshot) + $tasksBefore = @(Get-OrchestratorTask) + Write-Host ("Baseline: {0} registry values, {1} orchestrator tasks." -f $registryBefore.Count, $tasksBefore.Count) + + $known = @{} + foreach ($process in Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue) { + $known[$process.ProcessId] = $true + } + + Write-Host '' + Write-Host 'Now press "Reinstall now" in Settings > System > Recovery.' -ForegroundColor Green + Write-Host ("Watching for {0} seconds. New processes appear below as they start." -f $Seconds) + Write-Host '' + + $started = [System.Collections.Generic.List[string]]::new() + $deadline = (Get-Date).AddSeconds($Seconds) + $lastTick = Get-Date + + try { + while ((Get-Date) -lt $deadline) { + foreach ($process in Get-CimInstance -ClassName Win32_Process -ErrorAction SilentlyContinue) { + if ($known.ContainsKey($process.ProcessId)) { continue } + $known[$process.ProcessId] = $true + $started.Add($process.Name) + + $colour = if ($process.Name -match $NotableProcess) { 'Magenta' } else { 'Green' } + Write-Host (' {0:HH:mm:ss} {1} (pid {2})' -f (Get-Date), $process.Name, $process.ProcessId) -ForegroundColor $colour + + $line = if ([string]::IsNullOrWhiteSpace($process.CommandLine)) { $process.ExecutablePath } else { $process.CommandLine } + if (-not [string]::IsNullOrWhiteSpace($line)) { + Write-Host (' {0}' -f $line) -ForegroundColor DarkGray + } + } + + # A heartbeat every 30s, so a quiet stretch still looks like progress. + if (((Get-Date) - $lastTick).TotalSeconds -ge 30) { + $lastTick = Get-Date + $remaining = [int]($deadline - (Get-Date)).TotalSeconds + Write-Host (" ... still watching, {0}s left" -f $remaining) -ForegroundColor DarkGray + } + + Start-Sleep -Milliseconds 700 + } + } + finally { + + Write-Host '' + Write-Host '--- Processes started while watching ---' -ForegroundColor Yellow + if ($started.Count -eq 0) { + Write-Host ' (none)' + } + else { + $started | Group-Object | Sort-Object -Property Count -Descending | + ForEach-Object { Write-Host (' {0,-40} x{1}' -f $_.Name, $_.Count) } + } + + Write-Host '' + Write-Host '--- Windows Update registry values that changed ---' -ForegroundColor Yellow + $registryDelta = @(Compare-Object -ReferenceObject $registryBefore -DifferenceObject @(Get-UpdateRegistrySnapshot)) + if ($registryDelta.Count -eq 0) { + Write-Host ' (nothing changed)' + } + else { + foreach ($change in $registryDelta) { + $sign = if ($change.SideIndicator -eq '=>') { 'new ' } else { 'gone' } + Write-Host (' [{0}] {1}' -f $sign, $change.InputObject) + } + } + + Write-Host '' + Write-Host '--- Update Orchestrator tasks that appeared ---' -ForegroundColor Yellow + $tasksDelta = @( + Compare-Object -ReferenceObject $tasksBefore -DifferenceObject @(Get-OrchestratorTask) | + Where-Object { $_.SideIndicator -eq '=>' } + ) + if ($tasksDelta.Count -eq 0) { + Write-Host ' (none)' + } + else { + $tasksDelta | ForEach-Object { Write-Host (' {0}' -f $_.InputObject) } + } + + } +} + +Write-Host 'pcHealth -- repair trigger hunt (read-only)' -ForegroundColor Cyan + +if ($Mode -in @('Settings', 'All')) { Invoke-SettingIdScan } +if ($Mode -in @('Protocols', 'All')) { Invoke-ProtocolScan } +if ($Mode -in @('Strings', 'All')) { Invoke-StringScan } +if ($Mode -eq 'Deep') { + if ([string]::IsNullOrWhiteSpace($File)) { + Write-Host 'Deep mode needs -File, for example:' -ForegroundColor Red + Write-Host ' .\Find-RepairTrigger.ps1 -Mode Deep -File "$env:SystemRoot\System32\SystemSettings.Handlers.dll"' + } + else { + Invoke-DeepScan -Path $File -Match $Pattern + } +} +if ($Mode -eq 'Locate') { + if ([string]::IsNullOrWhiteSpace($File)) { + Write-Host 'Locate mode needs -File, for example -File "MoUsoCoreWorker.exe"' -ForegroundColor Red + } + else { + Invoke-Locate -Name $File + } +} +if ($Mode -eq 'Hunt') { Invoke-Hunt -Match $Pattern } +if ($Mode -in @('Watch', 'All')) { Invoke-ButtonWatch -Seconds $WatchSeconds } + +Write-Host '' +Write-Host 'Done. Send the output back so the findings can be turned into a direct call.' -ForegroundColor Cyan 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/installer/pcHealth.wxs b/installer/pcHealth.wxs new file mode 100644 index 0000000..22c2745 --- /dev/null +++ b/installer/pcHealth.wxs @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/CLI/Start.ps1 deleted file mode 100644 index a8eb40b..0000000 --- a/src/CLI/Start.ps1 +++ /dev/null @@ -1,187 +0,0 @@ -#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. -# ============================================================================ - -$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 7) { - Write-Host "[!!] pcHealth cannot run on kernel $kernelStr." -ForegroundColor Red - Write-Host " Minimum required: kernel 7.0." -ForegroundColor Red - Write-Host " https://www.kernel.org/" -ForegroundColor DarkGray - Read-Host 'Press Enter to exit' - 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/CLI/Start.ps1' -ForegroundColor Yellow - exit 1 - } -} - -# -- Windows: build check, elevate, relaunch in PS7 --------------------------- -if (-not $onLinux) { - $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 26200) { - 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 - Read-Host 'Press Enter to exit' - exit 1 - } - - $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 - } - - # 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. - } -} - -# -- Dependency check ---------------------------------------------------------- -Write-Host '' -Write-Host '[pcHealth] Checking dependencies...' -ForegroundColor Cyan - -$pad = 24 -function Write-DepStatus($label, $ok, [bool]$Optional = $false) { - $dots = '.' * ($pad - $label.Length) - if ($ok) { - Write-Host " $label $dots OK" -ForegroundColor Green - } elseif ($Optional) { - Write-Host " $label $dots not installed" -ForegroundColor Yellow - } else { - Write-Host " $label $dots NOT FOUND" -ForegroundColor Red - } -} - -# On Linux, pwsh is already running — trivially satisfied. -$pwshOk = $onLinux -or [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) -} - -if (-not $onLinux) { Write-DepStatus 'PowerShell 7' $pwshOk } -Write-DepStatus -label 'smartmontools' -ok $smartctlOk -Optional $true - -# -- Install PowerShell 7 (Windows only) -------------------------------------- -if (-not $onLinux -and -not $pwshOk) { - Write-Host '' - Write-Host '[pcHealth] PowerShell 7 is required to run this application.' -ForegroundColor Yellow - - $answer = Read-Host ' Install now via winget? [Y/N]' - if ($answer -notmatch '^[Yy]') { - Write-Host '' - Write-Host '[!!] Cannot continue without PowerShell 7.' -ForegroundColor Red - Read-Host 'Press Enter to exit' - exit 1 - } - - if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Host '[!!] winget is not available. Install PowerShell 7 manually:' -ForegroundColor Red - Write-Host ' https://aka.ms/powershell' -ForegroundColor Cyan - Read-Host 'Press Enter to exit' - exit 1 - } - - Write-Host '' - Write-Host '[pcHealth] Installing PowerShell 7...' -ForegroundColor Cyan - winget install --source winget --id Microsoft.PowerShell -e --silent ` - --accept-package-agreements --accept-source-agreements - - $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + - [System.Environment]::GetEnvironmentVariable('Path', 'User') - - if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { - Write-Host '[!!] Installation completed but pwsh was not found. Please restart and try again.' -ForegroundColor Red - Read-Host 'Press Enter to exit' - exit 1 - } - - Write-Host '[OK] PowerShell 7 installed.' -ForegroundColor Green -} - -# -- Optional: smartmontools -------------------------------------------------- -if (-not $smartctlOk) { - Write-Host '' - 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 - 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) { - 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') - } else { - Write-Host '[!!] winget not available. Install from: https://www.smartmontools.org/' -ForegroundColor Yellow - } - - if (Get-Command smartctl -ErrorAction SilentlyContinue) { - Write-Host '[OK] smartmontools installed.' -ForegroundColor Green - } else { - Write-Host '[!!] Install may need a restart to take effect.' -ForegroundColor Yellow - } - } else { - Write-Host ' Skipping — SMART data will be limited.' -ForegroundColor DarkGray - } -} - -# -- Launch app ---------------------------------------------------------------- -Write-Host '' -Write-Host '[pcHealth] All dependencies satisfied. Starting pcHealth...' -ForegroundColor Green -Write-Host '' - -$appScript = Join-Path $PSScriptRoot 'app.ps1' -& pwsh -NoProfile -ExecutionPolicy Bypass -File $appScript diff --git a/src/CLI/app.ps1 b/src/CLI/app.ps1 deleted file mode 100644 index 94ef4ab..0000000 --- a/src/CLI/app.ps1 +++ /dev/null @@ -1,81 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- CLI -# Auto-detects platform (Windows/Linux) and loads 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 7) { - Write-Host "[!!] pcHealth cannot run on kernel $kernelVersion." -ForegroundColor Red - Write-Host " Minimum required: kernel 7.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/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. - $build = [System.Environment]::OSVersion.Version.Build - if ($build -lt 26200) { - 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 - exit 1 - } - $Global:PcPlatform = 'Windows' - $Global:PcPlatformLabel = 'Windows' -} else { - Write-Host "[!!] Unsupported platform. pcHealth supports Windows and Linux only." -ForegroundColor Red - 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: $_" - } -} - -# $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' -$Global:PcVersion = if (Test-Path $versionFile) { - (Get-Content $versionFile -Raw).Trim() -} else { 'unknown' } - -# 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') - -Show-MainMenu diff --git a/src/CLI/menus/Helpers.ps1 b/src/CLI/menus/Helpers.ps1 deleted file mode 100644 index fb647cb..0000000 --- a/src/CLI/menus/Helpers.ps1 +++ /dev/null @@ -1,271 +0,0 @@ -# ============================================================================ -# pcHealth -- Shared -- 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 }) -} - -# 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. -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 - } - } 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). -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' - } - 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 - $scriptName = if ($callerScript) { - [System.IO.Path]::GetFileNameWithoutExtension($callerScript) - } else { 'pcHealth' } - - $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' - "[$timestamp] $Message" | Out-File -FilePath (Join-Path $logDir "$scriptName.log") -Append -ErrorAction Stop - } catch { - Write-Debug "Write-PcLog: failed to write to log file: $_" - } - if ($IsError) { - Write-Host $Message -ForegroundColor Red - } else { - Write-Host $Message - } -} - -# 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. - [Console]::ResetColor() - [Console]::Clear() -} - -$Global:PcTheme = 'Main' - -function Set-PcTheme { - param([string]$Theme) - $Global:PcTheme = $Theme - # RawUI colour changes only work in ConsoleHost; skip silently in VS Code, - # Windows Terminal with transparency, or any other non-standard host. - if ($Host.Name -ne 'ConsoleHost') { return } - switch ($Theme) { - 'Main' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Cyan' } - 'Tools' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Red' } - 'Programs' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Green' } - 'Action' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Green' } - 'Danger' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Red' } - 'Warning' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Yellow' } - } -} - -function Write-PcHeader { - param([string]$Title) - $line = '=' * 60 - $headerColor = switch ($Global:PcTheme) { - 'Main' { 'Cyan' } - 'Tools' { 'Red' } - 'Programs' { 'Green' } - default { 'Cyan' } - } - Write-Host "`n$line" -ForegroundColor $headerColor - 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 } - } catch { $null } - if (-not $fullName) { - $fullName = if ($IsLinux) { (Get-PcDesktopUser)?.Name } else { $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 -} - -function Write-PcDivider { - Write-Host ('-' * 60) -ForegroundColor DarkGray -} - -function Write-PcOption { - param([string]$Key, [string]$Label, [string]$Note = '') - $pad = ' ' * [Math]::Max(1, 4 - $Key.Length) - $keyColor = switch ($Global:PcTheme) { - 'Main' { 'Cyan' } - 'Tools' { 'Red' } - 'Programs' { 'Green' } - default { 'Yellow' } - } - Write-Host ' ' -NoNewline - Write-Host "[$Key]" -ForegroundColor $keyColor -NoNewline - Write-Host "$pad$Label" -NoNewline - if ($Note) { Write-Host " $Note" -ForegroundColor DarkGray -NoNewline } - Write-Host '' -} - -# Shown after every tool finishes. Returns '1', '2', or '3'. -# '1' -> stay in current submenu -# '2' -> return to main menu -# '3' -> exit the application -function Read-PcNavChoice { - param([string]$BackLabel = 'Back to previous menu') - Write-Host '' - Write-PcDivider - Write-PcOption '1' $BackLabel - Write-PcOption '2' 'Main Menu' - Write-PcOption '3' 'Exit' - Write-PcDivider - return (Read-Host "`n Choice").Trim() -} diff --git a/src/CLI/menus/Tools.ps1 b/src/CLI/menus/Tools.ps1 deleted file mode 100644 index 46acd8e..0000000 --- a/src/CLI/menus/Tools.ps1 +++ /dev/null @@ -1,107 +0,0 @@ -# ============================================================================ -# pcHealth -- Shared -- Tools Menu -# Data-driven: options are filtered per platform at runtime so option numbers -# are always sequential with no gaps. -# ============================================================================ - -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. - $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') } - ) - - $active = @($toolDefs | Where-Object { - $_.Platforms -contains $Global:PcPlatform -and - -not ($_.NeedsMutableOS -and $Global:PcImageBased) - }) - $t = Join-Path $Global:pcHealthRoot 'tools' - - while ($true) { - Set-PcTheme 'Tools' - Clear-PcHost - Write-PcHeader 'Tools' - - for ($i = 1; $i -le $active.Count; $i++) { - Write-PcOption "$i" $active[$i - 1].Label $active[$i - 1].Note - } - - $nav1 = $active.Count + 1 - $nav2 = $active.Count + 2 - $nav3 = $active.Count + 3 - - Write-PcDivider - Write-PcOption "$nav1" 'Programs Menu' - Write-PcOption "$nav2" 'Back to Main Menu' - Write-PcOption "$nav3" 'Exit' - Write-PcDivider - - $choice = (Read-Host "`n Choice").Trim() - - $num = 0 - if (-not [int]::TryParse($choice, [ref]$num)) { - Write-Host "`n Invalid choice." -ForegroundColor Red - Start-Sleep -Milliseconds 800 - continue - } - - if ($num -ge 1 -and $num -le $active.Count) { - $entry = $active[$num - 1] - Set-PcTheme 'Action' - Clear-PcHost - try { - & (Join-Path $t $entry.Script) - } catch [System.Management.Automation.PipelineStoppedException] { - Write-Debug 'Tool stopped via Ctrl+C, returning to menu.' - } catch { - Write-Host "`n[!!] Tool error: $_`n" -ForegroundColor Red - Start-Sleep -Seconds 2 - } - $nav = Read-PcNavChoice 'Back to Tools Menu' - switch ($nav) { - '2' { return 'main' } - '3' { return 'exit' } - } - } elseif ($num -eq $nav1) { return 'programs' - } elseif ($num -eq $nav2) { return 'main' - } elseif ($num -eq $nav3) { return 'exit' - } else { - Write-Host "`n Invalid choice." -ForegroundColor Red - Start-Sleep -Milliseconds 800 - } - } -} diff --git a/src/CLI/tools/Get-HardwareInfo.ps1 b/src/CLI/tools/Get-HardwareInfo.ps1 deleted file mode 100644 index 00c806a..0000000 --- a/src/CLI/tools/Get-HardwareInfo.ps1 +++ /dev/null @@ -1,344 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Hardware Information -# CPU, GPU, Storage (SMART via smartmontools), RAM, Chipset. -# ============================================================================ - -function Write-SectionHeader { - param([string]$Title) - $prefix = '--- ' - $fill = '-' * [Math]::Max(0, 90 - $prefix.Length - $Title.Length - 1) - Write-Host "`n$prefix$Title $fill" -ForegroundColor Cyan -} - -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 } - } - return $null -} - -$smartctl = Find-Smartctl -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() - 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 } - } 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') - } - } - $smartctl = Find-Smartctl - if ($smartctl) { - Write-Host " [OK] smartmontools installed.`n" -ForegroundColor Green - } else { - Write-Host " [!!] Install may need a restart to take effect.`n" -ForegroundColor Yellow - } - } else { - Write-Host " Skipping — SMART data will be limited.`n" -ForegroundColor DarkGray - } -} - -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." - } - - # -- 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." - } - } - - # -- 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." - } - - # -- 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) - } - } - } - } - ) - 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 - } - 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 - } - } | 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' } - } - } - - $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/CLI/tools/Get-SystemInfo.ps1 b/src/CLI/tools/Get-SystemInfo.ps1 deleted file mode 100644 index 8d1bfce..0000000 --- a/src/CLI/tools/Get-SystemInfo.ps1 +++ /dev/null @@ -1,170 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# 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 -} diff --git a/src/CLI/tools/Invoke-PowerOptions.ps1 b/src/CLI/tools/Invoke-PowerOptions.ps1 deleted file mode 100644 index b6c4e7b..0000000 --- a/src/CLI/tools/Invoke-PowerOptions.ps1 +++ /dev/null @@ -1,69 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Shutdown / Reboot / Log Off -# ============================================================================ - -Write-Host "`n$('=' * 60)" -ForegroundColor Cyan -Write-Host " Power Options" -ForegroundColor Cyan -Write-Host "$('=' * 60)`n" -ForegroundColor Cyan - -Write-Host " [1] Log Off" -Write-Host " [2] Restart" -Write-Host " [3] Shutdown" -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 } - } -} 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 } - } -} diff --git a/src/CLI/tools/Test-Traceroute.ps1 b/src/CLI/tools/Test-Traceroute.ps1 deleted file mode 100644 index 2781654..0000000 --- a/src/CLI/tools/Test-Traceroute.ps1 +++ /dev/null @@ -1,37 +0,0 @@ -#Requires -Version 7.0 -# ============================================================================ -# pcHealth -- Traceroute to Google -# ============================================================================ -param( - [string]$Target = 'google.com' -) - -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 } - - 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 - } -} 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 - } -} diff --git a/src/CLI/tools/linux/Get-BatteryReport.ps1 b/src/CLI/tools/linux/Get-BatteryReport.ps1 deleted file mode 100644 index e689ee5..0000000 --- a/src/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/CLI/tools/linux/Get-SystemLogs.ps1 b/src/CLI/tools/linux/Get-SystemLogs.ps1 deleted file mode 100644 index 8c380a0..0000000 --- a/src/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/CLI/tools/linux/Invoke-AudioRestart.ps1 b/src/CLI/tools/linux/Invoke-AudioRestart.ps1 deleted file mode 100644 index 829d1b4..0000000 --- a/src/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/CLI/tools/linux/Invoke-BootRepair.ps1 b/src/CLI/tools/linux/Invoke-BootRepair.ps1 deleted file mode 100644 index 6726eac..0000000 --- a/src/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/CLI/tools/linux/Invoke-DiskCleanup.ps1 b/src/CLI/tools/linux/Invoke-DiskCleanup.ps1 deleted file mode 100644 index 4c69e83..0000000 --- a/src/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/CLI/tools/linux/Invoke-DiskOptimize.ps1 b/src/CLI/tools/linux/Invoke-DiskOptimize.ps1 deleted file mode 100644 index 9aa209c..0000000 --- a/src/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/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 b/src/CLI/tools/linux/Invoke-FirmwareUpdate.ps1 deleted file mode 100644 index e8f9606..0000000 --- a/src/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/CLI/tools/linux/Invoke-NetworkReset.ps1 b/src/CLI/tools/linux/Invoke-NetworkReset.ps1 deleted file mode 100644 index 1b065bd..0000000 --- a/src/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/CLI/tools/linux/Invoke-ScanAndRepair.ps1 b/src/CLI/tools/linux/Invoke-ScanAndRepair.ps1 deleted file mode 100644 index bba8f31..0000000 --- a/src/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/CLI/tools/linux/Invoke-SystemUpdate.ps1 b/src/CLI/tools/linux/Invoke-SystemUpdate.ps1 deleted file mode 100644 index 7551828..0000000 --- a/src/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/CLI/tools/linux/Invoke-Topgrade.ps1 b/src/CLI/tools/linux/Invoke-Topgrade.ps1 deleted file mode 100644 index 64a59c5..0000000 --- a/src/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 '' diff --git a/src/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs b/src/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs deleted file mode 100644 index 2ab642b..0000000 --- a/src/GUI/pcHealth/ViewModels/AudioRestartViewModel.cs +++ /dev/null @@ -1,72 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using NLog; -using pcHealth.Services; - -namespace pcHealth.ViewModels; - -public partial class AudioRestartViewModel : ObservableObject -{ - private static readonly Logger Log = LogManager.GetCurrentClassLogger(); - private readonly IProcessRunner _runner; - - [ObservableProperty] public partial bool AebRunning { get; set; } - [ObservableProperty] public partial bool AudioRunning { get; set; } - [ObservableProperty] public partial bool IsRunning { get; set; } - [ObservableProperty] public partial bool Succeeded { get; set; } - [ObservableProperty] public partial string ErrorMessage { get; set; } = ""; - - public AudioRestartViewModel(IProcessRunner runner) => _runner = runner; - - [RelayCommand] - public async Task LoadStatusAsync() - { - try - { - AebRunning = await IsServiceRunningAsync("AudioEndpointBuilder"); - AudioRunning = await IsServiceRunningAsync("Audiosrv"); - } - catch (Exception ex) - { - Log.Error(ex, "Audio service status check failed"); - ErrorMessage = ex.Message; - } - } - - [RelayCommand(CanExecute = nameof(CanRestart))] - public async Task RestartAsync() - { - IsRunning = true; - Succeeded = false; - ErrorMessage = ""; - try - { - await _runner.RunAsync("net.exe", "stop Audiosrv /yes", _ => { }); - await _runner.RunAsync("net.exe", "stop AudioEndpointBuilder /yes", _ => { }); - await Task.Delay(1000); - await _runner.RunAsync("net.exe", "start AudioEndpointBuilder", _ => { }); - await _runner.RunAsync("net.exe", "start Audiosrv", _ => { }); - AebRunning = await IsServiceRunningAsync("AudioEndpointBuilder"); - AudioRunning = await IsServiceRunningAsync("Audiosrv"); - Succeeded = true; - } - catch (Exception ex) - { - Log.Error(ex, "Audio service restart failed"); - ErrorMessage = ex.Message; - } - finally - { - IsRunning = false; - } - } - - private bool CanRestart() => !IsRunning; - - private async Task IsServiceRunningAsync(string name) - { - var sb = new System.Text.StringBuilder(); - await _runner.RunAsync("sc.exe", $"query {name}", line => sb.AppendLine(line)); - return sb.ToString().Contains("RUNNING", StringComparison.OrdinalIgnoreCase); - } -} diff --git a/src/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs b/src/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs deleted file mode 100644 index d3cb369..0000000 --- a/src/GUI/pcHealth/ViewModels/HPUpdateViewModel.cs +++ /dev/null @@ -1,52 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using NLog; -using pcHealth.Services; - -namespace pcHealth.ViewModels; - -public partial class HPUpdateViewModel : ObservableObject -{ - private static readonly Logger Log = LogManager.GetCurrentClassLogger(); - private readonly IProcessRunner _runner; - - [ObservableProperty] public partial string Output { get; set; } = ""; - [ObservableProperty] public partial string Status { get; set; } = ""; - [ObservableProperty] public partial bool IsRunning { get; set; } - - public HPUpdateViewModel(IProcessRunner runner) => _runner = runner; - - [RelayCommand(CanExecute = nameof(CanInstall), IncludeCancelCommand = true)] - public async Task InstallAsync(CancellationToken ct) - { - IsRunning = true; - Output = ""; - Status = "Installing…"; - - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - void Append(string line) => dispatcher.TryEnqueue(() => Output += line + "\n"); - - try - { - await _runner.RunAsync("winget.exe", - "install --id HP.ImageAssistant --accept-source-agreements --accept-package-agreements", - Append, ct); - Status = "Done. Launch HP Image Assistant to update drivers."; - } - catch (OperationCanceledException) - { - Status = "Cancelled."; - } - catch (Exception ex) - { - Log.Error(ex, "HP Image Assistant install failed"); - Status = $"Error: {ex.Message}"; - } - finally - { - IsRunning = false; - } - } - - private bool CanInstall() => !IsRunning; -} diff --git a/src/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs b/src/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs deleted file mode 100644 index e8c8a2e..0000000 --- a/src/GUI/pcHealth/ViewModels/SystemUpdateViewModel.cs +++ /dev/null @@ -1,54 +0,0 @@ -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using NLog; -using pcHealth.Services; - -namespace pcHealth.ViewModels; - -public partial class SystemUpdateViewModel : ObservableObject -{ - private static readonly Logger Log = LogManager.GetCurrentClassLogger(); - private readonly IProcessRunner _runner; - - [ObservableProperty] public partial string Output { get; set; } = ""; - [ObservableProperty] public partial string Status { get; set; } = ""; - [ObservableProperty] public partial bool IsRunning { get; set; } - - public SystemUpdateViewModel(IProcessRunner runner) => _runner = runner; - - [RelayCommand(CanExecute = nameof(CanRun), IncludeCancelCommand = true)] - public async Task RunAsync(CancellationToken ct) - { - IsRunning = true; - Output = ""; - Status = "Updating packages…"; - - var dispatcher = Microsoft.UI.Dispatching.DispatcherQueue.GetForCurrentThread(); - void Append(string line) => dispatcher.TryEnqueue(() => Output += line + "\n"); - - try - { - await _runner.RunAsync("winget.exe", - "upgrade --all --accept-source-agreements --accept-package-agreements", - Append, ct); - Status = "Done."; - } - catch (OperationCanceledException) - { - Output += "\n[Cancelled]"; - Status = "Cancelled."; - } - catch (Exception ex) - { - Log.Error(ex, "System update failed"); - Output += $"\n[Error] {ex.Message}"; - Status = "Error."; - } - finally - { - IsRunning = false; - } - } - - private bool CanRun() => !IsRunning; -} diff --git a/src/Linux/README.md b/src/Linux/README.md new file mode 100644 index 0000000..c5b7e7b --- /dev/null +++ b/src/Linux/README.md @@ -0,0 +1,101 @@ +# 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 + smart.py SMART data, shared by Hardware Information and Health + health.py the health report: sections of checks, each with a status + 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, and it never renders a menu of +its own. It emits styled lines and *declares* the choices it needs: + +```python +choice = ctx.choose( + "What should happen?", + [ + Choice("restart", "Restart", "Restarts the system immediately.", destructive=True), + Choice("shutdown", "Shut Down", "Powers the system off immediately.", destructive=True), + ], +) +``` + +The terminal renders that as a numbered list, the GUI as one button per +option. The moment a tool prints `[1] ... [2] ...` itself, it has decided it +lives in a terminal and the GUI is stuck showing a text box for it. + +## 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/GUI/.gitkeep b/src/Linux/pchealth/cli/__init__.py similarity index 100% rename from src/GUI/.gitkeep rename to src/Linux/pchealth/cli/__init__.py diff --git a/src/Linux/pchealth/cli/menu.py b/src/Linux/pchealth/cli/menu.py new file mode 100644 index 0000000..27b52d9 --- /dev/null +++ b/src/Linux/pchealth/cli/menu.py @@ -0,0 +1,171 @@ +"""The terminal menus.""" + +from __future__ import annotations + +from .. import catalog, health, system +from ..tools import REGISTRY, Cancelled +from ..version import get_version +from . import programs, theme, ui + +REPO_URL = "https://github.com/REALSDEALS/pcHealth" +RELEASES_URL = f"{REPO_URL}/releases" + + +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(ui.TerminalUI()) + 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") + + +_STATUS_STYLE = { + health.Status.GOOD: "ok", + health.Status.WARNING: "warn", + health.Status.BAD: "error", + health.Status.UNKNOWN: "muted", + health.Status.INFO: "info", +} + + +def _health_screen() -> str: + theme.header("Health", "Reading system state...") + + sections = health.collect() + overall = health.overall(sections) + theme.write(f" Overall: {overall.value.upper()}", _STATUS_STYLE[overall]) + + for section in sections: + theme.write() + theme.write(f" {section.title}", "head") + width = max((len(check.label) for check in section.checks), default=0) + for check in section.checks: + row = f" {check.label.ljust(width)} {check.value}" + theme.write(row, _STATUS_STYLE[check.status]) + if check.detail: + theme.write(f" {' ' * width} {check.detail}", "muted") + + theme.write() + nav = input(" [1] Back to Main Menu [2] Exit: ").strip() + return "exit" if nav == "2" else "main" + + +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", "Health") + theme.option("2", "Tools") + theme.option("3", "Programs") + theme.write() + theme.option("4", "Go to repository") + theme.option("5", "Check for pre-releases") + theme.write() + theme.option("6", "Exit") + theme.write() + + choice = input(" Choice: ").strip() + if choice == "1": + return "health" + if choice == "2": + return "tools" + if choice == "3": + return "programs" + if choice == "4": + system.open_url(REPO_URL) + return "main" + if choice == "5": + system.open_url(RELEASES_URL) + return "main" + if choice == "6": + return "exit" + + theme.write("Invalid choice.", "error") + return "main" + + +def run() -> int: + target = "main" + while True: + try: + if target == "health": + target = _health_screen() + elif 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/cli/ui.py b/src/Linux/pchealth/cli/ui.py new file mode 100644 index 0000000..e950b02 --- /dev/null +++ b/src/Linux/pchealth/cli/ui.py @@ -0,0 +1,73 @@ +"""The terminal rendering of a tool. + +Here the structural calls become text: a section is a heading, fields are an +aligned block, a step prints its label and its output. The GTK front-end takes +the same calls and builds widgets instead. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from ..tools.base import Cancelled, Choice, Level, Step, ToolUI +from . import theme + +_STYLE = { + Level.INFO: "info", + Level.OK: "ok", + Level.WARN: "warn", + Level.ERROR: "error", +} + + +class TerminalStep(Step): + def write(self, line: str) -> None: + theme.write(f" {line}", "muted") + + def close(self, ok: bool, summary: str) -> None: + theme.write(f" {summary}", "ok" if ok else "error") + + +class TerminalUI(ToolUI): + def section(self, title: str) -> None: + theme.write() + theme.write(f" {title}", "head") + + def fields(self, rows: Sequence[tuple[str, str]]) -> None: + if not rows: + return + width = max(len(label) for label, _ in rows) + for label, value in rows: + theme.write(f" {label.ljust(width)} {value}") + + def note(self, text: str, level: Level = Level.INFO) -> None: + theme.write(f" {text}", _STYLE[level] if level is not Level.INFO else "muted") + + def step(self, label: str) -> Step: + theme.write(f" {label}...", "info") + return TerminalStep() + + def choose(self, question: str, options: Sequence[Choice]) -> str | None: + while True: + theme.write() + for index, option in enumerate(options, start=1): + theme.option(str(index), option.label, option.detail) + theme.option("B", "Back") + theme.write() + + answer = self._prompt(question).strip() + if answer.upper() == "B": + return None + if answer.isdigit() and 1 <= int(answer) <= len(options): + return options[int(answer) - 1].key + theme.write("Invalid choice.", "error") + + def confirm(self, question: str) -> bool: + return self._prompt(f"{question} (y/n)").strip().lower() in ("y", "yes") + + @staticmethod + def _prompt(text: str) -> str: + try: + return input(f" {text}: ") + except EOFError as exc: + raise Cancelled("no input available") from exc 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..544d861 --- /dev/null +++ b/src/Linux/pchealth/gui/app.py @@ -0,0 +1,98 @@ +"""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: python3 -m pchealth +""" + +# On Silverblue, Bazzite, Kinoite and MicroOS the package manager above cannot +# install into /usr at all, so pointing at it would just waste the user's time. +_MISSING_GTK_IMAGE_BASED = """pcHealth GUI needs PyGObject with GTK 4 and libadwaita. + +This is an image-based system, so /usr is read-only and a normal package +install will not work. Either layer it onto the image: + + rpm-ostree install python3-gobject + systemctl reboot + +or run pcHealth inside a toolbox, where installing is ordinary again: + + toolbox enter + sudo dnf install python3-gobject gtk4 libadwaita + +The terminal version needs none of this -- run: python3 -m 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): + from .. import system + + hint = _MISSING_GTK_IMAGE_BASED if system.is_image_based() else _MISSING_GTK + print(hint, 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) + + +def _run_as_script() -> int: + """Entry point for `python app.py`, which has no package context. + + Relative imports fail when this file is run as a plain script rather than + through the package, so put src/Linux on the path and re-enter the module + the normal way. People do reach for the file they are looking at; that + should work, not produce an ImportError. + + This only works here because the gi and window imports live inside main(). + A module whose imports run at import time -- __main__.py, for one -- fails + before any guard like this can help, so it has none. + """ + import pathlib + import sys + + sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) + from pchealth.gui.app import main as packaged_main + + return packaged_main() + + +if __name__ == "__main__": + raise SystemExit(main() if __package__ else _run_as_script()) diff --git a/src/Linux/pchealth/gui/dialogs.py b/src/Linux/pchealth/gui/dialogs.py new file mode 100644 index 0000000..144af5a --- /dev/null +++ b/src/Linux/pchealth/gui/dialogs.py @@ -0,0 +1,89 @@ +"""Questions a tool asks, rendered as dialogs. + +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 collections.abc import Sequence +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 + +from ..tools import Choice # 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") + +_CANCEL = "__cancel__" + + +def _ask( + parent: Gtk.Window, + heading: str, + body: str, + responses: Sequence[tuple[str, str, bool]], +) -> str: + """Show a dialog with one button per response and wait for the answer. + + Each response is (id, label, destructive). Runs from a worker thread. + """ + done = threading.Event() + answer = _CANCEL + + def build() -> bool: + dialog = _ALERT(heading=heading, body=body) + dialog.add_response(_CANCEL, "Cancel") + for response_id, label, destructive in responses: + dialog.add_response(response_id, label) + appearance = ( + Adw.ResponseAppearance.DESTRUCTIVE + if destructive + else Adw.ResponseAppearance.SUGGESTED + ) + dialog.set_response_appearance(response_id, appearance) + dialog.set_default_response(responses[-1][0] if responses else _CANCEL) + dialog.set_close_response(_CANCEL) + + def on_response(_dialog: Any, response: str) -> None: + nonlocal answer + answer = response + done.set() + + dialog.connect("response", on_response) + if _USES_ALERT_DIALOG: + dialog.present(parent) + else: + dialog.set_transient_for(parent) + dialog.present() + return GLib.SOURCE_REMOVE + + GLib.idle_add(build) + done.wait() + return answer + + +def confirm(parent: Gtk.Window, question: str) -> bool: + return _ask(parent, "pcHealth", question, [("ok", "Continue", True)]) == "ok" + + +def choose(parent: Gtk.Window, question: str, options: Sequence[Choice]) -> str | None: + """One button per option -- never a text box asking for a number.""" + body = "\n".join(f"{o.label} — {o.detail}" if o.detail else o.label for o in options) + answer = _ask( + parent, + question, + body, + [(option.key, option.label, option.destructive) for option in options], + ) + return None if answer == _CANCEL else answer diff --git a/src/Linux/pchealth/gui/toolui.py b/src/Linux/pchealth/gui/toolui.py new file mode 100644 index 0000000..74f12d2 --- /dev/null +++ b/src/Linux/pchealth/gui/toolui.py @@ -0,0 +1,169 @@ +"""The GTK rendering of a tool. + +The terminal turns these calls into text; here they become widgets. A section +is a group, fields are rows, a note is a styled row, and a step is an expander +whose raw command output stays folded away -- because if you wanted to read a +console you would have run the terminal version. + +Tools run on a worker thread and GTK may only be touched from the main loop, +so every method below schedules its work with GLib.idle_add. Those callbacks +run in the order they were queued, which is what lets a step's output arrive +after the step's row has been built. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable, Sequence + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, GLib, Gtk, Pango # noqa: E402 + +from ..tools.base import Choice, Level, Step, ToolUI # noqa: E402 +from . import dialogs # noqa: E402 + +_NOTE_CLASS = { + Level.INFO: "dim-label", + Level.OK: "success", + Level.WARN: "warning", + Level.ERROR: "error", +} + + +def _wrapping_row(title: str, subtitle: str = "", css: str = "") -> Adw.ActionRow: + row = Adw.ActionRow(title=title, subtitle=subtitle) + # Long values -- a model name, a command line, a log line -- must wrap + # rather than run off the edge of the window. + row.set_title_lines(0) + row.set_subtitle_lines(0) + if css: + row.add_css_class(css) + return row + + +class GtkStep(Step): + """One step: a row with a spinner, and its output folded behind it.""" + + def __init__(self, add_group: Callable[[Gtk.Widget], None], label: str) -> None: + super().__init__() + self._pending: list[str] = [] + self._output: Gtk.Label | None = None + self._row: Adw.ExpanderRow | None = None + self._spinner = Gtk.Spinner(spinning=True, valign=Gtk.Align.CENTER) + GLib.idle_add(self._build, add_group, label) + + def _build(self, add_group: Callable[[Gtk.Widget], None], label: str) -> bool: + self._row = Adw.ExpanderRow(title=label) + self._row.set_title_lines(0) + self._row.add_suffix(self._spinner) + + self._output = Gtk.Label( + xalign=0, + selectable=True, + wrap=True, + wrap_mode=Pango.WrapMode.WORD_CHAR, + margin_top=8, + margin_bottom=8, + margin_start=12, + margin_end=12, + css_classes=["monospace", "dim-label"], + ) + scroller = Gtk.ScrolledWindow( + child=self._output, max_content_height=280, propagate_natural_height=True + ) + self._row.add_row(Adw.ActionRow(child=scroller, activatable=False)) + + add_group(self._row) + self._render() + return GLib.SOURCE_REMOVE + + def _render(self) -> bool: + if self._output is not None: + self._output.set_text("\n".join(self._pending)) + return GLib.SOURCE_REMOVE + + def write(self, line: str) -> None: + self._pending.append(line) + GLib.idle_add(self._render) + + def close(self, ok: bool, summary: str) -> None: + def finish() -> bool: + if self._row is None: + return GLib.SOURCE_REMOVE + self._row.remove(self._spinner) + self._row.add_suffix( + Gtk.Label( + label=summary, + css_classes=["caption", "success" if ok else "error"], + valign=Gtk.Align.CENTER, + ) + ) + # Nothing to expand when the command said nothing. + self._row.set_enable_expansion(bool(self._pending)) + return GLib.SOURCE_REMOVE + + GLib.idle_add(finish) + + +class GtkToolUI(ToolUI): + def __init__( + self, page: Adw.PreferencesPage, window: Gtk.Window, stop: threading.Event + ) -> None: + self._page = page + self._window = window + self._stop = stop + self._group: Adw.PreferencesGroup | None = None + + # -- Building blocks ----------------------------------------------------- + + def _add(self, widget: Gtk.Widget) -> None: + """Append a row, opening an untitled group if no section was declared.""" + if self._group is None: + self.section("") + assert self._group is not None + self._group.add(widget) + + def _later(self, action: Callable[[], None]) -> None: + """Run a widget change on the main loop, which is the only place GTK allows it.""" + + def once() -> bool: + action() + return GLib.SOURCE_REMOVE + + GLib.idle_add(once) + + def section(self, title: str) -> None: + group = Adw.PreferencesGroup(title=title) + self._group = group + self._later(lambda: self._page.add(group)) + + def fields(self, rows: Sequence[tuple[str, str]]) -> None: + built = [_wrapping_row(label, value) for label, value in rows] + + def add_all() -> None: + for row in built: + self._add(row) + + self._later(add_all) + + def note(self, text: str, level: Level = Level.INFO) -> None: + row = _wrapping_row(text, css=_NOTE_CLASS[level]) + self._later(lambda: self._add(row)) + + def step(self, label: str) -> Step: + return GtkStep(self._add, label) + + # -- Questions ----------------------------------------------------------- + + def choose(self, question: str, options: Sequence[Choice]) -> str | None: + return dialogs.choose(self._window, question, options) + + def confirm(self, question: str) -> bool: + return dialogs.confirm(self._window, question) + + def should_stop(self) -> bool: + return self._stop.is_set() diff --git a/src/Linux/pchealth/gui/window.py b/src/Linux/pchealth/gui/window.py new file mode 100644 index 0000000..0d6e698 --- /dev/null +++ b/src/Linux/pchealth/gui/window.py @@ -0,0 +1,258 @@ +"""The main window. + +Laid out like the WinUI 3 app on the Windows side: a navigation sidebar, a +Tools page whose entries are grouped cards, and one page per tool with its own +title, description and Run button. Tool output lands in that page rather than +in a single shared console, so switching tools never shows you the previous +tool's text. +""" + +from __future__ import annotations + +import threading +from collections.abc import Callable + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from gi.repository import Adw, GLib, Gtk # noqa: E402 + +from .. import catalog, health, system # noqa: E402 +from ..tools import REGISTRY, Cancelled, Level # noqa: E402 +from ..version import get_version # noqa: E402 +from .toolui import GtkToolUI # noqa: E402 + +REPO_URL = "https://github.com/REALSDEALS/pcHealth" + +# Health statuses map onto libadwaita's own semantic classes, so they follow +# the theme instead of carrying hardcoded colours around. +_STATUS_CLASS = { + health.Status.GOOD: "success", + health.Status.WARNING: "warning", + health.Status.BAD: "error", + health.Status.UNKNOWN: "dim-label", + health.Status.INFO: "dim-label", +} +_STATUS_LABEL = { + health.Status.GOOD: "OK", + health.Status.WARNING: "Check", + health.Status.BAD: "Problem", + health.Status.UNKNOWN: "Unknown", + health.Status.INFO: "", +} + + +class ToolPage(Adw.NavigationPage): + """One tool: its description, a Run button, and its results as rows.""" + + def __init__(self, tool: catalog.Tool, window: Gtk.Window) -> None: + super().__init__(title=tool.name) + self._tool = tool + self._window = window + self._worker: threading.Thread | None = None + self._stop = threading.Event() + + self._results = Adw.PreferencesPage() + self._run = Gtk.Button(label="Run", css_classes=["suggested-action"]) + self._stop_button = Gtk.Button(label="Stop", sensitive=False) + self._run.connect("clicked", self._on_run) + self._stop_button.connect("clicked", lambda _b: self._stop.set()) + + header = Adw.HeaderBar() + header.pack_end(self._run) + header.pack_end(self._stop_button) + + self._placeholder = Adw.StatusPage( + title=tool.name, + description=tool.note or f"{tool.category} tool. Press Run to start.", + icon_name="media-playback-start-symbolic", + ) + self._body = Gtk.Stack() + self._body.add_named(self._placeholder, "idle") + self._body.add_named(self._results, "results") + + view = Adw.ToolbarView() + view.add_top_bar(header) + view.set_content(self._body) + self.set_child(view) + + def _on_run(self, _button: Gtk.Button) -> None: + if self._worker and self._worker.is_alive(): + return + implementation = REGISTRY.get(self._tool.id) + if implementation is None: + return + + # A fresh page per run: results from the previous run must not linger. + self._body.remove(self._results) + self._results = Adw.PreferencesPage() + self._body.add_named(self._results, "results") + self._body.set_visible_child_name("results") + + self._stop.clear() + self._run.set_sensitive(False) + self._stop_button.set_sensitive(True) + + ui = GtkToolUI(self._results, self._window, self._stop) + + def work() -> None: + try: + implementation(ui) + except Cancelled: + ui.note("Cancelled.", Level.INFO) + except OSError as exc: + ui.note(f"Tool error: {exc}", Level.ERROR) + finally: + GLib.idle_add(self._finish) + + self._worker = threading.Thread(target=work, daemon=True, name=f"pchealth-{self._tool.id}") + self._worker.start() + + def _finish(self) -> bool: + self._stop_button.set_sensitive(False) + self._run.set_sensitive(True) + return GLib.SOURCE_REMOVE + + +def _tools_page(open_tool: Callable[[catalog.Tool], None]) -> Adw.NavigationPage: + """The tool list, grouped by category the way the WinUI 3 page groups it.""" + page = Adw.PreferencesPage() + + # Group properly rather than starting a new heading on every change: the + # catalogue is in menu order, so categories interleave and a naive scan + # prints "Updates" three times. + by_category: dict[str, list[catalog.Tool]] = {} + for tool in catalog.active(): + by_category.setdefault(tool.category, []).append(tool) + + for category, tools in by_category.items(): + group = Adw.PreferencesGroup(title=category) + for tool in tools: + row = Adw.ActionRow(title=tool.name, subtitle=tool.note, activatable=True) + row.add_suffix(Gtk.Image.new_from_icon_name("go-next-symbolic")) + row.connect("activated", lambda _row, t=tool: open_tool(t)) + group.add(row) + page.add(group) + + return Adw.NavigationPage(title="Tools", child=page) + + +def _health_page() -> Adw.NavigationPage: + """The same report the terminal prints, as rows with a status icon.""" + page = Adw.PreferencesPage() + sections = health.collect() + + summary = health.overall(sections) + banner = Adw.PreferencesGroup(title="Overall") + banner.add( + Adw.ActionRow( + title=summary.value.capitalize(), + subtitle=f"{len(sections)} areas checked", + css_classes=[_STATUS_CLASS[summary]], + ) + ) + page.add(banner) + + for section in sections: + group = Adw.PreferencesGroup(title=section.title) + for check in section.checks: + row = Adw.ActionRow(title=check.label, subtitle=check.value) + if check.detail: + row.set_tooltip_text(check.detail) + label = _STATUS_LABEL[check.status] + if label: + row.add_suffix( + Gtk.Label(label=label, css_classes=["caption", _STATUS_CLASS[check.status]]) + ) + group.add(row) + page.add(group) + + return Adw.NavigationPage(title="Health", child=page) + + +def _about_page() -> Adw.NavigationPage: + status = Adw.StatusPage( + icon_name="help-about-symbolic", + title="pcHealth", + description=( + f"Version {get_version()}\n\n" + "Check the health of your Linux installation, drivers, updates and battery.\n" + "Made by REALSDEALS — licensed under GNU GPL-3." + ), + ) + link = Gtk.Button(label="Open the repository", halign=Gtk.Align.CENTER, css_classes=["pill"]) + link.connect("clicked", lambda _b: system.open_url(REPO_URL)) + status.set_child(link) + return Adw.NavigationPage(title="About", child=status) + + +class MainWindow(Adw.ApplicationWindow): + def __init__(self, application: Adw.Application) -> None: + super().__init__( + application=application, + title="pcHealth", + default_width=1100, + default_height=720, + ) + + self._content = Adw.NavigationView() + self._pages = { + "health": _health_page, + "tools": lambda: _tools_page(self._open_tool), + "about": _about_page, + } + + sidebar = Gtk.ListBox( + css_classes=["navigation-sidebar"], + selection_mode=Gtk.SelectionMode.SINGLE, + ) + for key, label, icon in ( + ("health", "Health", "utilities-system-monitor-symbolic"), + ("tools", "Tools", "applications-utilities-symbolic"), + ("about", "About", "help-about-symbolic"), + ): + row = Adw.ActionRow(title=label) + row.add_prefix(Gtk.Image.new_from_icon_name(icon)) + row.page_key = key + sidebar.append(row) + sidebar.connect("row-selected", self._on_nav) + + sidebar_view = Adw.ToolbarView() + sidebar_view.add_top_bar( + Adw.HeaderBar( + title_widget=Adw.WindowTitle(title="pcHealth", subtitle=f"v{get_version()}"), + show_end_title_buttons=False, + ) + ) + sidebar_view.set_content(Gtk.ScrolledWindow(child=sidebar, vexpand=True)) + # Said once, at the bottom, rather than crowding the window controls. + if not system.is_root(): + sidebar_view.add_bottom_bar( + Gtk.Label( + label="Each action asks for elevation", + css_classes=["dim-label", "caption"], + margin_top=8, + margin_bottom=8, + ) + ) + + self.set_content( + Adw.NavigationSplitView( + sidebar=Adw.NavigationPage(title="pcHealth", child=sidebar_view), + content=Adw.NavigationPage(title="pcHealth", child=self._content), + min_sidebar_width=240, + ) + ) + + sidebar.select_row(sidebar.get_row_at_index(0)) + + def _on_nav(self, _list: Gtk.ListBox, row: Gtk.ListBoxRow | None) -> None: + key = getattr(row, "page_key", None) + if key is None: + return + self._content.replace([self._pages[key]()]) + + def _open_tool(self, tool: catalog.Tool) -> None: + self._content.push(ToolPage(tool, self)) diff --git a/src/Linux/pchealth/health.py b/src/Linux/pchealth/health.py new file mode 100644 index 0000000..d461bed --- /dev/null +++ b/src/Linux/pchealth/health.py @@ -0,0 +1,413 @@ +"""The health report. + +The Linux counterpart of the WinUI 3 Health page: a handful of sections, each +a list of checks, each check carrying a status so a front-end can colour it. +The gathering lives here; the terminal and the GTK window only render it. + +What is checked differs from Windows because the systems differ -- there is no +Defender or BitLocker here, but there are CPU mitigations, a firewall, an LSM +and a package count. The shape of the answer is the same. +""" + +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass, field +from enum import Enum +from functools import lru_cache +from pathlib import Path + +from . import probe, smart, system + + +class Status(Enum): + GOOD = "good" + WARNING = "warning" + BAD = "bad" + UNKNOWN = "unknown" + INFO = "info" + + +_PSEUDO_FILESYSTEMS = frozenset( + { + "", + "autofs", + "binfmt_misc", + "bpf", + "cgroup", + "cgroup2", + "configfs", + "debugfs", + "devpts", + "devtmpfs", + "efivarfs", + "fusectl", + "hugetlbfs", + "mqueue", + "overlay", + "proc", + "pstore", + "ramfs", + "securityfs", + "squashfs", + "sysfs", + "tmpfs", + "tracefs", + } +) + +# Worst-first, so a section takes the colour of its most serious finding. +_SEVERITY = {Status.BAD: 4, Status.WARNING: 3, Status.UNKNOWN: 2, Status.GOOD: 1, Status.INFO: 0} + + +@dataclass(frozen=True) +class Check: + label: str + value: str + status: Status = Status.INFO + detail: str = "" + + +@dataclass(frozen=True) +class Section: + title: str + checks: list[Check] = field(default_factory=list) + + @property + def status(self) -> Status: + if not self.checks: + return Status.UNKNOWN + return max((check.status for check in self.checks), key=lambda s: _SEVERITY[s]) + + +# -- Shared hardware database ------------------------------------------------- + + +@lru_cache(maxsize=1) +def _hardware_db() -> dict[str, list[dict[str, object]]]: + """The same assets/hardware-db.json the WinUI 3 Health page reads.""" + for candidate in ( + Path(__file__).resolve().parent / "hardware-db.json", + Path(__file__).resolve().parents[3] / "assets" / "hardware-db.json", + ): + try: + data = json.loads(candidate.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + continue + return {key: value for key, value in data.items() if isinstance(value, list)} + return {} + + +def _release_year(name: str, table: str, key: str) -> int | None: + for entry in _hardware_db().get(table, []): + needle = str(entry.get(key, "")) + if needle and needle.lower() in name.lower(): + year = entry.get("year") + return int(year) if isinstance(year, int) else None + return None + + +def _age_status(year: int | None, *, warn_after: int = 7, bad_after: int = 10) -> Status: + if year is None: + return Status.UNKNOWN + from datetime import date + + age = date.today().year - year + if age >= bad_after: + return Status.WARNING if age < bad_after + 5 else Status.BAD + return Status.WARNING if age >= warn_after else Status.GOOD + + +# -- Sections ----------------------------------------------------------------- + + +def _overview() -> Section: + info = system.distro_info() + vendor = system.read_text("/sys/class/dmi/id/sys_vendor") + model = system.read_text("/sys/class/dmi/id/product_name") + uefi = Path("/sys/firmware/efi").exists() + + checks = [ + Check("Distribution", info["PRETTY_NAME"]), + Check("Kernel", system.kernel_release(), Status.GOOD), + Check("Machine", f"{vendor} {model}" if vendor and model else model or "Unknown"), + Check( + "Firmware", + "UEFI" if uefi else "Legacy BIOS", + Status.GOOD if uefi else Status.WARNING, + "" if uefi else "Boot Repair only supports UEFI systems.", + ), + Check("Uptime", probe.uptime_text()), + ] + if system.is_image_based(): + checks.append( + Check( + "Deployment", + "Image-based (ostree)", + Status.INFO, + "Package and boot tools are hidden.", + ) + ) + return Section("Overview", checks) + + +def _cpu() -> Section: + info = probe.cpu() + year = _release_year(info.model, "cpu_models", "name") + checks = [ + Check( + "Processor", + info.model, + _age_status(year), + f"Released {year}" if year else "Not in the hardware database", + ), + Check("Cores / threads", f"{info.cores} / {info.threads}"), + ] + + # Mitigations are the closest Linux equivalent of the Windows security rows. + if probe.has_vulnerability_reporting(): + checks.append( + Check( + "CPU mitigations", + "All mitigated" if not info.vulnerable else f"{len(info.vulnerable)} vulnerable", + Status.GOOD if not info.vulnerable else Status.WARNING, + ", ".join(info.vulnerable), + ) + ) + return Section("Processor", checks) + + +def _graphics() -> Section: + checks = [] + for name in probe.gpus(): + year = _release_year(name, "gpu_series", "pattern") + checks.append(Check("GPU", name, _age_status(year), f"Released {year}" if year else "")) + return Section("Graphics", checks or [Check("GPU", "None detected", Status.UNKNOWN)]) + + +def _memory() -> Section: + values = probe.meminfo() + total = values.get("MemTotal", 0) + if not total: + return Section("Memory", [Check("RAM", "Not readable", Status.UNKNOWN)]) + + available = values.get("MemAvailable", 0) + used_pct = round((total - available) / total * 100) + swap_total = values.get("SwapTotal", 0) + + return Section( + "Memory", + [ + Check( + "RAM", + f"{total / 1048576:.1f} GB total, {used_pct}% in use", + Status.BAD if used_pct >= 95 else Status.WARNING if used_pct >= 85 else Status.GOOD, + ), + Check( + "Swap", + f"{swap_total / 1048576:.1f} GB" if swap_total else "None configured", + Status.INFO if swap_total else Status.WARNING, + ), + ], + ) + + +def _storage() -> Section: + checks: list[Check] = [] + + for device in smart.devices(): + status = ( + Status.GOOD + if device.passed + else Status.BAD + if device.passed is False + else Status.UNKNOWN + ) + detail = [] + if device.life_left_pct is not None: + detail.append(f"life {device.life_left_pct}%") + if device.life_left_pct < 20: + status = Status.WARNING if status is Status.GOOD else status + if device.temperature_c is not None: + detail.append(f"{device.temperature_c} C") + if device.power_on_hours is not None: + detail.append(f"{device.power_on_hours} h") + checks.append(Check(device.model, device.health_text, status, ", ".join(detail))) + + if not checks and not smart.available(): + checks.append( + Check( + "SMART", + "smartmontools not installed", + Status.UNKNOWN, + "No disk health data available.", + ) + ) + + # Filesystem usage: the thing that actually breaks a machine day to day. + seen: set[int] = set() + for mount in probe.mounts(): + if mount.fstype in _PSEUDO_FILESYSTEMS: + continue + try: + usage = shutil.disk_usage(mount.target) + except OSError: + continue + # Bind mounts and container overlays repeat the same device, and a + # sub-gigabyte mount is a boot partition or a container detail, not + # something anyone needs a health warning about. + if usage.total in seen or usage.total < 1024**3: + continue + seen.add(usage.total) + used_pct = round(usage.used / usage.total * 100) if usage.total else 0 + free_gb = usage.free / 1024**3 + total_gb = usage.total / 1024**3 + checks.append( + Check( + f"Free space on {mount.target}", + f"{free_gb:.0f} GB free of {total_gb:.0f} GB ({used_pct}% used)", + Status.BAD if used_pct >= 95 else Status.WARNING if used_pct >= 85 else Status.GOOD, + ) + ) + + return Section("Storage", checks) + + +def _battery() -> Section | None: + root = Path("/sys/class/power_supply") + if not root.is_dir(): + return None + + for entry in sorted(root.iterdir()): + if system.read_text(entry / "type") != "Battery": + continue + + full = system.read_text(entry / "energy_full") or system.read_text(entry / "charge_full") + design = system.read_text(entry / "energy_full_design") or system.read_text( + entry / "charge_full_design" + ) + checks = [ + Check("Status", system.read_text(entry / "status") or "Unknown"), + Check("Charge", f"{system.read_text(entry / 'capacity') or '?'}%"), + ] + try: + if full and design and float(design) > 0: + health = round(float(full) / float(design) * 100, 1) + checks.append( + Check( + "Health", + f"{health}% of design capacity", + Status.GOOD + if health >= 80 + else Status.WARNING + if health >= 60 + else Status.BAD, + ) + ) + except ValueError: + pass + + cycles = system.read_text(entry / "cycle_count") + checks.append(Check("Cycle count", cycles or "Not reported by driver")) + return Section("Battery", checks) + + return None + + +def _security() -> Section: + checks: list[Check] = [] + + state = probe.secure_boot() + checks.append( + Check( + "Secure Boot", + state, + Status.GOOD + if state == "Enabled" + else Status.WARNING + if state == "Disabled" + else Status.UNKNOWN, + "" if state != "Unknown" else "No EFI SecureBoot variable on this system.", + ) + ) + + tpm = Path("/sys/class/tpm/tpm0") + version = system.read_text(tpm / "tpm_version_major") if tpm.is_dir() else None + checks.append( + Check( + "TPM", + f"Present (TPM {version})" if version else "Present" if tpm.is_dir() else "Not present", + Status.GOOD if tpm.is_dir() else Status.INFO, + ) + ) + + lsm = system.read_text("/sys/kernel/security/lsm") or "" + active = [name for name in ("selinux", "apparmor") if name in lsm] + checks.append( + Check( + "Access control", + ", ".join(name.upper() for name in active) if active else "None active", + Status.GOOD if active else Status.WARNING, + ) + ) + + for command, argv, good in ( + ("firewall-cmd", ["firewall-cmd", "--state"], "running"), + ("ufw", ["ufw", "status"], "active"), + ): + if not system.has(command): + continue + # Deliberately unprivileged: a report that asks for the root password + # to tell you the firewall state is not worth the interruption. Where + # the query needs root, say so rather than prompting. + result = system.run(argv) + output = (result.stdout + result.stderr).lower() + if good in output: + checks.append(Check("Firewall", f"{command}: active", Status.GOOD)) + elif result.ok: + checks.append(Check("Firewall", f"{command}: inactive", Status.WARNING)) + else: + checks.append(Check("Firewall", command, Status.UNKNOWN, "State needs root to query.")) + break + else: + checks.append(Check("Firewall", "No firewall tool found", Status.UNKNOWN)) + + return Section("Security", checks) + + +def _services() -> Section: + if not system.has("systemctl"): + return Section("Services", [Check("systemd", "Not in use", Status.INFO)]) + + failed = system.output(["systemctl", "--failed", "--no-legend", "--no-pager"]) or "" + count = len([line for line in failed.splitlines() if line.strip()]) + checks = [ + Check( + "Failed units", + "None" if not count else f"{count} failed", + Status.GOOD if not count else Status.WARNING, + failed.strip()[:200], + ) + ] + + boot = system.output(["systemd-analyze", "time"]) + if boot: + checks.append(Check("Boot time", boot.splitlines()[0])) + return Section("Services", checks) + + +def collect() -> list[Section]: + """Every section, in the order both front-ends show them.""" + sections = [_overview(), _cpu(), _graphics(), _memory(), _storage()] + battery = _battery() + if battery: + sections.append(battery) + sections += [_security(), _services()] + return sections + + +def overall(sections: list[Section]) -> Status: + if not sections: + return Status.UNKNOWN + return max((section.status for section in sections), key=lambda s: _SEVERITY[s]) diff --git a/src/Linux/pchealth/privileged.py b/src/Linux/pchealth/privileged.py new file mode 100644 index 0000000..4796925 --- /dev/null +++ b/src/Linux/pchealth/privileged.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Runs a batch of commands as root, from a single elevation prompt. + +pkexec authenticates per invocation, so a tool that ran six privileged +commands asked for the password six times. This helper is elevated once and +then runs the whole batch, streaming each line back as it arrives. + +It is deliberately dumb: it holds no logic, takes no decisions, and runs +exactly the argv lists it is handed on stdin as JSON. No shell is involved, so +nothing in a filename or a package name can become a command. It exits as soon +as the batch is done -- there is no long-lived root process listening on a +pipe. + +Standard library only, and no imports from the pchealth package: pkexec clears +the environment, so this file has to work when run as a bare path. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from typing import TypeGuard + +EXIT_BAD_PAYLOAD = 2 + + +def _emit(event: dict[str, object]) -> None: + sys.stdout.write(json.dumps(event) + "\n") + sys.stdout.flush() + + +def _valid(batch: object) -> TypeGuard[list[list[str]]]: + return isinstance(batch, list) and all( + isinstance(argv, list) and argv and all(isinstance(part, str) for part in argv) + for argv in batch + ) + + +def main() -> int: + try: + payload = json.loads(sys.stdin.read()) + except (json.JSONDecodeError, UnicodeDecodeError): + return EXIT_BAD_PAYLOAD + if not isinstance(payload, dict): + return EXIT_BAD_PAYLOAD + + batch = payload.get("commands") + # Boot repair must not run grub-mkconfig after grub-install failed, so a + # batch can ask to stop at the first non-zero exit. + stop_on_error = bool(payload.get("stop_on_error")) + if not _valid(batch): + return EXIT_BAD_PAYLOAD + + for index, argv in enumerate(batch): + try: + # argv is a validated list of strings and no shell is involved. + process = subprocess.Popen( + argv, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except OSError as exc: + _emit({"i": index, "exit": 127, "error": str(exc)}) + continue + + assert process.stdout is not None + with process.stdout: + for line in process.stdout: + _emit({"i": index, "line": line.rstrip("\n")}) + code = process.wait() + _emit({"i": index, "exit": code}) + if code != 0 and stop_on_error: + break + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/Linux/pchealth/probe.py b/src/Linux/pchealth/probe.py new file mode 100644 index 0000000..608d113 --- /dev/null +++ b/src/Linux/pchealth/probe.py @@ -0,0 +1,390 @@ +"""Native readers for /proc, /sys and the standard library. + +The kernel already publishes everything a health report needs. Asking lscpu, +uptime, findmnt, lsblk, timedatectl or mokutil for the same values means +spawning a process, hoping it is installed, and parsing prose that shifts with +the locale and the tool's version. These readers open the files those tools +open, so they work on a minimal install and inside a container, and they hand +back numbers instead of text. + +What the kernel genuinely does not know stays on a command. PCI device *names* +live in hwdata's pci.ids, which is lspci's job, and SMART needs an ioctl, which +is smartctl's -- so those two keep their tools, with a sysfs fallback where one +is possible. +""" + +from __future__ import annotations + +import contextlib +import os +import re +import time +from dataclasses import dataclass, field +from pathlib import Path + +from . import system + +_CPU_ROOT = Path("/sys/devices/system/cpu") + +# lspci prints "01:00.0 VGA compatible controller: NVIDIA ... [GeForce RTX 3060]". +_GPU_LINE = re.compile( + r"^[\w:.]+\s+(?:VGA compatible controller|Display controller|3D controller):\s*(.+)$" +) + +# The handful of vendors that ship a display adapter, for when pciutils is +# absent and only the numeric id from sysfs is available. +_PCI_VENDORS = { + "0x1002": "AMD", + "0x10de": "NVIDIA", + "0x8086": "Intel", + "0x102b": "Matrox", + "0x1a03": "ASPEED", + "0x1af4": "Virtio", + "0x15ad": "VMware", + "0x1234": "QEMU", +} + +# Virtual devices: loopbacks, ramdisks, optical drives and mapper targets are +# not disks anyone wants a health row about. +_VIRTUAL_BLOCK = ("loop", "ram", "zram", "dm-", "sr", "md", "fd") + + +def _int(text: str | None) -> int | None: + return int(text) if text and text.lstrip("-").isdigit() else None + + +# -- CPU ---------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Cpu: + model: str = "Unknown" + architecture: str = "" + cores: int = 0 + threads: int = 0 + max_mhz: int | None = None + virtualization: str = "None" + # Level name ("L1d", "L2", ...) to size as the kernel spells it, per core. + caches: dict[str, str] = field(default_factory=dict) + # Names from /sys/.../vulnerabilities whose state starts with "Vulnerable". + vulnerable: list[str] = field(default_factory=list) + + +def _cpu_fields() -> tuple[str, set[str], float | None]: + """Model name, feature flags and the first reported clock from /proc/cpuinfo.""" + model = "" + flags: set[str] = set() + mhz: float | None = None + for line in (system.read_text("/proc/cpuinfo") or "").splitlines(): + key, sep, value = line.partition(":") + if not sep: + continue + key, value = key.strip(), value.strip() + # x86 reports "model name"; arm64 has no such field and uses "Model". + if not model and key in ("model name", "Model"): + model = value + elif not flags and key in ("flags", "Features"): + flags = set(value.split()) + elif mhz is None and key == "cpu MHz": + with contextlib.suppress(ValueError): + mhz = float(value) + return model, flags, mhz + + +def _caches() -> dict[str, str]: + suffix = {"Data": "d", "Instruction": "i"} + sizes: dict[str, str] = {} + for index in sorted((_CPU_ROOT / "cpu0" / "cache").glob("index*")): + level = system.read_text(index / "level") + kind = system.read_text(index / "type") or "" + size = system.read_text(index / "size") + if level and size: + sizes[f"L{level}{suffix.get(kind, '')}"] = size + return sizes + + +def cpu() -> Cpu: + """Everything lscpu reported, straight from procfs and the cpu sysfs tree.""" + model, flags, mhz = _cpu_fields() + if not model: + model = system.read_text("/sys/firmware/devicetree/base/model") or "Unknown" + + online = [entry for entry in _CPU_ROOT.glob("cpu[0-9]*") if entry.is_dir()] + threads = len(online) or os.cpu_count() or 0 + cores = len( + { + ( + system.read_text(entry / "topology" / "physical_package_id"), + system.read_text(entry / "topology" / "core_id"), + ) + for entry in online + if (entry / "topology" / "core_id").exists() + } + ) + + max_khz = _int(system.read_text(_CPU_ROOT / "cpu0" / "cpufreq" / "cpuinfo_max_freq")) + max_mhz = round(max_khz / 1000) if max_khz else (round(mhz) if mhz else None) + + if "vmx" in flags: + virtualization = "VT-x" + elif "svm" in flags: + virtualization = "AMD-V" + elif "hypervisor" in flags: + virtualization = "Running as a guest" + else: + virtualization = "None" + + vulnerabilities = _CPU_ROOT / "vulnerabilities" + vulnerable = ( + sorted( + entry.name + for entry in vulnerabilities.iterdir() + if (system.read_text(entry) or "").startswith("Vulnerable") + ) + if vulnerabilities.is_dir() + else [] + ) + + return Cpu( + model=model, + architecture=os.uname().machine, + cores=cores or threads, + threads=threads, + max_mhz=max_mhz, + virtualization=virtualization, + caches=_caches(), + vulnerable=vulnerable, + ) + + +def has_vulnerability_reporting() -> bool: + return (_CPU_ROOT / "vulnerabilities").is_dir() + + +# -- Memory ------------------------------------------------------------------- + + +def meminfo() -> dict[str, int]: + """/proc/meminfo as kibibytes, keyed by its own labels.""" + 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 + + +# -- Uptime ------------------------------------------------------------------- + + +def uptime_seconds() -> float | None: + raw = (system.read_text("/proc/uptime") or "").split(" ", 1)[0] + try: + return float(raw) + except ValueError: + return None + + +def uptime_text() -> str: + """The same phrasing `uptime -p` produces, without asking procps for it.""" + seconds = uptime_seconds() + if seconds is None: + return "Unknown" + + minutes = int(seconds // 60) + parts = [ + (minutes // 1440, "day"), + (minutes % 1440 // 60, "hour"), + (minutes % 60, "minute"), + ] + said = [f"{n} {word}{'s' if n != 1 else ''}" for n, word in parts if n] + return "up " + ", ".join(said) if said else "up less than a minute" + + +def boot_time_text() -> str: + seconds = uptime_seconds() + if seconds is None: + return "Unknown" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - seconds)) + + +# -- Mounts and disks --------------------------------------------------------- + + +@dataclass(frozen=True) +class Mount: + target: str + fstype: str + source: str + + +def _unescape(text: str) -> str: + """mountinfo octal-escapes space, tab, newline and backslash in paths.""" + return re.sub(r"\\([0-7]{3})", lambda match: chr(int(match.group(1), 8)), text) + + +def mounts() -> list[Mount]: + """Every mount, from /proc/self/mountinfo -- the file findmnt reads.""" + found: list[Mount] = [] + for line in (system.read_text("/proc/self/mountinfo") or "").splitlines(): + # Optional fields sit between the mount point and " - ", so the line is + # split on that separator rather than counted from the left. + head, sep, tail = line.partition(" - ") + fields, rest = head.split(), tail.split() + if not sep or len(fields) < 5 or len(rest) < 2: + continue + found.append(Mount(_unescape(fields[4]), rest[0], _unescape(rest[1]))) + return found + + +def fstype_for(path: str) -> str | None: + """The filesystem type of the mount that contains a path. + + The same answer `findmnt --target` gives: the longest mount point that is + a prefix of the path wins, so a directory that is not itself a mount + reports the filesystem it sits on. + """ + target = os.path.realpath(path) + best: Mount | None = None + for mount in mounts(): + under = target == mount.target or target.startswith(mount.target.rstrip("/") + "/") + if under and (best is None or len(mount.target) > len(best.target)): + best = mount + return best.fstype if best else None + + +@dataclass(frozen=True) +class BlockDevice: + name: str + size_bytes: int + model: str + rotational: bool | None + + @property + def size_text(self) -> str: + gb = self.size_bytes / 1000**3 + if gb >= 1000: + return f"{gb / 1000:.1f} TB" + return f"{gb:.1f} GB" if gb >= 1 else f"{self.size_bytes / 1000**2:.0f} MB" + + @property + def kind(self) -> str: + if self.rotational is None: + return "Disk" + return "HDD" if self.rotational else "SSD" + + +def block_devices() -> list[BlockDevice]: + """Physical disks from /sys/block, the tree lsblk itself walks.""" + try: + entries = sorted(Path("/sys/block").iterdir()) + except OSError: + return [] + + devices: list[BlockDevice] = [] + for entry in entries: + if entry.name.startswith(_VIRTUAL_BLOCK): + continue + # The kernel always reports size in 512-byte sectors here, whatever the + # drive's own block size is. + sectors = _int(system.read_text(entry / "size")) or 0 + if not sectors: + continue + model = ( + system.read_text(entry / "device" / "model") + or system.read_text(entry / "device" / "name") + or "" + ) + rotational = _int(system.read_text(entry / "queue" / "rotational")) + devices.append( + BlockDevice( + name=entry.name, + size_bytes=sectors * 512, + model=model.strip(), + rotational=None if rotational is None else bool(rotational), + ) + ) + return devices + + +# -- Graphics ----------------------------------------------------------------- + + +def gpus() -> list[str]: + """Display adapters by name. + + Product names are not in the kernel: sysfs has the numeric PCI id and + hwdata's pci.ids turns it into words, which is precisely what lspci does. + So the name comes from lspci when pciutils is installed, and from the + vendor id and driver the kernel does know when it is not -- rather than + reporting nothing at all, which is what the old lspci-only path did. + """ + listing = system.output(["lspci"]) + if listing is not None: + named = [ + match.group(1).strip() + for line in listing.splitlines() + if (match := _GPU_LINE.match(line)) + ] + if named: + return named + return _gpus_from_drm() + + +def _gpus_from_drm() -> list[str]: + found: list[str] = [] + try: + cards = sorted(Path("/sys/class/drm").glob("card[0-9]*")) + except OSError: + return found + + for card in cards: + # card0-HDMI-A-1 is a connector on card0, not a second adapter. + if "-" in card.name: + continue + device = card / "device" + vendor = system.read_text(device / "vendor") or "" + label = _PCI_VENDORS.get(vendor, f"PCI vendor {vendor}" if vendor else "Unknown vendor") + driver = device / "driver" + name = os.path.basename(os.readlink(driver)) if driver.is_symlink() else "" + found.append(f"{label} graphics ({name})" if name else f"{label} graphics") + return found + + +# -- Firmware and locale ------------------------------------------------------ + + +def secure_boot() -> str: + """Enabled, Disabled or Unknown, from the EFI variable mokutil reads. + + The first four bytes of an efivars entry are the variable's attributes; + the fifth is the flag. Going here directly means the answer does not + depend on mokutil being installed. + """ + efivars = Path("/sys/firmware/efi/efivars") + try: + names = [entry for entry in efivars.iterdir() if entry.name.startswith("SecureBoot-")] + except OSError: + return "Unknown" + + for entry in names: + try: + raw = entry.read_bytes()[:5] + except OSError: + continue + if len(raw) == 5: + return "Enabled" if raw[4] else "Disabled" + return "Unknown" + + +def timezone() -> str: + """The zone name, from the /etc/localtime symlink systemd itself sets.""" + link = Path("/etc/localtime") + if link.is_symlink(): + _, sep, zone = os.readlink(link).partition("zoneinfo/") + if sep and zone: + return zone + return system.read_text("/etc/timezone") or os.environ.get("TZ") or time.tzname[0] or "N/A" diff --git a/src/Linux/pchealth/smart.py b/src/Linux/pchealth/smart.py new file mode 100644 index 0000000..0f2fbbf --- /dev/null +++ b/src/Linux/pchealth/smart.py @@ -0,0 +1,128 @@ +"""SMART data, read once and shared. + +Both Hardware Information and the Health report need this, and smartctl's +JSON is fiddly enough that having two readers of it would mean two sets of +quirks to keep in step. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +from . import system + +# SSD wear-levelling attributes, in the order vendors actually use them. +_LIFE_ATTRIBUTES = (231, 202, 177) + + +@dataclass(frozen=True) +class Device: + name: str + model: str + media: str + capacity_bytes: int | None = None + temperature_c: int | None = None + power_on_hours: int | None = None + life_left_pct: int | None = None + # True passed, False failing, None not reported. + passed: bool | None = None + + @property + def capacity_gb(self) -> str: + return f"{round(self.capacity_bytes / 1024**3)}" if self.capacity_bytes else "N/A" + + @property + def health_text(self) -> str: + if self.passed is True: + return "Healthy" + return "FAILING" if self.passed is False else "Unknown" + + +def _parse(stdout: 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 -- which is the whole point of + asking for JSON rather than parsing the human-readable report. + """ + if not stdout.strip(): + return None + try: + parsed = json.loads(stdout) + except json.JSONDecodeError: + return None + return parsed if isinstance(parsed, dict) else None + + +def available() -> bool: + return system.has("smartctl") + + +def devices() -> list[Device]: + """Every disk smartctl can see. Empty when smartmontools is not installed. + + Reading a disk needs root, so all of them are read in one elevated batch: + one password prompt for the whole machine rather than one per disk. + """ + if not available(): + return [] + + # Enumerating devices only reads /dev, which a normal user may do. Falling + # back to an elevated scan costs a second prompt, so only do it if needed. + scan = _parse(system.run(["smartctl", "--scan", "--json"]).stdout) + entries = (scan or {}).get("devices", []) + if not entries: + scan = _parse(system.run_root(["smartctl", "--scan", "--json"]).stdout) + entries = (scan or {}).get("devices", []) + + targets = [] + for entry in entries: + name = entry.get("name") + if not name: + continue + kind = entry.get("type", "") + argv = ["smartctl", "-a", name, "--json"] + if kind and kind != "auto": + argv += ["-d", kind] + targets.append((name, kind, argv)) + + if not targets: + return [] + + results = system.run_root_batch([argv for _, _, argv in targets]) + found: list[Device] = [] + + for (name, kind, _argv), result in zip(targets, results, strict=True): + data = _parse(result.stdout) + if not data or not data.get("model_name"): + continue + + is_nvme = kind == "nvme" + rotation = data.get("rotation_rate", 0) or 0 + + life: int | None = None + if is_nvme: + used = data.get("nvme_smart_health_information_log", {}).get("percentage_used") + if used is not None: + life = max(0, 100 - int(used)) + elif rotation == 0: + table = data.get("ata_smart_attributes", {}).get("table", []) + attribute = next((a for a in table if a.get("id") in _LIFE_ATTRIBUTES), None) + if attribute is not None: + life = attribute.get("value") + + found.append( + Device( + name=name, + model=str(data["model_name"]), + media="SSD" if is_nvme or rotation == 0 else "HDD", + capacity_bytes=data.get("capacity", {}).get("bytes"), + temperature_c=data.get("temperature", {}).get("current"), + power_on_hours=data.get("power_on_time", {}).get("hours"), + life_left_pct=life, + passed=data.get("smart_status", {}).get("passed"), + ) + ) + + return found diff --git a/src/Linux/pchealth/system.py b/src/Linux/pchealth/system.py new file mode 100644 index 0000000..230ca4f --- /dev/null +++ b/src/Linux/pchealth/system.py @@ -0,0 +1,455 @@ +"""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 smartctl, 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 json +import os +import pwd +import shutil +import subprocess +import sys +import webbrowser +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, + env: dict[str, 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, + env=env, + 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. The passwd database answers that + through the pwd module; shelling out to id and getent for the same three + fields only added three ways to fail. + """ + try: + if name := os.environ.get("SUDO_USER"): + entry = pwd.getpwnam(name) + elif (uid := os.environ.get("PKEXEC_UID", "")).isdigit(): + entry = pwd.getpwuid(int(uid)) + else: + entry = pwd.getpwuid(os.getuid()) + except (KeyError, OSError): + return None + + # 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/{entry.pw_uid}/bus" + + return DesktopUser(name=entry.pw_name, uid=str(entry.pw_uid), home=entry.pw_dir, dbus=dbus) + + +def run_as_user(user: DesktopUser, argv: Sequence[str]) -> Result: + """Run a command as the desktop user, with their session bus. + + Usually there is nothing to drop to. The GUI already runs as that user -- + it must never run as root -- so wrapping the call in sudo would ask for a + password that `systemctl --user` does not need, which is exactly the kind + of prompt this codebase keeps trying to get rid of. Only a process that + really is root has to switch back, and a machine with polkit but no sudo + still has a way through. + """ + session = {**os.environ, "DBUS_SESSION_BUS_ADDRESS": user.dbus} + + if str(os.getuid()) == user.uid: + return run(argv, env=session) + + # Switching user means a new process environment, so the session bus is + # passed as an argument to env rather than inherited. Each value is its own + # argv token and never shell text, so a hostile DISPLAY cannot become a + # command. + bus = f"DBUS_SESSION_BUS_ADDRESS={user.dbus}" + if has("sudo"): + return run(["sudo", "-u", user.name, "env", bus, *argv]) + if has("pkexec"): + return run(["pkexec", "--user", user.name, "env", bus, *argv]) + return Result(COMMAND_NOT_FOUND, "", "Neither sudo nor pkexec is available.") + + +# -- 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. + + webbrowser is the standard library's own launcher and knows the desktop + handler, a $BROWSER setting and the plain browsers besides, so it succeeds + on systems where xdg-open is not installed at all. Root is the exception: + its browser would open into a session that may not even accept it, so the + URL goes back to the user who logged in, the way the audio and topgrade + tools do. + """ + if not url.startswith(("http://", "https://")): + return False + + user = desktop_user() + if is_root() and user and user.name != "root" and has("sudo"): + return run_as_user(user, ["xdg-open", url]).ok + return webbrowser.open(url) + + +def _helper_argv() -> list[str]: + """pkexec clears the environment, so the helper is run as a plain path.""" + return [sys.executable, str(Path(__file__).resolve().parent / "privileged.py")] + + +def run_root_batch( + commands: Sequence[Sequence[str]], + on_line: Callable[[int, str], None] | None = None, + *, + stop_on_error: bool = False, +) -> list[Result]: + """Run several commands as root, asking for the password once. + + Every caller that needs more than one privileged command should use this. + Six separate run_root calls means six pkexec prompts, which is what made + the Health page unusable. + + on_line receives (index, line) as output arrives, so a long-running batch + still shows progress. + """ + batch = [list(command) for command in commands] + if not batch: + return [] + + if is_root(): + results = [] + for index, argv in enumerate(batch): + if on_line is None: + results.append(run(argv)) + else: + collected: list[str] = [] + + def collect(line: str, sink: list[str] = collected, i: int = index) -> None: + sink.append(line) + on_line(i, line) + + rc = stream(argv, collect) + results.append(Result(rc, "\n".join(collected))) + if stop_on_error and not results[-1].ok: + break + return results + + payload = json.dumps({"commands": batch, "stop_on_error": stop_on_error}) + output: list[list[str]] = [[] for _ in batch] + codes: list[int] = [COMMAND_NOT_FOUND] * len(batch) + + # Fixed argv, no shell: the batch travels on stdin as JSON. + process = subprocess.Popen( + elevated(_helper_argv()), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + ) + assert process.stdin is not None and process.stdout is not None + try: + process.stdin.write(payload) + process.stdin.close() + for raw in process.stdout: + try: + event = json.loads(raw) + except json.JSONDecodeError: + continue + index = event.get("i") + if not isinstance(index, int) or not 0 <= index < len(batch): + continue + if "line" in event: + output[index].append(str(event["line"])) + if on_line is not None: + on_line(index, str(event["line"])) + elif "exit" in event: + codes[index] = int(event["exit"]) + finally: + process.stdout.close() + process.wait() + + return [Result(code, "\n".join(lines)) for code, lines in zip(codes, output, strict=True)] diff --git a/src/Linux/pchealth/tools/__init__.py b/src/Linux/pchealth/tools/__init__.py new file mode 100644 index 0000000..26521b5 --- /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, Choice, Level, ToolFunc, ToolUI + +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", "Choice", "Level", "ToolFunc", "ToolUI"] diff --git a/src/Linux/pchealth/tools/audio.py b/src/Linux/pchealth/tools/audio.py new file mode 100644 index 0000000..deb5787 --- /dev/null +++ b/src/Linux/pchealth/tools/audio.py @@ -0,0 +1,49 @@ +"""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 Level, ToolUI + +PIPEWIRE_UNITS = ("pipewire", "pipewire-pulse", "wireplumber") + + +def audio_restart(ui: ToolUI) -> None: + ui.section("Restart Audio") + + user = system.desktop_user() + if not user: + ui.note("Could not determine the desktop user.", Level.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"]) + + if state.stdout.strip() == "active": + ui.note("Detected PipeWire.") + for unit in PIPEWIRE_UNITS: + step = ui.step(f"Restarting {unit}") + result = system.run_as_user(user, ["systemctl", "--user", "restart", unit]) + for line in (result.stdout + result.stderr).splitlines(): + step.output(line) + step.finish(result.ok, "Done" if result.ok else f"Exit code {result.returncode}") + elif system.has("pulseaudio"): + ui.note("Detected PulseAudio.") + step = ui.step("Restarting PulseAudio") + # 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"]) + step.finish(result.ok, "Done" if result.ok else f"Exit code {result.returncode}") + else: + ui.note("No supported audio server found (PipeWire or PulseAudio).", Level.ERROR) + return + + ui.note("Audio services restarted.", Level.OK) diff --git a/src/Linux/pchealth/tools/base.py b/src/Linux/pchealth/tools/base.py new file mode 100644 index 0000000..6fc7f79 --- /dev/null +++ b/src/Linux/pchealth/tools/base.py @@ -0,0 +1,193 @@ +"""What a tool is, and what it may say. + +A tool describes results. It does not print lines, draw menus or format +output, because those are decisions only a front-end can make: the terminal +writes text, the GTK window builds rows, cards and progress bars. The moment a +tool emits "[>>] Doing something..." it has decided it lives in a terminal, +and the GUI can do no better than show you that text -- which is exactly what +a GUI should not be. + +So the vocabulary is small and structural: + + ui.section("Battery") a heading + ui.fields([("Health", "94%")]) label/value rows + ui.note("No battery detected", WARN) one message + ui.run(argv, label="Trimming") a step, with its raw output + ui.choose(...) / ui.confirm(...) a question + +Raw command output goes with the step that produced it, where a front-end can +tuck it away. In the terminal it is printed; in the window it sits behind a +"Details" expander, because most of the time nobody wants to read it. +""" + +from __future__ import annotations + +import re +import time +from abc import ABC, abstractmethod +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from enum import Enum + +from .. import system + + +class Level(Enum): + INFO = "info" + OK = "ok" + WARN = "warn" + ERROR = "error" + + +class Cancelled(Exception): + """Raised when the user backs out of a prompt. Never an error.""" + + +@dataclass(frozen=True) +class Choice: + """One option a tool offers. `key` identifies it in the tool's own code.""" + + key: str + label: str + detail: str = "" + # Marks an option that reboots, reinstalls or otherwise cannot be undone, + # so a front-end can style it as destructive. + destructive: bool = False + + +# "Downloading…: 41.4%", "Installing: 7%", " Progress: 100.0 %" +_PROGRESS = re.compile(r"^\s*\S.*?[:\s]\s*\d{1,3}(?:[.,]\d+)?\s*%\s*$") + + +class Step(ABC): + """A running piece of work, with the command output it produces. + + fwupd, apt and dnf redraw a progress line with carriage returns; through a + pipe that becomes hundreds of separate lines. Thinning them out belongs + here rather than in every tool that happens to run such a command. + """ + + def __init__(self, interval: float = 1.0) -> None: + self._interval = interval + self._last = 0.0 + self._held: str | None = None + + def output(self, line: str) -> None: + if not _PROGRESS.match(line): + self._flush() + self.write(line) + return + now = time.monotonic() + if now - self._last >= self._interval: + self._last, self._held = now, None + self.write(line) + else: + self._held = line + + def _flush(self) -> None: + if self._held is not None: + self.write(self._held) + self._held = None + + def finish(self, ok: bool, summary: str = "") -> None: + self._flush() + self.close(ok, summary) + + @abstractmethod + def write(self, line: str) -> None: + """Record one line of raw output.""" + + @abstractmethod + def close(self, ok: bool, summary: str) -> None: + """Mark the step finished.""" + + +class ToolUI(ABC): + """Everything a tool is allowed to do. Implemented per front-end.""" + + @abstractmethod + def section(self, title: str) -> None: ... + + @abstractmethod + def fields(self, rows: Sequence[tuple[str, str]]) -> None: ... + + @abstractmethod + def note(self, text: str, level: Level = Level.INFO) -> None: ... + + @abstractmethod + def step(self, label: str) -> Step: ... + + @abstractmethod + def choose(self, question: str, options: Sequence[Choice]) -> str | None: ... + + @abstractmethod + def confirm(self, question: str) -> bool: ... + + def should_stop(self) -> bool: + return False + + # -- Running commands ---------------------------------------------------- + # Every tool used to repeat the same six lines around each command: print a + # label, stream the output with an indent, check the exit code, print OK or + # a failure. That lives here now. + + def run( + self, + argv: Sequence[str], + *, + label: str, + root: bool = False, + ok: str = "Done", + failed: str = "", + ) -> system.Result: + """Run one command as a step.""" + return self.run_all([(label, list(argv))], root=root, ok=ok, failed=failed)[0] + + def run_all( + self, + steps: Sequence[tuple[str, Sequence[str]]], + *, + root: bool = False, + stop_on_error: bool = False, + ok: str = "Done", + failed: str = "", + ) -> list[system.Result]: + """Run several commands, elevating once for the lot. + + pkexec authenticates per invocation, so elevating each command + separately asked for the password once per command. + """ + if not steps: + return [] + + opened: dict[int, Step] = {} + + def on_line(index: int, line: str) -> None: + if index not in opened: + opened[index] = self.step(steps[index][0]) + opened[index].output(line) + + if root: + results = system.run_root_batch( + [argv for _, argv in steps], on_line=on_line, stop_on_error=stop_on_error + ) + else: + results = [] + for index, (_, argv) in enumerate(steps): + + def forward(line: str, i: int = index) -> None: + on_line(i, line) + + code = system.stream(list(argv), forward) + results.append(system.Result(code)) + if stop_on_error and code != 0: + break + + for index, result in enumerate(results): + step = opened.get(index) or self.step(steps[index][0]) + summary = ok if result.ok else (failed or f"Exit code {result.returncode}") + step.finish(result.ok, summary) + return results + + +ToolFunc = Callable[[ToolUI], None] diff --git a/src/Linux/pchealth/tools/battery.py b/src/Linux/pchealth/tools/battery.py new file mode 100644 index 0000000..2d3d1fa --- /dev/null +++ b/src/Linux/pchealth/tools/battery.py @@ -0,0 +1,103 @@ +"""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 Level, ToolUI + +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.""" + try: + return f"{int(raw) / 1e6:.2f}" if raw else "N/A" + except ValueError: + return "N/A" + + +def _health(full: str | None, design: str | None) -> float | None: + """Drivers report either energy (uWh) or charge (uAh). + + The ratio holds for both, as long as full and design come from the pair. + """ + try: + if full and design and float(design) > 0: + return round(float(full) / float(design) * 100, 1) + except ValueError: + pass + return None + + +def battery_report(ui: ToolUI) -> None: + if not SUPPLY_ROOT.exists(): + ui.note(f"{SUPPLY_ROOT} not found -- this kernel exposes no power supplies.", Level.ERROR) + return + + try: + batteries = [d for d in sorted(SUPPLY_ROOT.iterdir()) if _attribute(d, "type") == "Battery"] + except OSError as exc: + ui.note(f"Could not read {SUPPLY_ROOT}: {exc}", Level.ERROR) + return + + if not batteries: + ui.note("No battery detected -- this looks like a desktop system.", Level.WARN) + return + + for battery in batteries: + ui.section(f"Battery {battery.name}") + + 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 = _health(full, design) + power = _attribute(battery, "power_now", "current_now") + capacity = _attribute(battery, "capacity") + cycles = _attribute(battery, "cycle_count") + + ui.fields( + [ + ("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}% of design capacity" if health is not None else "N/A"), + ] + ) + + if health is not None: + verdict, level = ( + ("The battery holds most of its design capacity.", Level.OK) + if health >= 80 + else ("Worn -- noticeably reduced runtime.", Level.WARN) + if health >= 60 + else ("Poor -- consider replacing the battery.", Level.ERROR) + ) + ui.note(verdict, level) + if not cycles: + ui.note("Many laptop batteries do not expose a cycle count to the kernel.") diff --git a/src/Linux/pchealth/tools/boot.py b/src/Linux/pchealth/tools/boot.py new file mode 100644 index 0000000..1a46997 --- /dev/null +++ b/src/Linux/pchealth/tools/boot.py @@ -0,0 +1,219 @@ +"""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 probe, system +from .base import Choice, Level, ToolUI + +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 probe.fstype_for(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(ui: ToolUI) -> None: + ui.section("Boot Repair") + ui.note( + "This modifies boot-critical files. Incorrect use can render the " + "system unbootable. Only proceed if you understand what you are doing.", + Level.WARN, + ) + + # On ostree systems the bootloader entries are generated from the + # deployments. Reinstalling by hand fights whatever produced them. + if system.is_image_based(): + ui.note("This is an image-based system (ostree).", Level.ERROR) + ui.note("Its bootloader belongs to the deployment. Roll back instead: rpm-ostree rollback") + return + + if not Path("/sys/firmware/efi").exists(): + ui.note("This system booted in legacy BIOS mode (no /sys/firmware/efi).", Level.ERROR) + ui.note("pcHealth only repairs UEFI bootloaders.") + return + + efi_name, grub_target = _efi_names() + esp = _find_esp() + if not esp: + ui.note("No mounted EFI System Partition at /efi, /boot/efi or /boot.", Level.ERROR) + ui.note("Mount it first, then run this tool again.") + listing = system.output(["lsblk", "-o", "NAME,SIZE,FSTYPE,PARTTYPENAME,MOUNTPOINT"]) or "" + candidates = [ + line for line in listing.splitlines() if "EFI System" in line or "vfat" in line + ] + if candidates: + ui.fields([(line.split()[0], line) for line in candidates]) + return + + bits = system.read_text("/sys/firmware/efi/fw_platform_size") or "64" + ui.fields( + [ + ("Firmware", f"UEFI ({bits}-bit, {os.uname().machine})"), + ("ESP", esp), + ("EFI binary", efi_name), + ] + ) + + loaders = _detect_loaders(esp, efi_name, grub_target) + if not loaders: + ui.note("No supported bootloader found (systemd-boot, GRUB or Limine).", Level.ERROR) + ui.note("Install your bootloader's package first, then run this tool again.") + return + + choice = ui.choose( + "Which bootloader should be repaired?", + [Choice(loader.name, loader.name, loader.state, destructive=True) for loader in loaders], + ) + if choice is None: + return + loader = next(candidate for candidate in loaders if candidate.name == choice) + + ui.section("These commands will run as root") + ui.fields([(f"{n}.", " ".join(command)) for n, command in enumerate(loader.commands, 1)]) + + # Two confirmations, same as the Windows tool: this is the one place where + # a mistaken click leaves the machine unbootable. + if not ui.confirm(f"Reinstall {loader.name} on {esp}?"): + return + if not ui.confirm("Last chance. An interrupted repair can leave this machine unbootable."): + return + + # stop_on_error so grub-mkconfig never runs after grub-install failed. + # efibootmgr rides along: a copied EFI binary with no firmware boot entry + # still leaves an unbootable machine. + commands: list[tuple[str, list[str]]] = [ + (" ".join(command), list(command)) for command in loader.commands + ] + show_entries = system.has("efibootmgr") + if show_entries: + commands.append(("Reading firmware boot entries", ["efibootmgr"])) + + results = ui.run_all(commands, root=True, stop_on_error=True) + repaired = len(results) >= len(loader.commands) and all( + result.ok for result in results[: len(loader.commands)] + ) + + if not repaired: + ui.note("The repair stopped at a failing command.", Level.ERROR) + ui.note( + "The system may still boot from its existing entry. Do not reboot " + "until you have resolved this, and keep a live USB to hand.", + Level.WARN, + ) + return + + ui.note(f"{loader.name} reinstalled on {esp}.", Level.OK) + if show_entries and len(results) > len(loader.commands): + entries = results[-1].stdout.splitlines() + ui.section("Firmware boot entries") + ui.fields([(line.split()[0].rstrip("*"), line) for line in entries if line.strip()]) + ui.note("Verify the entry above before rebooting.", Level.WARN) diff --git a/src/Linux/pchealth/tools/cleanup.py b/src/Linux/pchealth/tools/cleanup.py new file mode 100644 index 0000000..1cd7ebf --- /dev/null +++ b/src/Linux/pchealth/tools/cleanup.py @@ -0,0 +1,199 @@ +"""Disk cleanup, SSD trim and the package-integrity scan.""" + +from __future__ import annotations + +from pathlib import Path + +from .. import system +from .base import Level, ToolUI + +FS_ERROR_PATTERNS = ("EXT4-fs error", "XFS", "BTRFS error", "I/O error", "read-only") +MAX_FINDINGS_SHOWN = 20 + +Step = tuple[str, list[str]] + +_ARCH = ("arch", "cachyos", "manjaro", "endeavouros", "artix", "garuda") +_DEBIAN = ("debian", "ubuntu", "mint", "pop", "elementary", "zorin", "kali") +_FEDORA = ("fedora", "rhel", "centos", "almalinux", "rocky") + + +# -- Disk cleanup ------------------------------------------------------------- + + +def _package_steps(ui: ToolUI, family: str) -> list[Step]: + if any(key in family for key in _ARCH): + steps: list[Step] = [] + if system.has("paccache"): + steps.append(("Clearing pacman cache (keeping 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: + argv = ["pacman", "-Rns", *orphans.split(), "--noconfirm"] + steps.append(("Removing unneeded dependencies", argv)) + return steps + + if any(key in family for key in _DEBIAN): + return [ + ("Removing unneeded apt packages", ["apt", "autoremove", "-y"]), + ("Cleaning apt cache", ["apt", "autoclean"]), + ] + if any(key in family for key in _FEDORA): + return [ + ("Removing unneeded dnf packages", ["dnf", "autoremove", "-y"]), + ("Cleaning dnf cache", ["dnf", "clean", "all"]), + ] + if "suse" in family: + return [("Cleaning zypper cache", ["zypper", "clean", "--all"])] + + ui.note("Package cache: distro not recognised, skipping.") + return [] + + +def _clear_thumbnails(ui: ToolUI) -> None: + """Runs unprivileged: the cache belongs to the user, not to root.""" + user = system.desktop_user() + thumbnails = Path(user.home) / ".cache" / "thumbnails" if user else None + if not thumbnails or not thumbnails.is_dir(): + return + + files = [path for path in thumbnails.rglob("*") if path.is_file()] + size_mb = sum(path.stat().st_size for path in files) / 1048576 if files else 0.0 + + step = ui.step(f"Clearing thumbnail cache ({size_mb:.1f} MB)") + removed = 0 + for path in files: + try: + path.unlink() + removed += 1 + except OSError: + continue + step.finish(True, f"Removed {removed} file(s)") + + +def disk_cleanup(ui: ToolUI) -> None: + info = system.distro_info() + ui.section(f"Disk Cleanup on {info['PRETTY_NAME']}") + + steps = _package_steps(ui, f"{info['ID']} {info['ID_LIKE']}") + if system.has("journalctl"): + steps.append(("Vacuuming journal (keeping 7 days)", ["journalctl", "--vacuum-time=7d"])) + if system.has("flatpak"): + steps.append(("Removing unused Flatpaks", ["flatpak", "uninstall", "--unused", "-y"])) + + ui.run_all(steps, root=True) + _clear_thumbnails(ui) + ui.note("Disk cleanup complete.", Level.OK) + + +# -- Disk optimization -------------------------------------------------------- + + +def _block_devices() -> list[tuple[str, str]]: + """Real disks and whether they spin, skipping loop/ram/zram/optical.""" + try: + entries = sorted(Path("/sys/block").iterdir()) + except OSError: + return [] + return [ + ( + entry.name, + {"0": "SSD / NVMe", "1": "HDD"}.get( + system.read_text(entry / "queue" / "rotational") or "", "Unknown" + ), + ) + for entry in entries + if not entry.name.startswith(("loop", "ram", "zram", "sr")) + ] + + +def disk_optimize(ui: ToolUI) -> None: + ui.section("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: + ui.fields(devices) + if not any(kind == "SSD / NVMe" for _, kind in devices): + ui.note("No solid-state device detected -- there is nothing to trim.", Level.WARN) + ui.note("Linux filesystems do not need defragmenting.") + return + + if not system.has("fstrim"): + ui.note("fstrim not found. Install util-linux.", Level.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": + ui.note("fstrim.timer is enabled, so this already runs weekly.") + + ui.run( + ["fstrim", "--all", "--verbose"], + label="Trimming mounted filesystems", + root=True, + ok="Trim complete", + ) + + +# -- Scan + repair ------------------------------------------------------------ + + +def scan_repair(ui: ToolUI) -> None: + ui.section("Scan + Repair") + + manager = system.package_manager() + if not manager or not manager.verify: + ui.note("No supported package manager found (apt/dnf/pacman/zypper).", Level.ERROR) + return + + verify_cmd, *verify_args = manager.verify + if not system.has(verify_cmd): + ui.note( + f"{verify_cmd} is not installed -- it does the checking, not {manager.cmd}.", + Level.ERROR, + ) + ui.note(f"Install it with: {manager.cmd} {' '.join(manager.install)} {verify_cmd}") + return + + # 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. + dmesg = ui.run(["dmesg", "--level=err,warn"], label="Checking the kernel log", root=True) + fs_errors = [ + line + for line in dmesg.stdout.splitlines() + if any(pattern in line for pattern in FS_ERROR_PATTERNS) + ] + if fs_errors: + ui.note(f"The kernel has logged {len(fs_errors)} filesystem error(s).", Level.ERROR) + ui.fields([(f"Line {n}", line) for n, line in enumerate(fs_errors[-10:], 1)]) + ui.note("Run fsck from a live image -- it cannot repair a mounted root.", Level.WARN) + else: + ui.note("No filesystem errors in the kernel log.", Level.OK) + + if not ui.confirm(f"Verify every packaged file with {verify_cmd}? This takes several minutes."): + return + + # Merge stderr: debsums reports every changed file there, so dropping it + # would turn a corrupted system into a clean bill of health. + verify = ui.run( + [verify_cmd, *verify_args], + label=f"Verifying packages with {verify_cmd}", + root=True, + ok="Verified", + ) + findings = [line.strip() for line in verify.stdout.splitlines() if line.strip()] + + if not findings: + ui.note("Every packaged file matches the package database.", Level.OK) + return + + ui.section(f"{len(findings)} file(s) no longer match their package") + ui.fields( + [(line.split()[-1], " ".join(line.split()[:-1])) for line in findings[:MAX_FINDINGS_SHOWN]] + ) + if len(findings) > MAX_FINDINGS_SHOWN: + ui.note(f"... and {len(findings) - MAX_FINDINGS_SHOWN} more.") + ui.note("Config files you edited yourself show up here too -- that is expected.") + ui.note(f"Repair with: {manager.cmd} {' '.join(manager.install)} --reinstall ") diff --git a/src/Linux/pchealth/tools/firmware.py b/src/Linux/pchealth/tools/firmware.py new file mode 100644 index 0000000..8ddaf3e --- /dev/null +++ b/src/Linux/pchealth/tools/firmware.py @@ -0,0 +1,73 @@ +"""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 Level, ToolUI + +# 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(ui: ToolUI) -> None: + ui.section("Firmware Update") + + if not system.has("fwupdmgr"): + ui.note("fwupdmgr is not installed.", Level.ERROR) + ui.note("Install fwupd with your package manager: apt, dnf, pacman or zypper.") + return + + # --force refreshes even when the cached metadata is still considered + # fresh. Both queries run under one elevation prompt. + refresh, updates = ui.run_all( + [ + ("Refreshing metadata from LVFS", ["fwupdmgr", "refresh", "--force"]), + ("Checking for firmware updates", ["fwupdmgr", "get-updates"]), + ], + root=True, + ) + + if any(marker in updates.stdout for marker in _DAEMON_DOWN): + ui.note("Could not reach the fwupd daemon.", Level.ERROR) + ui.note("Start it with: systemctl start fwupd") + return + + # fwupdmgr exits non-zero when there is simply nothing to do, so read text. + if not updates.stdout.strip() or any(marker in updates.stdout for marker in _NO_UPDATES): + # Without fresh metadata the verdict reflects whatever was cached, + # which may be months old -- say so rather than reporting "up to date". + if any(marker in refresh.stdout for marker in _REFRESH_FAILED): + ui.note("No updates found, but the LVFS metadata could not be refreshed.", Level.WARN) + ui.note("This answer is based on cached data -- check again once you are online.") + else: + ui.note("All firmware is up to date.", Level.OK) + return + + ui.section("Updates available") + ui.fields( + [(line.split(":")[0].strip(), line) for line in updates.stdout.splitlines() if line.strip()] + ) + ui.note( + "Firmware updates carry real risk. Do not power the machine off while " + "one is running, and plug in the charger on a laptop.", + Level.WARN, + ) + + if not ui.confirm("Install these firmware updates?"): + return + + result = ui.run(["fwupdmgr", "update"], label="Installing firmware", root=True) + if result.ok: + ui.note("Some devices only apply the update on the next reboot.", Level.OK) diff --git a/src/Linux/pchealth/tools/hardware.py b/src/Linux/pchealth/tools/hardware.py new file mode 100644 index 0000000..84b9b99 --- /dev/null +++ b/src/Linux/pchealth/tools/hardware.py @@ -0,0 +1,132 @@ +"""Hardware information: CPU, GPU, storage (SMART), RAM and temperatures.""" + +from __future__ import annotations + +from pathlib import Path + +from .. import probe, smart, system +from .base import Level, ToolUI + + +def _gb(kib: int) -> str: + return f"{kib / 1048576:.2f} GB" + + +def _cpu(ui: ToolUI) -> None: + ui.section("CPU") + info = probe.cpu() + ui.fields( + [ + ("Name", info.model), + ("Architecture", info.architecture), + ("Cores", str(info.cores)), + ("Threads", str(info.threads)), + ("Max speed", f"{info.max_mhz} MHz" if info.max_mhz else "N/A"), + # Sizes come from cpu0, so they are what one core sees. + *[(f"{level} cache (per core)", size) for level, size in info.caches.items()], + ("Virtualization", info.virtualization), + ] + ) + + +def _gpu(ui: ToolUI) -> None: + ui.section("GPU") + found = probe.gpus() + if found: + ui.fields([(f"GPU {n}", name) for n, name in enumerate(found, 1)]) + else: + ui.note("No display adapter found.", Level.WARN) + + +def _storage(ui: ToolUI) -> None: + ui.section("Storage") + + if not smart.available(): + disks = probe.block_devices() + if disks: + ui.fields([(d.name, f"{d.size_text} {d.kind} {d.model}".strip()) for d in disks]) + ui.note("Install smartmontools for life %, temperature and power-on hours.") + else: + ui.note("No physical disks found under /sys/block.", Level.WARN) + return + + devices = smart.devices() + if not devices: + ui.note("smartctl found no devices with usable SMART data.", Level.WARN) + return + + for device in devices: + ui.fields( + [ + ("Model", device.model), + ("Type", device.media), + ("Size", f"{device.capacity_gb} GB"), + ("Temperature", f"{device.temperature_c} C" if device.temperature_c else "N/A"), + ("Power-on hours", str(device.power_on_hours or "N/A")), + ("Life left", f"{device.life_left_pct}%" if device.life_left_pct else "N/A"), + ("Health", device.health_text), + ] + ) + if device.passed is False: + ui.note(f"{device.model} reports SMART failure. Back it up now.", Level.ERROR) + + +def _memory(ui: ToolUI) -> None: + ui.section("Memory") + memory = probe.meminfo() + total = memory.get("MemTotal", 0) + if not total: + ui.note("RAM information not available.", Level.WARN) + return + + available = memory.get("MemAvailable", 0) + cache = memory.get("Buffers", 0) + memory.get("Cached", 0) + memory.get("SReclaimable", 0) + swap_total = memory.get("SwapTotal", 0) + + ui.fields( + [ + ("Total", _gb(total)), + ("Used", _gb(total - available)), + ("Available", _gb(available)), + ("Buffers / cache", _gb(cache)), + ("Swap total", _gb(swap_total)), + ("Swap used", _gb(swap_total - memory.get("SwapFree", 0))), + ] + ) + + +def _sensors(ui: ToolUI) -> None: + """Straight from the kernel's hwmon class. + + The same source lm-sensors reads, so nothing needs to be installed. + """ + ui.section("Temperatures") + root = Path("/sys/class/hwmon") + try: + chips = sorted(root.iterdir()) + except OSError: + chips = [] + + readings: list[tuple[str, str]] = [] + 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: + ui.fields(readings) + else: + ui.note("No temperature readings exposed by this kernel.") + + +def hardware_info(ui: ToolUI) -> None: + _cpu(ui) + _gpu(ui) + _storage(ui) + _memory(ui) + _sensors(ui) diff --git a/src/Linux/pchealth/tools/logs.py b/src/Linux/pchealth/tools/logs.py new file mode 100644 index 0000000..9d39088 --- /dev/null +++ b/src/Linux/pchealth/tools/logs.py @@ -0,0 +1,56 @@ +"""Recent error and warning entries from the systemd journal.""" + +from __future__ import annotations + +from .. import system +from .base import Choice, Level, ToolUI + +_VIEWS: dict[str, tuple[Choice, list[str]]] = { + "today": ( + Choice("today", "Errors from today", "Priority err and above, since midnight"), + ["journalctl", "--priority=err", "--since=today", "--no-pager"], + ), + "recent": ( + Choice("recent", "Last 100 warnings and errors", "Priority warning and above"), + ["journalctl", "--priority=warning", "-n", "100", "--no-pager"], + ), + "boot": ( + Choice("boot", "Boot messages", "This boot, last 100 lines"), + ["journalctl", "-b", "--no-pager", "-n", "100"], + ), + "kernel": ( + Choice("kernel", "Kernel messages", "dmesg, errors and warnings"), + ["dmesg", "--level=err,warn"], + ), + "failed": ( + Choice("failed", "Failed services", "systemd units that did not start"), + ["systemctl", "--failed", "--no-legend", "--no-pager"], + ), +} + + +def system_logs(ui: ToolUI) -> None: + ui.section("System Logs") + + if not system.has("journalctl"): + ui.note("journalctl not found. This system may not use systemd.", Level.ERROR) + return + + choice = ui.choose("Which log?", [view[0] for view in _VIEWS.values()]) + if choice is None: + return + + label, argv = _VIEWS[choice] + if choice == "failed": + failed = system.output(argv) or "" + units = [line for line in failed.splitlines() if line.strip()] + if not units: + ui.note("No failed units.", Level.OK) + return + ui.fields([(unit.split()[0], " ".join(unit.split()[1:])) for unit in units]) + ui.note("Inspect one with: journalctl -u -b") + return + + # 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. + ui.run(argv, label=label.label, root=True, ok="Read") diff --git a/src/Linux/pchealth/tools/network.py b/src/Linux/pchealth/tools/network.py new file mode 100644 index 0000000..8811e78 --- /dev/null +++ b/src/Linux/pchealth/tools/network.py @@ -0,0 +1,69 @@ +"""Ping, traceroute and the network stack reset.""" + +from __future__ import annotations + +from .. import system +from .base import Level, ToolUI + +PING_TARGET = "8.8.8.8" +TRACE_TARGET = "google.com" + + +def ping_short(ui: ToolUI) -> None: + ui.section(f"Short Ping Test ({PING_TARGET})") + # -w caps the total run: without it an unreachable host with a slow DNS + # path can sit there far longer than four packets suggest. + result = ui.run( + ["ping", "-c", "4", "-W", "2", "-w", "15", PING_TARGET], + label="Sending 4 packets", + ok="Host is reachable", + failed="No usable reply", + ) + if not result.ok: + ui.note("Check your network connection.", Level.ERROR) + + +def ping_continuous(ui: ToolUI) -> None: + ui.section(f"Continuous Ping Test ({PING_TARGET})") + step = ui.step("Pinging until stopped") + code = system.stream(["ping", PING_TARGET], step.output, should_stop=ui.should_stop) + step.finish(True, f"Stopped (exit {code})") + + +def traceroute(ui: ToolUI) -> None: + ui.section(f"Traceroute to {TRACE_TARGET}") + command = next((c for c in ("traceroute", "tracepath") if system.has(c)), None) + if not command: + ui.note("Neither traceroute nor tracepath is installed.", Level.WARN) + ui.note("Install via: apt install traceroute (or dnf / pacman / zypper)") + return + + step = ui.step(f"Tracing with {command}") + code = system.stream([command, TRACE_TARGET], step.output, should_stop=ui.should_stop) + step.finish(code == 0, "Route traced" if code == 0 else f"Exit code {code}") + + +def network_reset(ui: ToolUI) -> None: + ui.section("Reset Network Stack") + if not system.has("systemctl"): + ui.note("systemctl not found. This system may not use systemd.", Level.ERROR) + return + + ui.note("The network connection will drop briefly.", Level.WARN) + if not ui.confirm("Restart networking now?"): + return + + manager_active = system.output(["systemctl", "is-active", "NetworkManager"]) == "active" + unit = "NetworkManager" if manager_active or system.has("nmcli") else "systemd-networkd" + + steps: list[tuple[str, list[str]]] = [(f"Restarting {unit}", ["systemctl", "restart", unit])] + if system.has("resolvectl"): + steps.append(("Flushing DNS cache", ["resolvectl", "flush-caches"])) + elif system.has("systemd-resolve"): + steps.append(("Flushing DNS cache", ["systemd-resolve", "--flush-caches"])) + + results = ui.run_all(steps, root=True) + if all(result.ok for result in results): + ui.note("Network reset complete.", Level.OK) + else: + ui.note("The network stack did not come back cleanly. See the steps above.", Level.ERROR) diff --git a/src/Linux/pchealth/tools/power.py b/src/Linux/pchealth/tools/power.py new file mode 100644 index 0000000..7fba74f --- /dev/null +++ b/src/Linux/pchealth/tools/power.py @@ -0,0 +1,39 @@ +"""Shutdown, reboot and log off.""" + +from __future__ import annotations + +from .. import system +from .base import Choice, Level, ToolUI + + +def power_options(ui: ToolUI) -> None: + ui.section("Power Options") + + choice = ui.choose( + "What should happen?", + [ + Choice("logoff", "Log Off", "Ends the desktop session.", destructive=True), + Choice("restart", "Restart", "Restarts the system immediately.", destructive=True), + Choice("shutdown", "Shut Down", "Powers the system off immediately.", destructive=True), + ], + ) + if choice is None: + return + + if choice == "logoff": + # Under sudo the environment describes root; log off the human instead. + user = system.desktop_user() + if not user: + ui.note("Could not determine the desktop user.", Level.ERROR) + return + if ui.confirm(f"Log off {user.name}?"): + # loginctl ends the session cleanly, unlike killing the processes. + ui.run(["loginctl", "terminate-user", user.name], label="Ending session", root=True) + return + + label, argv = { + "restart": ("Restarting", ["shutdown", "-r", "now"]), + "shutdown": ("Shutting down", ["shutdown", "-h", "now"]), + }[choice] + if ui.confirm(f"{label.rstrip('ing')}? This closes everything immediately."): + ui.run(argv, label=label, root=True) diff --git a/src/Linux/pchealth/tools/sysinfo.py b/src/Linux/pchealth/tools/sysinfo.py new file mode 100644 index 0000000..ce3f3bb --- /dev/null +++ b/src/Linux/pchealth/tools/sysinfo.py @@ -0,0 +1,114 @@ +"""System information, and the BIOS password link.""" + +from __future__ import annotations + +import os +import socket + +from .. import probe, system +from .base import Choice, Level, ToolUI + +_SECURE_BOOT_NOTE = ( + "Secure Boot shows the UEFI firmware state only. Actual enforcement " + "depends on shim/MOK setup and varies per distro." +) + + +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 _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 _session_type() -> str: + if os.environ.get("WAYLAND_DISPLAY"): + return "Wayland" + if os.environ.get("DISPLAY"): + return "X11" + return "Unknown" + + +def system_info(ui: ToolUI) -> None: + ui.section("System Information") + + memory = probe.meminfo() + total_kib = memory.get("MemTotal") + available_kib = memory.get("MemAvailable") + uname = os.uname() + user = system.desktop_user() + + ui.fields( + [ + ("Computer name", socket.gethostname()), + ("Machine", _machine_model()), + ("OS name", system.distro_info()["PRETTY_NAME"]), + ("Kernel", uname.release), + ("Architecture", uname.machine), + ("CPU", probe.cpu().model), + ( + "RAM used", + f"{(total_kib - available_kib) / 1048576:.2f} GB" + if total_kib and available_kib is not None + else "N/A", + ), + ("RAM total", f"{total_kib / 1048576:.2f} GB" if total_kib else "N/A"), + ("Firmware", "UEFI" if os.path.exists("/sys/firmware/efi") else "Legacy BIOS"), + ("Secure Boot", probe.secure_boot()), + ("Uptime", probe.uptime_text()), + ("Last boot", probe.boot_time_text()), + ( + "Desktop", + os.environ.get("XDG_CURRENT_DESKTOP") + or os.environ.get("DESKTOP_SESSION") + or "Unknown", + ), + ("Session", _session_type()), + ("Shell", os.environ.get("SHELL", "Unknown").rsplit("/", 1)[-1]), + ("Packages", _package_count()), + ("Timezone", probe.timezone()), + ("User", user.name if user else "N/A"), + ] + ) + ui.note(_SECURE_BOOT_NOTE) + + +def bios_password(ui: ToolUI) -> None: + """Links to bios-pw.org. Credits: @bacher09 -- pwgen-for-bios.""" + ui.section("BIOS Password Recovery") + ui.note( + "bios-pw.org generates recovery codes for locked BIOS passwords. " + "Credits for this tool go to @bacher09." + ) + + urls = { + "site": "https://bios-pw.org", + "repo": "https://github.com/bacher09/pwgen-for-bios", + } + choice = ui.choose( + "Which page should open?", + [ + Choice("site", "bios-pw.org", "The recovery tool itself"), + Choice("repo", "pwgen-for-bios on GitHub", "How the codes are generated"), + ], + ) + if choice is None: + return + + if not system.open_url(urls[choice]): + ui.note(f"Could not open a browser. Visit: {urls[choice]}", Level.WARN) diff --git a/src/Linux/pchealth/tools/updates.py b/src/Linux/pchealth/tools/updates.py new file mode 100644 index 0000000..85b8af7 --- /dev/null +++ b/src/Linux/pchealth/tools/updates.py @@ -0,0 +1,130 @@ +"""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 Level, ToolUI + +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(ui: ToolUI) -> None: + ui.section("Update all packages") + + manager = system.package_manager() + if not manager: + ui.note("No supported package manager found (apt/dnf/pacman/zypper).", Level.ERROR) + return + + # Refresh and list are back to back, so they share one elevation prompt. + steps: list[tuple[str, list[str]]] = [] + if manager.refresh: + steps.append(("Refreshing package index", [manager.cmd, *manager.refresh])) + steps.append(("Checking for updates", [manager.cmd, *manager.list_updates])) + + results = ui.run_all(steps, root=True) + if manager.refresh and not results[0].ok: + ui.note("Refresh failed. Check your network connection.", Level.ERROR) + return + + # 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. + lines = [ + line.strip() + for line in results[-1].stdout.splitlines() + if line.strip() and not line.startswith(("Listing", "Last metadata")) + ] + if not lines: + ui.note("Everything is already up to date.", Level.OK) + return + + ui.section(f"{len(lines)} update(s) available") + ui.fields([(line.split()[0], " ".join(line.split()[1:])) for line in lines[:PREVIEW_LINES]]) + if len(lines) > PREVIEW_LINES: + ui.note(f"... and {len(lines) - PREVIEW_LINES} more.") + + if not ui.confirm(f"Install {len(lines)} update(s)?"): + return + + result = ui.run([manager.cmd, *manager.update], label="Updating packages", root=True) + if not result.ok: + return + + # Kernel and glibc updates only take effect after a restart. + if _reboot_required(): + ui.note("A reboot is required to finish this update.", Level.WARN) + + +def topgrade(ui: ToolUI) -> None: + ui.section("Topgrade") + + if not system.has("topgrade"): + ui.note("topgrade is not installed.", Level.ERROR) + ui.note( + "Install it with your package manager: pacman -S topgrade, " + "or cargo install topgrade elsewhere." + ) + return + + user = system.desktop_user() + if not user: + ui.note("Could not determine the desktop user.", Level.ERROR) + return + + ui.note( + "topgrade upgrades packages, flatpak, VS Code extensions, uv tools, " + "gcloud, helm, firmware and more. It asks its own questions, so it " + "opens in a terminal window of its own." + ) + + # 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 + ui.run([terminal, *args, *run_command], label=f"Opening topgrade in {terminal}") + return + + ui.note("No supported terminal emulator found.", Level.ERROR) + ui.note("Install one of: " + ", ".join(TERMINALS)) 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 diff --git a/src/Windows/CLI/Start.ps1 b/src/Windows/CLI/Start.ps1 new file mode 100644 index 0000000..9565087 --- /dev/null +++ b/src/Windows/CLI/Start.ps1 @@ -0,0 +1,166 @@ +#Requires -Version 5.1 +# ============================================================================ +# 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' +$isPwsh7 = $PSVersionTable.PSVersion.Major -ge 7 + +# $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 +} + +# -- 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 +} + +$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 +} + +# 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. +} + +# -- Dependency check ---------------------------------------------------------- +Write-Host '' +Write-Host '[pcHealth] Checking dependencies...' -ForegroundColor Cyan + +$pad = 24 +function Write-DepStatus($label, $ok, [bool]$Optional = $false) { + $dots = '.' * ($pad - $label.Length) + if ($ok) { + Write-Host " $label $dots OK" -ForegroundColor Green + } elseif ($Optional) { + Write-Host " $label $dots not installed" -ForegroundColor Yellow + } else { + Write-Host " $label $dots NOT FOUND" -ForegroundColor Red + } +} + +$pwshOk = [bool](Get-Command pwsh -ErrorAction SilentlyContinue) + +$smartctlOk = (Test-Path (Join-Path $env:ProgramFiles 'smartmontools\bin\smartctl.exe')) -or + [bool](Get-Command smartctl -ErrorAction SilentlyContinue) + +Write-DepStatus 'PowerShell 7' $pwshOk +Write-DepStatus -label 'smartmontools' -ok $smartctlOk -Optional $true + +# -- Install PowerShell 7 ----------------------------------------------------- +if (-not $pwshOk) { + Write-Host '' + Write-Host '[pcHealth] PowerShell 7 is required to run this application.' -ForegroundColor Yellow + + $answer = Read-Host ' Install now via winget? [Y/N]' + if ($answer -notmatch '^[Yy]') { + Write-Host '' + Write-Host '[!!] Cannot continue without PowerShell 7.' -ForegroundColor Red + Read-Host 'Press Enter to exit' + exit 1 + } + + if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Host '[!!] winget is not available. Install PowerShell 7 manually:' -ForegroundColor Red + Write-Host ' https://aka.ms/powershell' -ForegroundColor Cyan + Read-Host 'Press Enter to exit' + exit 1 + } + + Write-Host '' + Write-Host '[pcHealth] Installing PowerShell 7...' -ForegroundColor Cyan + winget install --source winget --id Microsoft.PowerShell -e --silent ` + --accept-package-agreements --accept-source-agreements + + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path', 'User') + + if (-not (Get-Command pwsh -ErrorAction SilentlyContinue)) { + Write-Host '[!!] Installation completed but pwsh was not found. Please restart and try again.' -ForegroundColor Red + Read-Host 'Press Enter to exit' + exit 1 + } + + Write-Host '[OK] PowerShell 7 installed.' -ForegroundColor Green +} + +# -- Optional: smartmontools -------------------------------------------------- +if (-not $smartctlOk) { + Write-Host '' + 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 + + $answer = Read-Host ' Install now via winget? [Y/N]' + if ($answer -match '^[Yy]') { + 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') + ';' + + [System.Environment]::GetEnvironmentVariable('Path', 'User') + } else { + Write-Host '[!!] winget not available. Install from: https://www.smartmontools.org/' -ForegroundColor Yellow + } + + if (Get-Command smartctl -ErrorAction SilentlyContinue) { + Write-Host '[OK] smartmontools installed.' -ForegroundColor Green + } else { + Write-Host '[!!] Install may need a restart to take effect.' -ForegroundColor Yellow + } + } else { + Write-Host ' Skipping — SMART data will be limited.' -ForegroundColor DarkGray + } +} + +# -- Launch app ---------------------------------------------------------------- +Write-Host '' +Write-Host '[pcHealth] All dependencies satisfied. Starting pcHealth...' -ForegroundColor Green +Write-Host '' + +$appScript = Join-Path $PSScriptRoot 'app.ps1' +& pwsh -NoProfile -ExecutionPolicy Bypass -File $appScript diff --git a/src/Windows/CLI/app.ps1 b/src/Windows/CLI/app.ps1 new file mode 100644 index 0000000..9f51532 --- /dev/null +++ b/src/Windows/CLI/app.ps1 @@ -0,0 +1,60 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- Windows CLI +# Checks the Windows build and loads the menus. +# ============================================================================ + +$ErrorActionPreference = 'Stop' + +# -- 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 +} + +# 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 + +# 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' } + +# Order matters: Helpers must load before Main/Tools/Programs. +. (Join-Path -Path $PSScriptRoot -ChildPath 'menus' -AdditionalChildPath 'Helpers.ps1') + +. (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') + +Show-MainMenu diff --git a/src/Windows/CLI/menus/Helpers.ps1 b/src/Windows/CLI/menus/Helpers.ps1 new file mode 100644 index 0000000..ce4d21c --- /dev/null +++ b/src/Windows/CLI/menus/Helpers.ps1 @@ -0,0 +1,124 @@ +# ============================================================================ +# pcHealth -- Windows -- UI Helpers +# Display and navigation utilities used by all menu scripts. +# ============================================================================ + +# Opens a URL in the user's default browser. +function Open-PcUrl { + param([Parameter(Mandatory)][string]$Url) + try { + 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\. +function Write-PcLog { + param( + [string]$Message, + [switch]$IsError + ) + try { + $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 + $scriptName = if ($callerScript) { + [System.IO.Path]::GetFileNameWithoutExtension($callerScript) + } else { 'pcHealth' } + + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "[$timestamp] $Message" | Out-File -FilePath (Join-Path $logDir "$scriptName.log") -Append -ErrorAction Stop + } catch { + Write-Debug "Write-PcLog: failed to write to log file: $_" + } + if ($IsError) { + Write-Host $Message -ForegroundColor Red + } else { + Write-Host $Message + } +} + +function Clear-PcHost { + # [Console]::Clear() fills the entire buffer with spaces and resets the + # cursor, which avoids partial-render artifacts when colour state leaks + # out of a tool. + [Console]::ResetColor() + [Console]::Clear() +} + +$Global:PcTheme = 'Main' + +function Set-PcTheme { + param([string]$Theme) + $Global:PcTheme = $Theme + # RawUI colour changes only work in ConsoleHost; skip silently in VS Code, + # Windows Terminal with transparency, or any other non-standard host. + if ($Host.Name -ne 'ConsoleHost') { return } + switch ($Theme) { + 'Main' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Cyan' } + 'Tools' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Red' } + 'Programs' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Green' } + 'Action' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Green' } + 'Danger' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Red' } + 'Warning' { $Host.UI.RawUI.BackgroundColor = 'Black'; $Host.UI.RawUI.ForegroundColor = 'Yellow' } + } +} + +function Write-PcHeader { + param([string]$Title) + $line = '=' * 60 + $headerColor = switch ($Global:PcTheme) { + 'Main' { 'Cyan' } + 'Tools' { 'Red' } + 'Programs' { 'Green' } + default { 'Cyan' } + } + Write-Host "`n$line" -ForegroundColor $headerColor + Write-Host " pcHealth * $Global:PcPlatformLabel * $Title" -ForegroundColor $headerColor + Write-Host $line -ForegroundColor $headerColor + $fullName = try { + (Get-LocalUser -Name $env:USERNAME -ErrorAction SilentlyContinue).FullName + } catch { $null } + 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 +} + +function Write-PcDivider { + Write-Host ('-' * 60) -ForegroundColor DarkGray +} + +function Write-PcOption { + param([string]$Key, [string]$Label, [string]$Note = '') + $pad = ' ' * [Math]::Max(1, 4 - $Key.Length) + $keyColor = switch ($Global:PcTheme) { + 'Main' { 'Cyan' } + 'Tools' { 'Red' } + 'Programs' { 'Green' } + default { 'Yellow' } + } + Write-Host ' ' -NoNewline + Write-Host "[$Key]" -ForegroundColor $keyColor -NoNewline + Write-Host "$pad$Label" -NoNewline + if ($Note) { Write-Host " $Note" -ForegroundColor DarkGray -NoNewline } + Write-Host '' +} + +# Shown after every tool finishes. Returns '1', '2', or '3'. +# '1' -> stay in current submenu +# '2' -> return to main menu +# '3' -> exit the application +function Read-PcNavChoice { + param([string]$BackLabel = 'Back to previous menu') + Write-Host '' + Write-PcDivider + Write-PcOption '1' $BackLabel + Write-PcOption '2' 'Main Menu' + Write-PcOption '3' 'Exit' + Write-PcDivider + return (Read-Host "`n Choice").Trim() +} 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 69% rename from src/CLI/menus/Programs.ps1 rename to src/Windows/CLI/menus/Programs.ps1 index c136c68..151df6a 100644 --- a/src/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' } @@ -156,10 +148,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 +189,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) @@ -223,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 new file mode 100644 index 0000000..a002712 --- /dev/null +++ b/src/Windows/CLI/menus/Tools.ps1 @@ -0,0 +1,90 @@ +# ============================================================================ +# 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. + $toolDefs = @( + @{ 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) + $t = Join-Path $Global:pcHealthRoot 'tools' + + while ($true) { + Set-PcTheme 'Tools' + Clear-PcHost + Write-PcHeader 'Tools' + + for ($i = 1; $i -le $active.Count; $i++) { + Write-PcOption "$i" $active[$i - 1].Label $active[$i - 1].Note + } + + $nav1 = $active.Count + 1 + $nav2 = $active.Count + 2 + $nav3 = $active.Count + 3 + + Write-PcDivider + Write-PcOption "$nav1" 'Programs Menu' + Write-PcOption "$nav2" 'Back to Main Menu' + Write-PcOption "$nav3" 'Exit' + Write-PcDivider + + $choice = (Read-Host "`n Choice").Trim() + + $num = 0 + if (-not [int]::TryParse($choice, [ref]$num)) { + Write-Host "`n Invalid choice." -ForegroundColor Red + Start-Sleep -Milliseconds 800 + continue + } + + if ($num -ge 1 -and $num -le $active.Count) { + $entry = $active[$num - 1] + Set-PcTheme 'Action' + Clear-PcHost + try { + & (Join-Path $t $entry.Script) + } catch [System.Management.Automation.PipelineStoppedException] { + Write-Debug 'Tool stopped via Ctrl+C, returning to menu.' + } catch { + Write-Host "`n[!!] Tool error: $_`n" -ForegroundColor Red + Start-Sleep -Seconds 2 + } + $nav = Read-PcNavChoice 'Back to Tools Menu' + switch ($nav) { + '2' { return 'main' } + '3' { return 'exit' } + } + } elseif ($num -eq $nav1) { return 'programs' + } elseif ($num -eq $nav2) { return 'main' + } elseif ($num -eq $nav3) { return 'exit' + } else { + Write-Host "`n Invalid choice." -ForegroundColor Red + Start-Sleep -Milliseconds 800 + } + } +} 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/Windows/CLI/tools/Get-HardwareInfo.ps1 b/src/Windows/CLI/tools/Get-HardwareInfo.ps1 new file mode 100644 index 0000000..417bd27 --- /dev/null +++ b/src/Windows/CLI/tools/Get-HardwareInfo.ps1 @@ -0,0 +1,190 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- Hardware Information +# CPU, GPU, Storage (SMART via smartmontools), RAM, Chipset. +# ============================================================================ + +function Write-SectionHeader { + param([string]$Title) + $prefix = '--- ' + $fill = '-' * [Math]::Max(0, 90 - $prefix.Length - $Title.Length - 1) + Write-Host "`n$prefix$Title $fill" -ForegroundColor Cyan +} + +function Find-Smartctl { + $inPath = Get-Command smartctl -ErrorAction SilentlyContinue + if ($inPath) { return $inPath.Source } + $prog = "$env:ProgramFiles\smartmontools\bin\smartctl.exe" + if (Test-Path $prog) { return $prog } + return $null +} + +$smartctl = Find-Smartctl +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 + + $answer = (Read-Host ' Install now via winget? [Y/N]').Trim() + if ($answer -match '^[Yy]') { + 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') + } + $smartctl = Find-Smartctl + if ($smartctl) { + Write-Host " [OK] smartmontools installed.`n" -ForegroundColor Green + } else { + Write-Host " [!!] Install may need a restart to take effect.`n" -ForegroundColor Yellow + } + } else { + Write-Host " Skipping — SMART data will be limited.`n" -ForegroundColor DarkGray + } +} + +# -- 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 + } + 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 + } + } | 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' } + } +} + +$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/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/Windows/CLI/tools/Get-SystemInfo.ps1 b/src/Windows/CLI/tools/Get-SystemInfo.ps1 new file mode 100644 index 0000000..a4785da --- /dev/null +++ b/src/Windows/CLI/tools/Get-SystemInfo.ps1 @@ -0,0 +1,64 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- System Information +# ============================================================================ + +$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/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 93% rename from src/CLI/tools/Invoke-BootRepair.ps1 rename to src/Windows/CLI/tools/Invoke-BootRepair.ps1 index 8956baf..b8d8ab9 100644 --- a/src/CLI/tools/Invoke-BootRepair.ps1 +++ b/src/Windows/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. 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 +# fallback underneath it. # ============================================================================ if (Get-Command Set-PcTheme -ErrorAction SilentlyContinue) { 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 97% rename from src/CLI/tools/Invoke-HPUpdate.ps1 rename to src/Windows/CLI/tools/Invoke-HPUpdate.ps1 index d19aa87..e3dbc44 100644 --- a/src/CLI/tools/Invoke-HPUpdate.ps1 +++ b/src/Windows/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-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/Windows/CLI/tools/Invoke-PowerOptions.ps1 b/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 new file mode 100644 index 0000000..153cf76 --- /dev/null +++ b/src/Windows/CLI/tools/Invoke-PowerOptions.ps1 @@ -0,0 +1,39 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- Shutdown / Reboot / Log Off +# ============================================================================ + +Write-Host "`n$('=' * 60)" -ForegroundColor Cyan +Write-Host " Power Options" -ForegroundColor Cyan +Write-Host "$('=' * 60)`n" -ForegroundColor Cyan + +Write-Host " [1] Log Off" +Write-Host " [2] Restart" +Write-Host " [3] Shutdown" +Write-Host " [B] Cancel`n" + +$choice = (Read-Host " Choice").Trim().ToUpper() + +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 } +} 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 96% rename from src/CLI/tools/Invoke-SystemUpdate.ps1 rename to src/Windows/CLI/tools/Invoke-SystemUpdate.ps1 index fb1f40f..e3378a7 100644 --- a/src/CLI/tools/Invoke-SystemUpdate.ps1 +++ b/src/Windows/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 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/Windows/CLI/tools/Test-Traceroute.ps1 b/src/Windows/CLI/tools/Test-Traceroute.ps1 new file mode 100644 index 0000000..b110235 --- /dev/null +++ b/src/Windows/CLI/tools/Test-Traceroute.ps1 @@ -0,0 +1,22 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- Traceroute to Google +# ============================================================================ +param( + [string]$Target = 'google.com' +) + +Write-Host "`nTraceroute to $Target (max 30 hops)...`n" -ForegroundColor Cyan + +$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 +} diff --git a/src/Windows/GUI/.gitkeep b/src/Windows/GUI/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/GUI/Start.ps1 b/src/Windows/GUI/Make-Release.ps1 similarity index 85% rename from src/GUI/Start.ps1 rename to src/Windows/GUI/Make-Release.ps1 index c20365f..10b8b35 100644 --- a/src/GUI/Start.ps1 +++ b/src/Windows/GUI/Make-Release.ps1 @@ -1,8 +1,13 @@ #Requires -Version 5.1 # ============================================================================ -# pcHealth -- GUI Launcher (Windows) -# Checks dependencies, elevates to admin, builds and launches the WinUI 3 app. -# Stays PS 5.1-compatible so it can bootstrap dependencies on fresh systems. +# pcHealth -- GUI release build and launch (Windows) +# Checks dependencies, elevates to admin, builds the WinUI 3 app in Release +# and launches the resulting exe. Stays PS 5.1-compatible so it can bootstrap +# a fresh machine that has neither pwsh nor the .NET SDK yet. +# +# This is the "does it work like a user sees it" path. While developing use +# Run-Debug.ps1 next to this file: it builds Debug and keeps the log live in +# the terminal instead of handing you a detached exe. # ============================================================================ $ErrorActionPreference = 'Stop' @@ -11,18 +16,21 @@ $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 } -# Recommended and hard minimum Windows build versions (see README.md) +# Recommended and hard minimum Windows build versions (see README.md). +# 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) +$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 " Your build: $build" -ForegroundColor Red + Write-Host " WinUI 3 does not run on older builds." -ForegroundColor Yellow Write-Host " Update Windows and try again." -ForegroundColor Yellow Read-Host 'Press Enter to exit' exit 1 @@ -77,7 +85,7 @@ function Assert-Dep { [System.Environment]::GetEnvironmentVariable('Path', 'User') if (-not (& $IsInstalled)) { - Write-Host "[!!] $Label installed but not detected. Please restart and re-run Start.ps1." -ForegroundColor Red + Write-Host "[!!] $Label installed but not detected. Please restart and re-run Make-Release.ps1." -ForegroundColor Red Read-Host 'Press Enter to exit' exit 1 } diff --git a/src/Windows/GUI/Run-Debug.ps1 b/src/Windows/GUI/Run-Debug.ps1 new file mode 100644 index 0000000..20e058f --- /dev/null +++ b/src/Windows/GUI/Run-Debug.ps1 @@ -0,0 +1,178 @@ +#Requires -Version 7.0 +# ============================================================================ +# pcHealth -- GUI development runner (Windows) +# +# Builds Debug and runs the app with its log streaming into this terminal, +# the way an IDE does: the process stays in the foreground, every line the +# app writes appears as it happens, and the exit code is decoded when it +# stops. +# +# A WinUI 3 app is a GUI subsystem binary, so it has no console of its own +# and Console.WriteLine goes nowhere. The log is the live feed: NLog.config +# already writes every line to +# %LOCALAPPDATA%\pcHealth\pcHealth_.log, and this tails it from the +# byte where this run started, so nothing from earlier runs is shown. +# +# Use Make-Release.ps1 next to this file for the Release build a user gets, +# and for bootstrapping a machine that has no dependencies yet. +# +# Usage: +# pwsh -File src/Windows/GUI/Run-Debug.ps1 +# ============================================================================ + +$ErrorActionPreference = 'Stop' + +# -- Elevate ------------------------------------------------------------------- +# pcHealth's manifest is requireAdministrator, and Start-Process cannot both +# elevate and redirect output. So this window elevates itself first and then +# starts the app as an ordinary child, which keeps the redirection. +$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator +) +if (-not $isAdmin) { + $forwarded = @('-NoExit', '-ExecutionPolicy', 'Bypass', '-NoProfile', '-File', $PSCommandPath) + + Write-Host '[pcHealth] Elevating; the live log continues in the new window.' -ForegroundColor Yellow + Start-Process -FilePath (Get-Process -Id $PID).Path -Verb RunAs -ArgumentList $forwarded + exit +} + +# -- Project paths ------------------------------------------------------------- +if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + Write-Host '[!!] No .NET SDK on PATH. Run Make-Release.ps1 once to install it.' -ForegroundColor Red + exit 1 +} + +$projectFile = Join-Path $PSScriptRoot 'pcHealth\pcHealth.csproj' + +$rid = if ([System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture -eq + [System.Runtime.InteropServices.Architecture]::Arm64) { 'win-arm64' } else { 'win-x64' } + +# Read the TargetFramework from the csproj so this path never drifts from it. +$tfm = ([xml](Get-Content $projectFile)).Project.PropertyGroup.TargetFramework | + Where-Object { $_ } | Select-Object -First 1 +$exePath = Join-Path $PSScriptRoot "pcHealth\bin\Debug\$tfm\$rid\pcHealth.exe" + +# -- Build --------------------------------------------------------------------- +Write-Host '' +Write-Host "[pcHealth] Building Debug ($rid)..." -ForegroundColor Cyan +dotnet build $projectFile -c Debug -r $rid --nologo -v minimal +if ($LASTEXITCODE -ne 0) { + Write-Host '' + Write-Host '[!!] Build failed. The errors are above.' -ForegroundColor Red + exit 1 +} + +# -- Log tail ------------------------------------------------------------------ +# Reads whole lines only: a writer can be mid-line, and the rest arrives on the +# next pass. Byte offsets rather than characters, so the count stays exact. +function Show-LogTail { + param( + [Parameter(Mandatory)] [string] $Path, + [Parameter(Mandatory)] [ref] $Offset + ) + + if (-not (Test-Path $Path)) { return } + + $stream = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') + try { + # NLog rolls the file at midnight; start over rather than seek past it. + if ($stream.Length -lt $Offset.Value) { $Offset.Value = 0 } + if ($stream.Length -eq $Offset.Value) { return } + + $null = $stream.Seek($Offset.Value, [System.IO.SeekOrigin]::Begin) + $buffer = [byte[]]::new([int] ($stream.Length - $Offset.Value)) + $read = $stream.Read($buffer, 0, $buffer.Length) + $text = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $read) + } + finally { $stream.Dispose() } + + $cut = $text.LastIndexOf("`n") + if ($cut -lt 0) { return } + + $complete = $text.Substring(0, $cut + 1) + $Offset.Value += [System.Text.Encoding]::UTF8.GetByteCount($complete) + + foreach ($line in ($complete -split "`r?`n")) { + if (-not $line) { continue } + $colour = switch -Regex ($line) { + '\[(FATAL|ERROR)\]' { 'Red'; break } + '\[WARN\]' { 'Yellow'; break } + '\[INFO\]' { 'White'; break } + default { 'DarkGray' } + } + Write-Host $line -ForegroundColor $colour + } +} + +$logDir = Join-Path $env:LOCALAPPDATA 'pcHealth' +$logFile = Join-Path $logDir ('pcHealth_{0}.log' -f (Get-Date -Format 'yyyy-MM-dd')) + +# Start where today's log currently ends, so only this run is shown. +$offset = if (Test-Path $logFile) { (Get-Item $logFile).Length } else { 0 } + +# A GUI binary writes nothing here on a good day, but the CLR prints an +# unhandled exception to stderr on its way out, which is worth keeping. +$stdoutFile = Join-Path $env:TEMP 'pcHealth-dev-stdout.log' +$stderrFile = Join-Path $env:TEMP 'pcHealth-dev-stderr.log' + +Write-Host '' +Write-Host "[pcHealth] Running : $exePath" -ForegroundColor Green +Write-Host "[pcHealth] Log : $logFile" -ForegroundColor DarkGray +Write-Host '[pcHealth] Ctrl+C stops the app and this runner.' -ForegroundColor DarkGray +Write-Host '' + +$proc = Start-Process -FilePath $exePath -PassThru ` + -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + +try { + while (-not $proc.HasExited) { + Show-LogTail -Path $logFile -Offset ([ref] $offset) + Start-Sleep -Milliseconds 250 + } +} +finally { + if (-not $proc.HasExited) { + Write-Host '' + Write-Host '[pcHealth] Stopping the app...' -ForegroundColor Yellow + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + } + # Whatever was written between the last pass and the exit. + Show-LogTail -Path $logFile -Offset ([ref] $offset) +} + +# -- Exit ---------------------------------------------------------------------- +foreach ($capture in @(@{ Label = 'stdout'; Path = $stdoutFile }, @{ Label = 'stderr'; Path = $stderrFile })) { + if ((Test-Path $capture.Path) -and (Get-Item $capture.Path).Length -gt 0) { + Write-Host '' + Write-Host "[pcHealth] $($capture.Label):" -ForegroundColor Magenta + Get-Content $capture.Path | ForEach-Object { Write-Host " $_" -ForegroundColor Magenta } + } + Remove-Item $capture.Path -ErrorAction SilentlyContinue +} + +$code = $proc.ExitCode +$hex = '0x{0:X8}' -f $code + +# A native crash never reaches a catch block, so the exit code is the only +# thing that names it. These are the ones worth recognising on sight. +$reason = switch ($hex) { + '0xC0000005' { 'access violation -- a native crash, which no catch block can hold' } + '0xC0000409' { 'fail-fast or stack buffer overrun' } + '0xC000013A' { 'terminated by Ctrl+C' } + '0xE0434352' { 'unhandled .NET exception' } + default { '' } +} + +Write-Host '' +if ($code -eq 0) { + Write-Host '[pcHealth] Exited cleanly (0).' -ForegroundColor Green +} +else { + Write-Host "[!!] Exited with $code ($hex)" -ForegroundColor Red + if ($reason) { Write-Host " $reason" -ForegroundColor Red } + Write-Host ' Windows records these under Event Viewer > Windows Logs >' -ForegroundColor Yellow + Write-Host ' Application, source "Application Error" or ".NET Runtime".' -ForegroundColor Yellow +} + +exit $code 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 87% rename from src/GUI/pcHealth/App.xaml.cs rename to src/Windows/GUI/pcHealth/App.xaml.cs index dab5e1e..4523830 100644 --- a/src/GUI/pcHealth/App.xaml.cs +++ b/src/Windows/GUI/pcHealth/App.xaml.cs @@ -35,6 +35,13 @@ private static void ConfigureServices() s.AddSingleton(); s.AddSingleton(); + // Singletons: WinGetComClient probes the COM server once and caches + // the answer, so every page shares that one decision. + s.AddSingleton(); + s.AddSingleton(); + s.AddSingleton(); + s.AddSingleton(); + // ViewModels — Transient: elke navigatie krijgt een frisse instantie s.AddTransient(); s.AddTransient(); @@ -62,6 +69,7 @@ private static void ConfigureServices() s.AddTransient(); s.AddTransient(); s.AddTransient(); + s.AddTransient(); s.AddTransient(); Services = s.BuildServiceProvider(); 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 50% rename from src/GUI/pcHealth/Helpers/DialogHelper.cs rename to src/Windows/GUI/pcHealth/Helpers/DialogHelper.cs index 8f3761d..50b57f1 100644 --- a/src/GUI/pcHealth/Helpers/DialogHelper.cs +++ b/src/Windows/GUI/pcHealth/Helpers/DialogHelper.cs @@ -24,4 +24,27 @@ internal static async Task ShowErrorAsync(XamlRoot xamlRoot, string title, strin }; await dialog.ShowAsync(); } + + /// + /// Asks the user to confirm something consequential. The close button is + /// the default, so leaning on Enter cannot start anything. + /// Must be called on the UI thread. + /// + internal static async Task ShowConfirmAsync( + XamlRoot xamlRoot, + string title, + string message, + string confirmText) + { + var dialog = new ContentDialog + { + Title = title, + Content = message, + PrimaryButtonText = confirmText, + CloseButtonText = "Cancel", + DefaultButton = ContentDialogButton.Close, + XamlRoot = xamlRoot, + }; + return await dialog.ShowAsync() == ContentDialogResult.Primary; + } } 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 85% rename from src/GUI/pcHealth/MainWindow.xaml.cs rename to src/Windows/GUI/pcHealth/MainWindow.xaml.cs index 3e7236b..a861ef3 100644 --- a/src/GUI/pcHealth/MainWindow.xaml.cs +++ b/src/Windows/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)) @@ -94,6 +100,10 @@ private void NavView_BackRequested(NavigationView sender, NavigationViewBackRequ private void ContentFrame_Navigated(object sender, NavigationEventArgs args) { NavView.IsBackEnabled = ContentFrame.CanGoBack; + + // Every menu click and every drill-in passes through here, so this one + // line is what makes a log read like a session rather than a crash dump. + Log.Info("Navigated to {Page}", args.SourcePageType.Name); } internal void NavigateTo(string? tag) 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 53% rename from src/GUI/pcHealth/Models/ProgramItem.cs rename to src/Windows/GUI/pcHealth/Models/ProgramItem.cs index c5edc6b..74fd076 100644 --- a/src/GUI/pcHealth/Models/ProgramItem.cs +++ b/src/Windows/GUI/pcHealth/Models/ProgramItem.cs @@ -37,6 +37,47 @@ public bool IsInstalled } } + // Installing used to open a console window. The card shows the progress + // and the outcome itself now, so nothing has to pop up over the app. + private bool _isBusy; + public bool IsBusy + { + get => _isBusy; + set + { + if (_isBusy == value) return; + _isBusy = value; + Notify(nameof(IsBusy)); + Notify(nameof(IsIdle)); + Notify(nameof(BusyVisibility)); + } + } + + public bool IsIdle => !_isBusy; + + // The page binds Visibility properties rather than converting bools, which + // is how NoteVisibility below already does it. + public Microsoft.UI.Xaml.Visibility BusyVisibility => + _isBusy ? Microsoft.UI.Xaml.Visibility.Visible : Microsoft.UI.Xaml.Visibility.Collapsed; + + private string _status = ""; + public string Status + { + get => _status; + set + { + if (_status == value) return; + _status = value; + Notify(nameof(Status)); + Notify(nameof(StatusVisibility)); + } + } + + public Microsoft.UI.Xaml.Visibility StatusVisibility => + string.IsNullOrEmpty(Status) + ? Microsoft.UI.Xaml.Visibility.Collapsed + : Microsoft.UI.Xaml.Visibility.Visible; + public string ButtonLabel => IsInstalled ? "Installed" : string.IsNullOrEmpty(WingetId) ? "Open Download Page" : 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 71% rename from src/GUI/pcHealth/Pages/BootRepairPage.xaml rename to src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml index f20f7e8..e151155 100644 --- a/src/GUI/pcHealth/Pages/BootRepairPage.xaml +++ b/src/Windows/GUI/pcHealth/Pages/BootRepairPage.xaml @@ -1,4 +1,4 @@ - + - + @@ -35,17 +35,18 @@ Foreground="{ThemeResource TextFillColorSecondaryBrush}"/> - - - + + + + - + 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 69% rename from src/GUI/pcHealth/Pages/HPUpdatePage.xaml rename to src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml index 460e2c2..966b38d 100644 --- a/src/GUI/pcHealth/Pages/HPUpdatePage.xaml +++ b/src/Windows/GUI/pcHealth/Pages/HPUpdatePage.xaml @@ -1,4 +1,4 @@ - + - + @@ -30,23 +30,19 @@